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.
This commit is contained in:
2026-10-23 11:00:59 +08:00
parent bd7871a800
commit 4a19336445
17 changed files with 1523 additions and 1 deletions
+22
View File
@@ -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",
+2
View File
@@ -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,
];
+2
View File
@@ -17,6 +17,7 @@ import {
TbOutlineLink,
TbOutlineMapPin,
TbOutlinePhoto,
TbOutlinePrinter,
TbOutlineSparkles,
TbOutlineSquare,
TbOutlineTable,
@@ -34,6 +35,7 @@ const DOC_ICONS: Record<string, IconTypes> = {
border: TbOutlineSquare,
embed: TbOutlineFileText,
deck: TbOutlineCards,
print: TbOutlinePrinter,
yarn: TbOutlineGitBranch,
gm: TbOutlineTheater,
player: TbOutlineDice5,
+1
View File
@@ -8,6 +8,7 @@ import "./md-border";
import "./md-font";
import "./md-embed";
import "./md-deck";
import "./md-print";
import "./md-yarn-spinner";
// 导出组件
+256
View File
@@ -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<string> {
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<HTMLElement[]>([]);
const [paginating, setPaginating] = createSignal(false);
const [warnings, setWarnings] = createSignal<string[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [scale, setScale] = createSignal(0.8);
const [sourceEl, setSourceEl] = createSignal<HTMLElement>();
const [mountEl, setMountEl] = createSignal<HTMLElement>();
const [sheetEl, setSheetEl] = createSignal<HTMLElement>();
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 (
<Portal>
<div class="fixed inset-0 bg-black/60 z-60 flex flex-col">
<div class="shrink-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3">
<TbOutlinePrinter class="text-xl text-gray-600 shrink-0" />
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900 truncate">
{title()}
</div>
<div class="text-xs text-gray-500">
{pages().length} 页 · A4 横版 · 每页 {props.config.columns} 列
</div>
</div>
<div class="flex-1" />
<button
onClick={() => void runPagination()}
disabled={paginating()}
class="px-3 py-1.5 rounded text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50"
>
<TbOutlineRefresh class="inline-block align-[-0.125em]" /> 重新分页
</button>
<button
onClick={() => void handleExport()}
disabled={paginating() || pages().length === 0}
class="px-3 py-1.5 rounded text-sm font-medium bg-green-100 text-green-700 hover:bg-green-200 disabled:opacity-50"
>
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 打印 /
导出 PDF
</button>
<button
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
title="关闭"
>
<TbOutlineX />
</button>
</div>
<div class="flex-1 overflow-auto bg-gray-100 p-6">
<Show when={content.loading || paginating()}>
<div class="text-center text-gray-500 py-20">正在分页…</div>
</Show>
<Show when={content.error || error()}>
<div class="text-center text-red-500 py-20">
{error() || content.error?.message}
</div>
</Show>
<Show when={warnings().length > 0}>
<div class="max-w-3xl mx-auto mb-4 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
<For each={warnings()}>{(warning) => <div>· {warning}</div>}</For>
</div>
</Show>
{/* Hidden measuring container: one column wide, same typography. */}
<article
ref={setSourceEl}
data-src={props.articlePath}
class="md-print-content prose"
style={{
width: `${columnWidthMm(props.config)}mm`,
"--md-print-font-size": `${props.config.font}pt`,
position: "absolute",
left: "-99999px",
top: "0",
visibility: "hidden",
"pointer-events": "none",
}}
/>
{/* Hidden mount for pages while they are being measured. */}
<div
ref={setMountEl}
style={{
position: "absolute",
left: "-99999px",
top: "0",
visibility: "hidden",
"pointer-events": "none",
}}
/>
<div
ref={setSheetEl}
style={{ "--md-print-scale": String(scale()) }}
/>
</div>
</div>
</Portal>
);
}
+52
View File
@@ -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,
});
});
});
+52
View File
@@ -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<Record<keyof PrintConfig, unknown>> = {},
): 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),
};
}
+84
View File
@@ -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<PrintProps>(
"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<string, unknown> = {};
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 (
<div class="md-print mb-4">
<div class="flex items-center gap-3 border border-gray-200 rounded-lg px-4 py-3 bg-linear-to-r from-slate-50 to-white">
<TbOutlinePrinter class="text-xl text-gray-500 shrink-0" />
<div class="min-w-0">
<div class="text-sm font-medium text-gray-800">打印版式</div>
<div class="text-xs text-gray-500 truncate">
{displayName}
{section ? `#${section}` : ""}
</div>
</div>
<div class="flex-1" />
<button
onClick={() => setIsOpen(true)}
class="px-3 py-1.5 rounded text-sm font-medium bg-green-100 text-green-700 hover:bg-green-200"
>
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 打印预览
/ 导出 PDF
</button>
</div>
<Show when={isOpen()}>
<PrintPreview
src={resolvedPath}
section={section}
articlePath={resolvedPath}
config={config}
onClose={() => setIsOpen(false)}
/>
</Show>
</div>
);
},
);
+39
View File
@@ -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 <strong>brave new</strong> 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");
});
});
+629
View File
@@ -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 `<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}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<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) || "文本节点";
}
+118
View File
@@ -0,0 +1,118 @@
import type { PrintConfig } from "./types";
import { createPageElement } from "./paginate";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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<void> {
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(`
<div class="md-print-sheet">
${first}
${second}
${config.cutMarks ? '<div class="md-print-cut-line"></div>' : ""}
</div>
`);
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
${styles}
<style>
@page { size: A4 landscape; margin: 0; }
html, body { margin: 0; padding: 0; background: white; }
.md-print-sheet {
width: 297mm;
height: 210mm;
display: flex;
position: relative;
background: white;
page-break-after: always;
break-after: page;
}
.md-print-sheet:last-child {
page-break-after: auto;
break-after: auto;
}
.md-print-cut-line {
position: absolute;
left: 148.5mm;
top: 0;
bottom: 0;
border-left: 0.2mm dashed #999;
pointer-events: none;
}
[data-print-hide] { display: none !important; }
md-dice, md-link, md-pins, md-font, md-bg, md-border { display: inline; }
md-table, md-deck, md-embed, md-print, md-yarn-spinner { display: block; }
</style>
</head>
<body>
${sheets.join("\n")}
</body>
</html>
`);
printWindow.document.close();
await new Promise<void>((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<void>((resolve) => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), { once: true });
}),
),
);
printWindow.focus();
printWindow.print();
}
+26
View File
@@ -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;
}
+1 -1
View File
@@ -172,7 +172,7 @@ customElement(
return (
<div class="ttrpg-table">
<div class="flex items-center gap-2 border-b border-gray-200">
<div class="flex items-center gap-2 border-b border-gray-200" data-print-hide>
<div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */}
<Show when={hasGroup()}>
+64
View File
@@ -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 表格。
+123
View File
@@ -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 {