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 `
` (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}mm`); 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 || opts.timestamp) { 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 { 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) || "文本节点"; }