feat(md-print): add A4/A5 multi-column print layout
Add an md-print element that paginates markdown into A4 landscape sheets holding two A5 pages, each with configurable columns. The preview modal exports through the browser print dialog. Paragraphs, lists, and tables split at word, item, and row boundaries; headings are kept with following content.
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
import type { PrintConfig } from "./types";
|
||||
|
||||
/** A5 portrait dimensions. Two of these fill an A4 landscape sheet exactly. */
|
||||
export const PAGE_WIDTH_MM = 148.5;
|
||||
export const PAGE_HEIGHT_MM = 210;
|
||||
|
||||
export interface PaginateOptions extends PrintConfig {
|
||||
/** Path of the markdown being printed, used by nested components. */
|
||||
articlePath: string;
|
||||
}
|
||||
|
||||
export interface PaginateResult {
|
||||
pages: HTMLElement[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface SplitResult {
|
||||
head: Node;
|
||||
tail: Node | null;
|
||||
}
|
||||
|
||||
export function mmToPx(mm: number): number {
|
||||
return (mm * 96) / 25.4;
|
||||
}
|
||||
|
||||
export function contentWidthMm(config: PrintConfig): number {
|
||||
return PAGE_WIDTH_MM - config.margin * 2;
|
||||
}
|
||||
|
||||
export function contentHeightMm(config: PrintConfig): number {
|
||||
return PAGE_HEIGHT_MM - config.margin * 2;
|
||||
}
|
||||
|
||||
export function columnWidthMm(config: PrintConfig): number {
|
||||
const width = contentWidthMm(config);
|
||||
return (width - (config.columns - 1) * config.gap) / config.columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one A5 page: an `<article data-src>` (so nested components can
|
||||
* resolve relative paths) containing a fixed-height multi-column flow.
|
||||
*/
|
||||
export function createPageElement(opts: PaginateOptions): {
|
||||
page: HTMLElement;
|
||||
flow: HTMLElement;
|
||||
} {
|
||||
const page = document.createElement("article");
|
||||
page.className = "md-print-page";
|
||||
page.setAttribute("data-src", opts.articlePath);
|
||||
page.style.width = `${PAGE_WIDTH_MM}mm`;
|
||||
page.style.height = `${PAGE_HEIGHT_MM}mm`;
|
||||
page.style.padding = `${opts.margin}mm`;
|
||||
page.style.boxSizing = "border-box";
|
||||
page.style.position = "relative";
|
||||
page.style.overflow = "hidden";
|
||||
page.style.background = "white";
|
||||
page.style.setProperty("--md-print-font-size", `${opts.font}pt`);
|
||||
|
||||
if (opts.title) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "md-print-header";
|
||||
header.textContent = opts.title;
|
||||
page.appendChild(header);
|
||||
}
|
||||
|
||||
const flow = document.createElement("div");
|
||||
flow.className = "md-print-flow md-print-content prose";
|
||||
flow.style.columnCount = String(opts.columns);
|
||||
flow.style.columnGap = `${opts.gap}mm`;
|
||||
flow.style.columnFill = "auto";
|
||||
flow.style.height = "100%";
|
||||
flow.style.width = "100%";
|
||||
page.appendChild(flow);
|
||||
|
||||
if (opts.pageNumbers) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "md-print-footer";
|
||||
page.appendChild(footer);
|
||||
}
|
||||
|
||||
return { page, flow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow the children of `source` into A5 pages.
|
||||
*
|
||||
* The algorithm is greedy: append a block to the current page and measure.
|
||||
* If it overflows, try to split it at a word / list-item / table-row
|
||||
* boundary so the remaining space is used; otherwise start a new page.
|
||||
* Blocks are moved (not cloned) so custom elements keep their state.
|
||||
*/
|
||||
export function paginate(
|
||||
source: HTMLElement,
|
||||
opts: PaginateOptions,
|
||||
mount?: HTMLElement,
|
||||
): PaginateResult {
|
||||
const pages: HTMLElement[] = [];
|
||||
const warnings: string[] = [];
|
||||
const queue: Node[] = Array.from(source.childNodes).filter(isMeaningfulNode);
|
||||
|
||||
const newPage = () => {
|
||||
const created = createPageElement(opts);
|
||||
// Pages must be in the document for getBoundingClientRect() to work.
|
||||
mount?.appendChild(created.page);
|
||||
pages.push(created.page);
|
||||
return created;
|
||||
};
|
||||
|
||||
// Wrap stray text nodes so they can be measured and moved as blocks.
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const node = queue[i];
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const p = document.createElement("p");
|
||||
p.appendChild(node);
|
||||
queue[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
let current = newPage();
|
||||
let i = 0;
|
||||
let guard = 0;
|
||||
|
||||
while (i < queue.length) {
|
||||
if (++guard > 100000) {
|
||||
warnings.push("分页循环次数过多,已中止。");
|
||||
break;
|
||||
}
|
||||
|
||||
const node = queue[i];
|
||||
current.flow.appendChild(node);
|
||||
|
||||
if (!overflows(current.flow)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current.flow.removeChild(node as ChildNode);
|
||||
|
||||
const split = splitToFit(node, current.flow);
|
||||
if (split) {
|
||||
current.flow.appendChild(split.head);
|
||||
if (split.tail && hasContent(split.tail)) {
|
||||
queue.splice(i, 1, split.tail);
|
||||
current = newPage();
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.flow.childNodes.length > 0) {
|
||||
// Avoid leaving a heading alone at the bottom of the last column.
|
||||
const heading = takeTrailingHeading(current.flow, opts);
|
||||
if (heading) queue.splice(i, 0, heading);
|
||||
current = newPage();
|
||||
continue;
|
||||
}
|
||||
|
||||
// The block is taller than a whole page and cannot be split.
|
||||
current.flow.appendChild(node);
|
||||
warnings.push(`内容块过高,已允许溢出:${describeNode(node)}`);
|
||||
i++;
|
||||
current = newPage();
|
||||
}
|
||||
|
||||
if (pages.length > 1) {
|
||||
const last = pages[pages.length - 1];
|
||||
const lastFlow = last.querySelector(".md-print-flow");
|
||||
if (lastFlow && !lastFlow.hasChildNodes()) pages.pop();
|
||||
}
|
||||
|
||||
return { pages, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until the element's scroll size has been stable for `quietMs`.
|
||||
* Used to wait for async custom elements (tables, embeds) and images.
|
||||
*/
|
||||
export async function waitForStableSize(
|
||||
el: HTMLElement,
|
||||
quietMs = 300,
|
||||
maxMs = 4000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let last = "";
|
||||
let lastChange = Date.now();
|
||||
|
||||
while (Date.now() - start < maxMs) {
|
||||
const size = `${el.scrollHeight}x${el.scrollWidth}`;
|
||||
if (size !== last) {
|
||||
last = size;
|
||||
lastChange = Date.now();
|
||||
} else if (Date.now() - lastChange >= quietMs) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A block overflows when its last fragment extends past the flow's right
|
||||
* (an extra column) or bottom (taller than the column) edge.
|
||||
*/
|
||||
function overflows(flow: HTMLElement): boolean {
|
||||
// Overflow columns extend the scroll width of a fixed-height multicol box.
|
||||
if (flow.scrollWidth > flow.clientWidth + 1) return true;
|
||||
|
||||
const rect = flow.getBoundingClientRect();
|
||||
const last = flow.lastElementChild;
|
||||
if (!last) return false;
|
||||
const lastRect = last.getBoundingClientRect();
|
||||
return lastRect.right > rect.right + 0.5 || lastRect.bottom > rect.bottom + 0.5;
|
||||
}
|
||||
|
||||
function takeTrailingHeading(
|
||||
flow: HTMLElement,
|
||||
opts: PaginateOptions,
|
||||
): HTMLElement | null {
|
||||
const last = flow.lastElementChild;
|
||||
if (!last || !/^H[1-6]$/.test(last.tagName)) return null;
|
||||
if (flow.childNodes.length <= 1) return null;
|
||||
|
||||
const flowRect = flow.getBoundingClientRect();
|
||||
const lastRect = last.getBoundingClientRect();
|
||||
const gapPx = mmToPx(opts.gap);
|
||||
const columnWidthPx =
|
||||
(flowRect.width - (opts.columns - 1) * gapPx) / opts.columns;
|
||||
const lastColumnLeft =
|
||||
flowRect.left + (opts.columns - 1) * (columnWidthPx + gapPx);
|
||||
|
||||
// Only move headings that sit in the final column; a heading in an earlier
|
||||
// column still has content after it on the same page.
|
||||
if (lastRect.left < lastColumnLeft - 0.5) return null;
|
||||
|
||||
flow.removeChild(last);
|
||||
return last as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPLITTABLE_CONTAINERS = new Set([
|
||||
"section",
|
||||
"article",
|
||||
"aside",
|
||||
"details",
|
||||
"figure",
|
||||
]);
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
"p",
|
||||
"div",
|
||||
"ul",
|
||||
"ol",
|
||||
"table",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"section",
|
||||
"article",
|
||||
"aside",
|
||||
"details",
|
||||
"figure",
|
||||
]);
|
||||
|
||||
function splitToFit(node: Node, flow: HTMLElement): SplitResult | null {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
if (tag === "p") {
|
||||
return splitTextElementToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "div" || tag === "blockquote" || tag === "li") {
|
||||
return hasBlockChildren(el)
|
||||
? splitContainerToFit(el, flow)
|
||||
: splitTextElementToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
return splitListToFit(el, flow);
|
||||
}
|
||||
|
||||
if (tag === "table") {
|
||||
return splitTableToFit(el, flow);
|
||||
}
|
||||
|
||||
if (SPLITTABLE_CONTAINERS.has(tag)) {
|
||||
return splitContainerToFit(el, flow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasBlockChildren(el: HTMLElement): boolean {
|
||||
return Array.from(el.children).some((child) =>
|
||||
BLOCK_TAGS.has(child.tagName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function splitTextElementToFit(
|
||||
el: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const total = countWords(el);
|
||||
if (total < 2) return null;
|
||||
|
||||
let lo = 1;
|
||||
let hi = total - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const [head] = splitTextElementAt(el, mid);
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const [head, tail] = splitTextElementAt(el, best);
|
||||
trimLeadingWhitespace(tail);
|
||||
removeEmptyInline(tail);
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an element's inline content after `wordCount` words, preserving
|
||||
* inline markup (strong/em/code/links) via a DOM Range.
|
||||
*/
|
||||
export function splitTextElementAt(
|
||||
el: HTMLElement,
|
||||
wordCount: number,
|
||||
): [HTMLElement, HTMLElement] {
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
const boundary = findWordBoundary(el, wordCount);
|
||||
if (!boundary) {
|
||||
head.appendChild(el.cloneNode(true));
|
||||
return [head, tail];
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(el, 0);
|
||||
range.setEnd(boundary.node, boundary.offset);
|
||||
head.appendChild(range.cloneContents());
|
||||
|
||||
range.setStart(boundary.node, boundary.offset);
|
||||
range.setEnd(el, el.childNodes.length);
|
||||
tail.appendChild(range.cloneContents());
|
||||
|
||||
return [head, tail];
|
||||
}
|
||||
|
||||
function countWords(el: Node): number {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let count = 0;
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const matches = (node.textContent || "").match(/\S+/g);
|
||||
if (matches) count += matches.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function findWordBoundary(
|
||||
el: Node,
|
||||
wordCount: number,
|
||||
): { node: Text; offset: number } | null {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let count = 0;
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || "";
|
||||
const re = /\S+/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text))) {
|
||||
count++;
|
||||
if (count === wordCount) {
|
||||
return { node: node as Text, offset: match.index + match[0].length };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitListToFit(
|
||||
list: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const items = Array.from(list.children).filter(
|
||||
(child) => child.tagName === "LI",
|
||||
) as HTMLElement[];
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
if (items.length === 1) {
|
||||
const split = splitToFit(items[0], flow);
|
||||
if (!split) return null;
|
||||
const headList = list.cloneNode(false) as HTMLElement;
|
||||
headList.appendChild(split.head);
|
||||
const tailList = list.cloneNode(false) as HTMLElement;
|
||||
if (split.tail) tailList.appendChild(split.tail);
|
||||
return { head: headList, tail: hasContent(tailList) ? tailList : null };
|
||||
}
|
||||
|
||||
let lo = 1;
|
||||
let hi = items.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = list.cloneNode(false) as HTMLElement;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
head.appendChild(items[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = list.cloneNode(false) as HTMLElement;
|
||||
const tail = list.cloneNode(false) as HTMLElement;
|
||||
items.forEach((item, index) => {
|
||||
(index < best ? head : tail).appendChild(item.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
function splitTableToFit(
|
||||
table: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const tbody = table.querySelector("tbody") || table;
|
||||
const rows = Array.from(tbody.children).filter(
|
||||
(child) => child.tagName === "TR",
|
||||
) as HTMLElement[];
|
||||
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
let lo = 1;
|
||||
let hi = rows.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = cloneTableShell(table);
|
||||
const headBody = head.querySelector("tbody")!;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
headBody.appendChild(rows[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = cloneTableShell(table);
|
||||
const headBody = head.querySelector("tbody")!;
|
||||
const tail = cloneTableShell(table);
|
||||
const tailBody = tail.querySelector("tbody")!;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
(index < best ? headBody : tailBody).appendChild(row.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
function cloneTableShell(table: HTMLElement): HTMLElement {
|
||||
const clone = table.cloneNode(false) as HTMLElement;
|
||||
for (const child of Array.from(table.children)) {
|
||||
const tag = child.tagName;
|
||||
if (tag === "THEAD" || tag === "COLGROUP" || tag === "CAPTION") {
|
||||
clone.appendChild(child.cloneNode(true));
|
||||
}
|
||||
}
|
||||
const tbody = document.createElement("tbody");
|
||||
clone.appendChild(tbody);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function splitContainerToFit(
|
||||
el: HTMLElement,
|
||||
flow: HTMLElement,
|
||||
): SplitResult | null {
|
||||
const children = Array.from(el.childNodes).filter(isMeaningfulNode);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
if (children.length === 1) {
|
||||
const only = children[0];
|
||||
if (only.nodeType === Node.ELEMENT_NODE) {
|
||||
const split = splitToFit(only, flow);
|
||||
if (split) {
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
head.appendChild(split.head);
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
if (split.tail) tail.appendChild(split.tail);
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let lo = 1;
|
||||
let hi = children.length - 1;
|
||||
let best = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
for (let i = 0; i < mid; i++) {
|
||||
head.appendChild(children[i].cloneNode(true));
|
||||
}
|
||||
flow.appendChild(head);
|
||||
const fits = !overflows(flow);
|
||||
flow.removeChild(head);
|
||||
if (fits) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best === 0) return null;
|
||||
|
||||
const head = el.cloneNode(false) as HTMLElement;
|
||||
const tail = el.cloneNode(false) as HTMLElement;
|
||||
children.forEach((child, index) => {
|
||||
(index < best ? head : tail).appendChild(child.cloneNode(true));
|
||||
});
|
||||
|
||||
return { head, tail: hasContent(tail) ? tail : null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isMeaningfulNode(node: Node): boolean {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return !!node.textContent?.trim();
|
||||
}
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
function trimLeadingWhitespace(el: Node): void {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent || "";
|
||||
if (!text.trim()) {
|
||||
node.textContent = "";
|
||||
continue;
|
||||
}
|
||||
node.textContent = text.replace(/^\s+/, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyInline(el: Element): void {
|
||||
for (const child of Array.from(el.children)) {
|
||||
removeEmptyInline(child);
|
||||
if (
|
||||
child.children.length === 0 &&
|
||||
!child.textContent?.trim() &&
|
||||
!["IMG", "BR", "HR", "SVG"].includes(child.tagName)
|
||||
) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasContent(node: Node): boolean {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return !!node.textContent?.trim();
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||
const el = node as HTMLElement;
|
||||
if (["IMG", "BR", "HR", "SVG", "VIDEO", "CANVAS"].includes(el.tagName)) {
|
||||
return true;
|
||||
}
|
||||
if (el.textContent?.trim()) return true;
|
||||
return el.querySelector("img, svg, video, canvas, hr, br") !== null;
|
||||
}
|
||||
|
||||
function describeNode(node: Node): string {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
const text = (el.textContent || "").trim().slice(0, 40);
|
||||
return `<${el.tagName.toLowerCase()}>${text ? ` ${text}…` : ""}`;
|
||||
}
|
||||
return (node.textContent || "").trim().slice(0, 40) || "文本节点";
|
||||
}
|
||||
Reference in New Issue
Block a user