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 {