630 lines
17 KiB
TypeScript
630 lines
17 KiB
TypeScript
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}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<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) || "文本节点";
|
|
}
|