From 4a19336445fd26fa941e48fbbc4ee4ab70b5b9aa Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 23 Oct 2026 11:00:59 +0800 Subject: [PATCH] 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. --- .agents/skills/tttk-authoring/reference.md | 26 + docs/reference.md | 26 + src/cli/validate/known.ts | 22 + src/components/doc-data.ts | 2 + src/components/doc-icons.ts | 2 + src/components/index.ts | 1 + src/components/md-print/PrintPreview.tsx | 256 +++++++++ src/components/md-print/config.test.ts | 52 ++ src/components/md-print/config.ts | 52 ++ src/components/md-print/index.tsx | 84 +++ src/components/md-print/paginate.test.ts | 39 ++ src/components/md-print/paginate.ts | 629 +++++++++++++++++++++ src/components/md-print/print-export.ts | 118 ++++ src/components/md-print/types.ts | 26 + src/components/md-table.tsx | 2 +- src/doc-entries/md-print.md | 64 +++ src/styles.css | 123 ++++ 17 files changed, 1523 insertions(+), 1 deletion(-) create mode 100644 src/components/md-print/PrintPreview.tsx create mode 100644 src/components/md-print/config.test.ts create mode 100644 src/components/md-print/config.ts create mode 100644 src/components/md-print/index.tsx create mode 100644 src/components/md-print/paginate.test.ts create mode 100644 src/components/md-print/paginate.ts create mode 100644 src/components/md-print/print-export.ts create mode 100644 src/components/md-print/types.ts create mode 100644 src/doc-entries/md-print.md diff --git a/.agents/skills/tttk-authoring/reference.md b/.agents/skills/tttk-authoring/reference.md index 6654ed9..ed0975d 100644 --- a/.agents/skills/tttk-authoring/reference.md +++ b/.agents/skills/tttk-authoring/reference.md @@ -48,6 +48,7 @@ Nest with more colons (`::::`, `:::::`). | `:md-dice` | dice formula | `key` | | `:md-table` | CSV path or inline id | `roll`, `remix` | | `:md-deck` | CSV path | `size`, `grid`, `layers` | +| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` | | `:md-link` | path, optional #section | — | | `:md-embed` | path, optional #section | `headingBase` | | `:md-bg` | image path or CSS color | `fit` | @@ -111,6 +112,31 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l :md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"} ``` +### `:md-print` + +Paginates a markdown document into A4 landscape sheets for printing. + +Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin` and `gap` are in millimetres, `font` is in points. `pageNumbers` adds an `n / total` footer and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale). + +- Body: markdown path, optional #section +- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` + +```markdown +:md-print[./rules.md]{columns="2" margin="10" gap="5" font="9"} +``` + +````markdown +```yaml role=tag +tag: md-print +body: ./rules.md +columns: 2 +margin: 10 +gap: 5 +font: 9 +title: 规则书 +``` +```` + ### `:md-link` Clickable link that expands the target article inline. diff --git a/docs/reference.md b/docs/reference.md index 6654ed9..ed0975d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -48,6 +48,7 @@ Nest with more colons (`::::`, `:::::`). | `:md-dice` | dice formula | `key` | | `:md-table` | CSV path or inline id | `roll`, `remix` | | `:md-deck` | CSV path | `size`, `grid`, `layers` | +| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` | | `:md-link` | path, optional #section | — | | `:md-embed` | path, optional #section | `headingBase` | | `:md-bg` | image path or CSS color | `fit` | @@ -111,6 +112,31 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l :md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"} ``` +### `:md-print` + +Paginates a markdown document into A4 landscape sheets for printing. + +Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin` and `gap` are in millimetres, `font` is in points. `pageNumbers` adds an `n / total` footer and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale). + +- Body: markdown path, optional #section +- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` + +```markdown +:md-print[./rules.md]{columns="2" margin="10" gap="5" font="9"} +``` + +````markdown +```yaml role=tag +tag: md-print +body: ./rules.md +columns: 2 +margin: 10 +gap: 5 +font: 9 +title: 规则书 +``` +```` + ### `:md-link` Clickable link that expands the target article inline. diff --git a/src/cli/validate/known.ts b/src/cli/validate/known.ts index 78a03a9..c17ec69 100644 --- a/src/cli/validate/known.ts +++ b/src/cli/validate/known.ts @@ -67,6 +67,28 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [ ':md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}', ], }, + { + name: "md-print", + body: "markdown path, optional #section", + attrs: [ + "columns", + "margin", + "gap", + "font", + "title", + "pageNumbers", + "cutMarks", + ], + summary: + "Paginates a markdown document into A4 landscape sheets for printing.", + details: + "Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin` and `gap` are in millimetres, `font` is in points. `pageNumbers` adds an `n / total` footer and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).", + refKind: "md", + examples: [ + ':md-print[./rules.md]{columns="2" margin="10" gap="5" font="9"}', + "```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 9\ntitle: 规则书\n```", + ], + }, { name: "md-link", body: "path, optional #section", diff --git a/src/components/doc-data.ts b/src/components/doc-data.ts index 4b0fbe7..a89fa1c 100644 --- a/src/components/doc-data.ts +++ b/src/components/doc-data.ts @@ -48,6 +48,7 @@ import mdBgRaw from "../doc-entries/md-bg.md"; import mdBorderRaw from "../doc-entries/md-border.md"; import mdEmbedRaw from "../doc-entries/md-embed.md"; import mdDeckRaw from "../doc-entries/md-deck.md"; +import mdPrintRaw from "../doc-entries/md-print.md"; import mdYarnRaw from "../doc-entries/md-yarn-spinner.md"; const rawDocuments: string[] = [ @@ -60,6 +61,7 @@ const rawDocuments: string[] = [ mdBorderRaw, mdEmbedRaw, mdDeckRaw, + mdPrintRaw, mdYarnRaw, ]; diff --git a/src/components/doc-icons.ts b/src/components/doc-icons.ts index 8d35464..45bc2ab 100644 --- a/src/components/doc-icons.ts +++ b/src/components/doc-icons.ts @@ -17,6 +17,7 @@ import { TbOutlineLink, TbOutlineMapPin, TbOutlinePhoto, + TbOutlinePrinter, TbOutlineSparkles, TbOutlineSquare, TbOutlineTable, @@ -34,6 +35,7 @@ const DOC_ICONS: Record = { border: TbOutlineSquare, embed: TbOutlineFileText, deck: TbOutlineCards, + print: TbOutlinePrinter, yarn: TbOutlineGitBranch, gm: TbOutlineTheater, player: TbOutlineDice5, diff --git a/src/components/index.ts b/src/components/index.ts index 2c70043..d91bb9b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,7 @@ import "./md-border"; import "./md-font"; import "./md-embed"; import "./md-deck"; +import "./md-print"; import "./md-yarn-spinner"; // 导出组件 diff --git a/src/components/md-print/PrintPreview.tsx b/src/components/md-print/PrintPreview.tsx new file mode 100644 index 0000000..6f55f93 --- /dev/null +++ b/src/components/md-print/PrintPreview.tsx @@ -0,0 +1,256 @@ +import { + createEffect, + createResource, + createSignal, + For, + onCleanup, + onMount, + Show, +} from "solid-js"; +import { Portal } from "solid-js/web"; +import { + TbOutlineDownload, + TbOutlinePrinter, + TbOutlineRefresh, + TbOutlineX, +} from "solid-icons/tb"; +import { getIndexedData } from "../../data-loader/file-index"; +import { extractSection } from "../../data-loader"; +import { parseMarkdown } from "../../markdown"; +import type { PrintConfig } from "./types"; +import { + columnWidthMm, + createPageElement, + mmToPx, + paginate, + waitForStableSize, + type PaginateOptions, +} from "./paginate"; +import { exportPrintPDF } from "./print-export"; + +export interface PrintPreviewProps { + src: string; + section?: string; + articlePath: string; + config: PrintConfig; + onClose: () => void; +} + +async function fetchPrintContent(params: { + src: string; + section?: string; +}): Promise { + const text = await getIndexedData(params.src); + return params.section ? extractSection(text, params.section) : text; +} + +export function PrintPreview(props: PrintPreviewProps) { + const [content] = createResource( + () => ({ src: props.src, section: props.section }), + fetchPrintContent, + ); + + const [pages, setPages] = createSignal([]); + const [paginating, setPaginating] = createSignal(false); + const [warnings, setWarnings] = createSignal([]); + const [error, setError] = createSignal(null); + const [scale, setScale] = createSignal(0.8); + const [sourceEl, setSourceEl] = createSignal(); + const [mountEl, setMountEl] = createSignal(); + const [sheetEl, setSheetEl] = createSignal(); + + let running = false; + + const title = () => + props.config.title || props.src.split("/").pop() || "打印预览"; + + const options = (): PaginateOptions => ({ + ...props.config, + articlePath: props.articlePath, + }); + + const runPagination = async () => { + const md = content(); + const source = sourceEl(); + const mount = mountEl(); + if (md === undefined || !source || !mount || running) return; + + running = true; + setPaginating(true); + setError(null); + setWarnings([]); + setPages([]); + mount.innerHTML = ""; + + try { + source.innerHTML = parseMarkdown(md, props.articlePath); + await document.fonts.ready; + await waitForStableSize(source, 300, 4000); + + const result = paginate(source, options(), mount); + result.pages.forEach((page, index) => { + const footer = page.querySelector(".md-print-footer"); + if (footer) footer.textContent = `${index + 1} / ${result.pages.length}`; + }); + setPages(result.pages); + setWarnings(result.warnings); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + running = false; + setPaginating(false); + } + }; + + createEffect(() => { + if (content() !== undefined && sourceEl() && mountEl()) { + void runPagination(); + } + }); + + createEffect(() => { + const container = sheetEl(); + const pageList = pages(); + if (!container) return; + + container.innerHTML = ""; + for (let i = 0; i < pageList.length; i += 2) { + const sheet = document.createElement("div"); + sheet.className = "md-print-sheet md-print-sheet--preview"; + sheet.appendChild(pageList[i]); + if (pageList[i + 1]) { + sheet.appendChild(pageList[i + 1]); + } else { + sheet.appendChild(createPageElement(options()).page); + } + if (props.config.cutMarks) { + const line = document.createElement("div"); + line.className = "md-print-cut-line"; + sheet.appendChild(line); + } + const wrapper = document.createElement("div"); + wrapper.className = "md-print-sheet-wrapper"; + wrapper.appendChild(sheet); + container.appendChild(wrapper); + } + }); + + onMount(() => { + const container = sheetEl(); + if (container) { + const observer = new ResizeObserver(() => { + const available = container.clientWidth; + if (available <= 0) return; + setScale(Math.min(1, available / mmToPx(297))); + }); + observer.observe(container); + onCleanup(() => observer.disconnect()); + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") props.onClose(); + }; + document.addEventListener("keydown", onKeyDown); + onCleanup(() => document.removeEventListener("keydown", onKeyDown)); + }); + + const handleExport = async () => { + try { + await exportPrintPDF(pages(), props.config, title(), props.articlePath); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + return ( + +
+
+ +
+
+ {title()} +
+
+ {pages().length} 页 · A4 横版 · 每页 {props.config.columns} 列 +
+
+
+ + + +
+ +
+ +
正在分页…
+
+ + +
+ {error() || content.error?.message} +
+
+ + 0}> +
+ {(warning) =>
· {warning}
}
+
+
+ + {/* Hidden measuring container: one column wide, same typography. */} +
+ + {/* Hidden mount for pages while they are being measured. */} +
+ +
+
+
+ + ); +} diff --git a/src/components/md-print/config.test.ts b/src/components/md-print/config.test.ts new file mode 100644 index 0000000..3b13d42 --- /dev/null +++ b/src/components/md-print/config.test.ts @@ -0,0 +1,52 @@ +import { normalizePrintConfig, PRINT_DEFAULTS } from "./config"; + +describe("normalizePrintConfig", () => { + test("uses defaults", () => { + expect(normalizePrintConfig()).toEqual(PRINT_DEFAULTS); + }); + + test("parses directive strings and clamps values", () => { + const config = normalizePrintConfig({ + columns: "3", + margin: "100", + gap: "-5", + font: "20", + pageNumbers: "false", + cutMarks: "no", + }); + + expect(config.columns).toBe(3); + expect(config.margin).toBe(40); + expect(config.gap).toBe(0); + expect(config.font).toBe(16); + expect(config.pageNumbers).toBe(false); + expect(config.cutMarks).toBe(false); + }); + + test("clamps columns to 1..4", () => { + expect(normalizePrintConfig({ columns: "0" }).columns).toBe(1); + expect(normalizePrintConfig({ columns: "9" }).columns).toBe(4); + }); + + test("accepts YAML data-config numbers and booleans", () => { + const config = normalizePrintConfig({ + columns: 2, + margin: 12, + gap: 6, + font: 10, + title: "规则书", + pageNumbers: true, + cutMarks: false, + }); + + expect(config).toEqual({ + columns: 2, + margin: 12, + gap: 6, + font: 10, + title: "规则书", + pageNumbers: true, + cutMarks: false, + }); + }); +}); diff --git a/src/components/md-print/config.ts b/src/components/md-print/config.ts new file mode 100644 index 0000000..f9d5c61 --- /dev/null +++ b/src/components/md-print/config.ts @@ -0,0 +1,52 @@ +import type { PrintConfig } from "./types"; + +export const PRINT_DEFAULTS: PrintConfig = { + columns: 2, + margin: 10, + gap: 5, + font: 9, + title: "", + pageNumbers: true, + cutMarks: true, +}; + +function toNumber(value: unknown, fallback: number): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function toBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const s = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(s)) return true; + if (["false", "0", "no", "off"].includes(s)) return false; + } + return fallback; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Normalize directive attributes / YAML `data-config` into a usable config. + * Values may arrive as strings (directive attributes) or numbers/booleans + * (YAML `data-config`), so everything is coerced and clamped. + */ +export function normalizePrintConfig( + input: Partial> = {}, +): PrintConfig { + return { + columns: Math.round( + clamp(toNumber(input.columns, PRINT_DEFAULTS.columns), 1, 4), + ), + margin: clamp(toNumber(input.margin, PRINT_DEFAULTS.margin), 5, 40), + gap: clamp(toNumber(input.gap, PRINT_DEFAULTS.gap), 0, 20), + font: clamp(toNumber(input.font, PRINT_DEFAULTS.font), 6, 16), + title: + typeof input.title === "string" ? input.title : PRINT_DEFAULTS.title, + pageNumbers: toBoolean(input.pageNumbers, PRINT_DEFAULTS.pageNumbers), + cutMarks: toBoolean(input.cutMarks, PRINT_DEFAULTS.cutMarks), + }; +} diff --git a/src/components/md-print/index.tsx b/src/components/md-print/index.tsx new file mode 100644 index 0000000..a6dc781 --- /dev/null +++ b/src/components/md-print/index.tsx @@ -0,0 +1,84 @@ +import { customElement, noShadowDOM } from "solid-element"; +import { createSignal, Show } from "solid-js"; +import { TbOutlineDownload, TbOutlinePrinter } from "solid-icons/tb"; +import { resolvePath } from "../utils/path"; +import { normalizePrintConfig } from "./config"; +import type { PrintProps } from "./types"; +import { PrintPreview } from "./PrintPreview"; + +customElement( + "md-print", + { + columns: "2", + margin: "10", + gap: "5", + font: "9", + title: "", + pageNumbers: "true", + cutMarks: "true", + }, + (props, { element }) => { + noShadowDOM(); + + const rawPath = element?.textContent?.trim() || ""; + if (element) element.textContent = ""; + + // Resolve the printed markdown relative to the current article. + const articleEl = element?.closest("article[data-src]"); + const articlePath = articleEl?.getAttribute("data-src") || ""; + + const hashIndex = rawPath.indexOf("#"); + const filePath = hashIndex >= 0 ? rawPath.slice(0, hashIndex) : rawPath; + const section = hashIndex >= 0 ? rawPath.slice(hashIndex + 1) : undefined; + const resolvedPath = resolvePath(articlePath, filePath); + + // Structured config from a `yaml role=tag` block's `data-config`. + let dataConfig: Record = {}; + const dataConfigAttr = element?.getAttribute("data-config"); + if (dataConfigAttr) { + try { + dataConfig = JSON.parse(dataConfigAttr); + } catch (e) { + console.error("Invalid data-config on md-print:", e); + } + } + + const config = normalizePrintConfig({ ...props, ...dataConfig }); + const [isOpen, setIsOpen] = createSignal(false); + + const displayName = filePath.split("/").pop() || filePath; + + return ( +
+
+ +
+
打印版式
+
+ {displayName} + {section ? `#${section}` : ""} +
+
+
+ +
+ + + setIsOpen(false)} + /> + +
+ ); + }, +); diff --git a/src/components/md-print/paginate.test.ts b/src/components/md-print/paginate.test.ts new file mode 100644 index 0000000..d5e01bd --- /dev/null +++ b/src/components/md-print/paginate.test.ts @@ -0,0 +1,39 @@ +/** + * @jest-environment jsdom + */ + +import { splitTextElementAt } from "./paginate"; + +describe("splitTextElementAt", () => { + test("splits at a word boundary", () => { + const p = document.createElement("p"); + p.textContent = "one two three four"; + + const [head, tail] = splitTextElementAt(p, 2); + + expect(head.textContent).toBe("one two"); + expect(tail.textContent).toBe(" three four"); + }); + + test("preserves inline markup", () => { + const p = document.createElement("p"); + p.innerHTML = "Hello brave new world"; + + const [head, tail] = splitTextElementAt(p, 3); + + expect(head.textContent).toBe("Hello brave new"); + expect(tail.textContent).toBe(" world"); + expect(head.querySelector("strong")?.textContent).toBe("brave new"); + }); + + test("keeps the element's attributes on both halves", () => { + const p = document.createElement("p"); + p.className = "lead"; + p.textContent = "alpha beta gamma"; + + const [head, tail] = splitTextElementAt(p, 1); + + expect(head.className).toBe("lead"); + expect(tail.className).toBe("lead"); + }); +}); diff --git a/src/components/md-print/paginate.ts b/src/components/md-print/paginate.ts new file mode 100644 index 0000000..bc24ae1 --- /dev/null +++ b/src/components/md-print/paginate.ts @@ -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 `
` (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 { + 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) || "文本节点"; +} diff --git a/src/components/md-print/print-export.ts b/src/components/md-print/print-export.ts new file mode 100644 index 0000000..81972d5 --- /dev/null +++ b/src/components/md-print/print-export.ts @@ -0,0 +1,118 @@ +import type { PrintConfig } from "./types"; +import { createPageElement } from "./paginate"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Open a new window with the paginated sheets and trigger the browser's + * print dialog. The user can then print or "Save as PDF". + */ +export async function exportPrintPDF( + pages: HTMLElement[], + config: PrintConfig, + title: string, + articlePath: string, +): Promise { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + throw new Error("无法打开新窗口,请允许弹出窗口"); + } + + const styles = Array.from( + document.querySelectorAll('style, link[rel="stylesheet"]'), + ) + .map((el) => el.outerHTML) + .join("\n"); + + const blankPage = createPageElement({ + ...config, + articlePath, + }).page; + + const sheets: string[] = []; + for (let i = 0; i < pages.length; i += 2) { + const first = pages[i]?.outerHTML ?? blankPage.outerHTML; + const second = pages[i + 1]?.outerHTML ?? blankPage.outerHTML; + sheets.push(` +
+ ${first} + ${second} + ${config.cutMarks ? '
' : ""} +
+ `); + } + + printWindow.document.write(` + + + + + ${escapeHtml(title)} + ${styles} + + + + ${sheets.join("\n")} + + + `); + + printWindow.document.close(); + + await new Promise((resolve) => { + if (printWindow.document.readyState === "complete") { + resolve(); + } else { + printWindow.addEventListener("load", () => resolve(), { once: true }); + } + }); + + await printWindow.document.fonts.ready; + + await Promise.all( + Array.from(printWindow.document.images).map((img) => + img.complete + ? Promise.resolve() + : new Promise((resolve) => { + img.addEventListener("load", () => resolve(), { once: true }); + img.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); + + printWindow.focus(); + printWindow.print(); +} diff --git a/src/components/md-print/types.ts b/src/components/md-print/types.ts new file mode 100644 index 0000000..10c0a5a --- /dev/null +++ b/src/components/md-print/types.ts @@ -0,0 +1,26 @@ +export interface PrintConfig { + /** Number of columns per A5 page. */ + columns: number; + /** Page margin in millimetres. */ + margin: number; + /** Column gap in millimetres. */ + gap: number; + /** Body font size in points. */ + font: number; + /** Running header text. Empty hides the header. */ + title: string; + /** Show `n / total` in the page footer. */ + pageNumbers: boolean; + /** Draw the centre cut line between the two A5 pages. */ + cutMarks: boolean; +} + +export interface PrintProps { + columns?: string | number; + margin?: string | number; + gap?: string | number; + font?: string | number; + title?: string; + pageNumbers?: string | boolean; + cutMarks?: string | boolean; +} diff --git a/src/components/md-table.tsx b/src/components/md-table.tsx index 081c2f5..992a85a 100644 --- a/src/components/md-table.tsx +++ b/src/components/md-table.tsx @@ -172,7 +172,7 @@ customElement( return (
-
+
{/* 分组 tabs */} diff --git a/src/doc-entries/md-print.md b/src/doc-entries/md-print.md new file mode 100644 index 0000000..08895e2 --- /dev/null +++ b/src/doc-entries/md-print.md @@ -0,0 +1,64 @@ +--- +tag: md-print +icon: print +title: 打印版式 +--- + +将 Markdown 文档排版为可打印的 A4 横版页面:每张 A4 放两个 A5 页面,每个 A5 页面再分成多列。 + +**语法:** `:md-print[路径#章节]{选项}` + +**基本用法:** + +```markdown +:md-print[./rules.md] +``` + +**自定义版式:** + +```markdown +:md-print[./rules.md]{columns="2" margin="10" gap="5" font="9"} +``` + +**YAML 写法:** + +```yaml +tag: md-print +body: ./rules.md +columns: 2 +margin: 10 +gap: 5 +font: 9 +title: 规则书 +``` + +点击组件上的「打印预览 / 导出 PDF」按钮打开预览。预览中会显示分页后的 A4 横版页面,可以直接打印或另存为 PDF。 + +## 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `columns` | number | `2` | 每个 A5 页面的列数(1–4) | +| `margin` | number | `10` | 页面边距(毫米) | +| `gap` | number | `5` | 列间距(毫米) | +| `font` | number | `9` | 正文字号(磅) | +| `title` | string | — | 页眉标题 | +| `pageNumbers` | boolean | `true` | 是否显示 `n / 总数` 页码 | +| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 | + +## 打印设置 + +在浏览器打印对话框中: + +- 纸张:**A4** +- 方向:**横向** +- 边距:**无** +- 缩放:**100%** +- 勾选「背景图形」(如果内容包含背景色) + +## 分页说明 + +- 内容按块级元素分页;段落、列表和表格可以在分页处拆分。 +- 标题不会单独留在页面底部,会自动移到下一页。 +- 预览中的分页结果与打印结果一致。 +- `md-table` 只打印当前选中的标签页内容;需要完整表格时请使用普通 Markdown 表格。 diff --git a/src/styles.css b/src/styles.css index c00e0d4..e6dfb91 100644 --- a/src/styles.css +++ b/src/styles.css @@ -166,6 +166,129 @@ icon.big .icon-label-stroke { -webkit-tap-highlight-color: transparent; } +/* md-print — A4 landscape sheet with two A5 pages, each in N columns */ + +.md-print-sheet { + width: 297mm; + height: 210mm; + display: flex; + position: relative; + background: white; +} + +.md-print-sheet-wrapper { + width: calc(297mm * var(--md-print-scale, 1)); + height: calc(210mm * var(--md-print-scale, 1)); + margin: 0 auto 24px; +} + +.md-print-sheet--preview { + transform: scale(var(--md-print-scale, 1)); + transform-origin: top left; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); +} + +.md-print-page { + width: 148.5mm; + height: 210mm; + box-sizing: border-box; + position: relative; + overflow: hidden; + background: white; +} + +.md-print-flow { + column-fill: auto; + height: 100%; + width: 100%; +} + +.md-print-content { + font-size: var(--md-print-font-size, 9pt); + line-height: 1.45; + color: #000; + max-width: none; +} + +/* Nested prose (md-table body, md-embed) should inherit the print size. */ +.md-print-content .prose { + font-size: inherit; + line-height: inherit; + color: inherit; + max-width: none; +} + +.md-print-content :where(p, ul, ol, blockquote, table, pre, figure) { + margin-top: 0.45em; + margin-bottom: 0.45em; +} + +.md-print-content :where(h1, h2, h3, h4, h5, h6) { + margin-top: 0.8em; + margin-bottom: 0.3em; + break-after: avoid; +} + +.md-print-content img { + max-width: 100%; + height: auto; +} + +.md-print-content table { + width: 100%; + font-size: 0.9em; +} + +.md-print-content .ttrpg-table .p-4 { + padding: 0.5em; +} + +.md-print-content pre { + white-space: pre-wrap; + word-break: break-word; +} + +.md-print-cut-line { + position: absolute; + left: 148.5mm; + top: 0; + bottom: 0; + border-left: 0.2mm dashed #d1d5db; + pointer-events: none; +} + +.md-print-header, +.md-print-footer { + position: absolute; + left: 0; + right: 0; + text-align: center; + font-size: 8pt; + color: #6b7280; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.md-print-header { + top: 3mm; +} + +.md-print-footer { + bottom: 3mm; +} + +@media print { + .md-print-sheet--preview { + transform: none; + box-shadow: none; + } + + [data-print-hide] { + display: none !important; + } +} + /* heading flash highlight */ @keyframes heading-flash {