Files
ttrpg-tools/src/components/md-print/paginate.ts
T

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) || "文本节点";
}