Compare commits

..
5 Commits
Author SHA1 Message Date
hypercross 2ba6ed74c7 fix(md-print): correct alert padding 2026-10-23 20:19:39 +08:00
hypercross 8957e3d2f1 feat(md-print): add timestamp footer option 2026-10-23 13:26:34 +08:00
hypercross 1ce76dca64 feat(md-print): use mm font and corner page numbers
Body font size is now specified in millimetres and defaults to 3mm. Page numbers move from the centre footer to the outer bottom corners, alternating left and right per A5 page.
2026-10-23 11:28:22 +08:00
hypercross 80da5364e5 docs: document md-print usage 2026-10-23 11:01:16 +08:00
hypercross 4a19336445 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.
2026-10-23 11:00:59 +08:00
20 changed files with 1713 additions and 2 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: tttk-authoring name: tttk-authoring
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-pins, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`. description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-print, :md-pins, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
--- ---
# Authoring tttk game design documents # Authoring tttk game design documents
@@ -82,6 +82,9 @@ marker, and fence attribute keys may contain hyphens (e.g. `data-foo=bar`):
- For card decks, use the `yaml role=tag` form with `tag: md-deck` (see - For card decks, use the `yaml role=tag` form with `tag: md-deck` (see
`yaml-tag.md`). The `:md-deck[...]{...}` directive is legacy and can't express `yaml-tag.md`). The `:md-deck[...]{...}` directive is legacy and can't express
template layers or card backs. template layers or card backs.
- To print a rules document, use `:md-print[./rules.md]` (or the `yaml role=tag`
form). It paginates into A4 landscape sheets holding two A5 pages, each with
`columns` columns (default 2). See `reference.md` for the attributes.
- Keep CSV files clean — header row plus data. Put deck config and templates in - Keep CSV files clean — header row plus data. Put deck config and templates in
the YAML block's `data-config`, not in CSV front matter (see `csv.md`). the YAML block's `data-config`, not in CSV front matter (see `csv.md`).
- `{{prop}}` in a CSV `body` interpolates another column (or front-matter key) - `{{prop}}` in a CSV `body` interpolates another column (or front-matter key)
@@ -48,6 +48,7 @@ Nest with more colons (`::::`, `:::::`).
| `:md-dice` | dice formula | `key` | | `:md-dice` | dice formula | `key` |
| `:md-table` | CSV path or inline id | `roll`, `remix` | | `:md-table` | CSV path or inline id | `roll`, `remix` |
| `:md-deck` | CSV path | `size`, `grid`, `layers` | | `:md-deck` | CSV path | `size`, `grid`, `layers` |
| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
| `:md-link` | path, optional #section | — | | `:md-link` | path, optional #section | — |
| `:md-embed` | path, optional #section | `headingBase` | | `:md-embed` | path, optional #section | `headingBase` |
| `:md-bg` | image path or CSS color | `fit` | | `:md-bg` | image path or CSS color | `fit` |
@@ -111,6 +112,36 @@ 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-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`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that 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`, `timestamp`, `cutMarks`
```markdown
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
```
```markdown
:md-print[./rules.md]{timestamp=true}
```
````markdown
```yaml role=tag
tag: md-print
body: ./rules.md
columns: 2
margin: 10
gap: 5
font: 3
title: 规则书
timestamp: true
```
````
### `:md-link` ### `:md-link`
Clickable link that expands the target article inline. Clickable link that expands the target article inline.
+1
View File
@@ -37,6 +37,7 @@ npx tttk check ./content
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 | | 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 | | 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) | | 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
| 🖨️ 打印版式 | `:md-print[./rules.md]` | A4 横版双 A5、多列排版并导出 PDF |
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 | | 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
## 技术栈 ## 技术栈
+51
View File
@@ -458,6 +458,57 @@ data-config:
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。 每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
### 🖨️ 打印版式组件 (md-print)
将 Markdown 文档排版为可打印的 A4 横版页面:每张 A4 放两个 A5 页面,每个 A5 页面再分成多列。
```markdown
:md-print[./rules.md]
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
```
**功能:**
- 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分
- 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列
- 预览中可直接打印或另存为 PDF
- 支持页眉标题、页码、导出时间戳和中缝裁切线
**属性:**
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `columns` | number | `2` | 每个 A5 页面的列数(1–4) |
| `margin` | number | `10` | 页面边距(毫米) |
| `gap` | number | `5` | 列间距(毫米) |
| `font` | number | `3` | 正文字号(毫米) |
| `title` | string | - | 页眉标题 |
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 |
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
**YAML 写法:**
```yaml
tag: md-print
body: ./rules.md
columns: 2
margin: 10
gap: 5
font: 3
title: 规则书
timestamp: true
```
**导出时间戳:**
```markdown
:md-print[./rules.md]{timestamp=true}
```
开启后页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。
**打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。
### 🧶 叙事线组件 (md-yarn-spinner) ### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
+31
View File
@@ -48,6 +48,7 @@ Nest with more colons (`::::`, `:::::`).
| `:md-dice` | dice formula | `key` | | `:md-dice` | dice formula | `key` |
| `:md-table` | CSV path or inline id | `roll`, `remix` | | `:md-table` | CSV path or inline id | `roll`, `remix` |
| `:md-deck` | CSV path | `size`, `grid`, `layers` | | `:md-deck` | CSV path | `size`, `grid`, `layers` |
| `:md-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
| `:md-link` | path, optional #section | — | | `:md-link` | path, optional #section | — |
| `:md-embed` | path, optional #section | `headingBase` | | `:md-embed` | path, optional #section | `headingBase` |
| `:md-bg` | image path or CSS color | `fit` | | `:md-bg` | image path or CSS color | `fit` |
@@ -111,6 +112,36 @@ 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-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`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that 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`, `timestamp`, `cutMarks`
```markdown
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
```
```markdown
:md-print[./rules.md]{timestamp=true}
```
````markdown
```yaml role=tag
tag: md-print
body: ./rules.md
columns: 2
margin: 10
gap: 5
font: 3
title: 规则书
timestamp: true
```
````
### `:md-link` ### `:md-link`
Clickable link that expands the target article inline. Clickable link that expands the target article inline.
+24
View File
@@ -67,6 +67,30 @@ 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"}', ':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",
"timestamp",
"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`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that 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="3"}',
':md-print[./rules.md]{timestamp=true}',
"```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 3\ntitle: 规则书\ntimestamp: true\n```",
],
},
{ {
name: "md-link", name: "md-link",
body: "path, optional #section", 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 mdBorderRaw from "../doc-entries/md-border.md";
import mdEmbedRaw from "../doc-entries/md-embed.md"; import mdEmbedRaw from "../doc-entries/md-embed.md";
import mdDeckRaw from "../doc-entries/md-deck.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"; import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
const rawDocuments: string[] = [ const rawDocuments: string[] = [
@@ -60,6 +61,7 @@ const rawDocuments: string[] = [
mdBorderRaw, mdBorderRaw,
mdEmbedRaw, mdEmbedRaw,
mdDeckRaw, mdDeckRaw,
mdPrintRaw,
mdYarnRaw, mdYarnRaw,
]; ];
+2
View File
@@ -17,6 +17,7 @@ import {
TbOutlineLink, TbOutlineLink,
TbOutlineMapPin, TbOutlineMapPin,
TbOutlinePhoto, TbOutlinePhoto,
TbOutlinePrinter,
TbOutlineSparkles, TbOutlineSparkles,
TbOutlineSquare, TbOutlineSquare,
TbOutlineTable, TbOutlineTable,
@@ -34,6 +35,7 @@ const DOC_ICONS: Record<string, IconTypes> = {
border: TbOutlineSquare, border: TbOutlineSquare,
embed: TbOutlineFileText, embed: TbOutlineFileText,
deck: TbOutlineCards, deck: TbOutlineCards,
print: TbOutlinePrinter,
yarn: TbOutlineGitBranch, yarn: TbOutlineGitBranch,
gm: TbOutlineTheater, gm: TbOutlineTheater,
player: TbOutlineDice5, player: TbOutlineDice5,
+1
View File
@@ -8,6 +8,7 @@ import "./md-border";
import "./md-font"; import "./md-font";
import "./md-embed"; import "./md-embed";
import "./md-deck"; import "./md-deck";
import "./md-print";
import "./md-yarn-spinner"; import "./md-yarn-spinner";
// 导出组件 // 导出组件
+282
View File
@@ -0,0 +1,282 @@
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";
function formatTimestamp(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
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 applyFooter = (pageList: HTMLElement[], timestamp: string) => {
pageList.forEach((page, index) => {
const footer = page.querySelector(".md-print-footer");
if (!footer) return;
const pageLabel = props.config.pageNumbers
? `${index + 1} / ${pageList.length}`
: "";
footer.textContent = [pageLabel, timestamp].filter(Boolean).join(" · ");
// Left A5 gets the left corner, right A5 gets the right corner.
footer.classList.add(
index % 2 === 0
? "md-print-footer--left"
: "md-print-footer--right",
);
});
};
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);
applyFooter(
result.pages,
props.config.timestamp ? formatTimestamp(new Date()) : "",
);
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 {
// Refresh the timestamp so the exported file records the export time.
if (props.config.timestamp) {
applyFooter(pages(), formatTimestamp(new Date()));
}
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}mm`,
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>
);
}
+66
View File
@@ -0,0 +1,66 @@
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",
timestamp: "true",
cutMarks: "no",
});
expect(config.columns).toBe(3);
expect(config.margin).toBe(40);
expect(config.gap).toBe(0);
expect(config.font).toBe(6);
expect(config.pageNumbers).toBe(false);
expect(config.timestamp).toBe(true);
expect(config.cutMarks).toBe(false);
});
test("clamps font to 2..6mm", () => {
expect(normalizePrintConfig({ font: "1" }).font).toBe(2);
expect(normalizePrintConfig({ font: "20" }).font).toBe(6);
});
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: 3,
title: "规则书",
pageNumbers: true,
timestamp: true,
cutMarks: false,
});
expect(config).toEqual({
columns: 2,
margin: 12,
gap: 6,
font: 3,
title: "规则书",
pageNumbers: true,
timestamp: true,
cutMarks: false,
});
});
test("timestamp defaults to false", () => {
expect(normalizePrintConfig().timestamp).toBe(false);
expect(normalizePrintConfig({ timestamp: "false" }).timestamp).toBe(false);
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { PrintConfig } from "./types";
export const PRINT_DEFAULTS: PrintConfig = {
columns: 2,
margin: 10,
gap: 5,
font: 3,
title: "",
pageNumbers: true,
timestamp: false,
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), 2, 6),
title:
typeof input.title === "string" ? input.title : PRINT_DEFAULTS.title,
pageNumbers: toBoolean(input.pageNumbers, PRINT_DEFAULTS.pageNumbers),
timestamp: toBoolean(input.timestamp, PRINT_DEFAULTS.timestamp),
cutMarks: toBoolean(input.cutMarks, PRINT_DEFAULTS.cutMarks),
};
}
+85
View File
@@ -0,0 +1,85 @@
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: "3",
title: "",
pageNumbers: "true",
timestamp: "false",
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>
);
},
);
+74
View File
@@ -0,0 +1,74 @@
/**
* @jest-environment jsdom
*/
import { PRINT_DEFAULTS } from "./config";
import { createPageElement, splitTextElementAt } from "./paginate";
describe("createPageElement", () => {
const base = { ...PRINT_DEFAULTS, articlePath: "rules.md" };
test("adds a footer when page numbers are enabled", () => {
const { page } = createPageElement({
...base,
pageNumbers: true,
timestamp: false,
});
expect(page.querySelector(".md-print-footer")).not.toBeNull();
});
test("adds a footer when only the timestamp is enabled", () => {
const { page } = createPageElement({
...base,
pageNumbers: false,
timestamp: true,
});
expect(page.querySelector(".md-print-footer")).not.toBeNull();
});
test("omits the footer when both are disabled", () => {
const { page } = createPageElement({
...base,
pageNumbers: false,
timestamp: false,
});
expect(page.querySelector(".md-print-footer")).toBeNull();
});
});
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}mm`);
if (opts.title) {
const header = document.createElement("div");
header.className = "md-print-header";
header.textContent = opts.title;
page.appendChild(header);
}
const flow = document.createElement("div");
flow.className = "md-print-flow md-print-content prose";
flow.style.columnCount = String(opts.columns);
flow.style.columnGap = `${opts.gap}mm`;
flow.style.columnFill = "auto";
flow.style.height = "100%";
flow.style.width = "100%";
page.appendChild(flow);
if (opts.pageNumbers || opts.timestamp) {
const footer = document.createElement("div");
footer.className = "md-print-footer";
page.appendChild(footer);
}
return { page, flow };
}
/**
* Flow the children of `source` into A5 pages.
*
* The algorithm is greedy: append a block to the current page and measure.
* If it overflows, try to split it at a word / list-item / table-row
* boundary so the remaining space is used; otherwise start a new page.
* Blocks are moved (not cloned) so custom elements keep their state.
*/
export function paginate(
source: HTMLElement,
opts: PaginateOptions,
mount?: HTMLElement,
): PaginateResult {
const pages: HTMLElement[] = [];
const warnings: string[] = [];
const queue: Node[] = Array.from(source.childNodes).filter(isMeaningfulNode);
const newPage = () => {
const created = createPageElement(opts);
// Pages must be in the document for getBoundingClientRect() to work.
mount?.appendChild(created.page);
pages.push(created.page);
return created;
};
// Wrap stray text nodes so they can be measured and moved as blocks.
for (let i = 0; i < queue.length; i++) {
const node = queue[i];
if (node.nodeType === Node.TEXT_NODE) {
const p = document.createElement("p");
p.appendChild(node);
queue[i] = p;
}
}
let current = newPage();
let i = 0;
let guard = 0;
while (i < queue.length) {
if (++guard > 100000) {
warnings.push("分页循环次数过多,已中止。");
break;
}
const node = queue[i];
current.flow.appendChild(node);
if (!overflows(current.flow)) {
i++;
continue;
}
current.flow.removeChild(node as ChildNode);
const split = splitToFit(node, current.flow);
if (split) {
current.flow.appendChild(split.head);
if (split.tail && hasContent(split.tail)) {
queue.splice(i, 1, split.tail);
current = newPage();
continue;
}
i++;
continue;
}
if (current.flow.childNodes.length > 0) {
// Avoid leaving a heading alone at the bottom of the last column.
const heading = takeTrailingHeading(current.flow, opts);
if (heading) queue.splice(i, 0, heading);
current = newPage();
continue;
}
// The block is taller than a whole page and cannot be split.
current.flow.appendChild(node);
warnings.push(`内容块过高,已允许溢出:${describeNode(node)}`);
i++;
current = newPage();
}
if (pages.length > 1) {
const last = pages[pages.length - 1];
const lastFlow = last.querySelector(".md-print-flow");
if (lastFlow && !lastFlow.hasChildNodes()) pages.pop();
}
return { pages, warnings };
}
/**
* Poll until the element's scroll size has been stable for `quietMs`.
* Used to wait for async custom elements (tables, embeds) and images.
*/
export async function waitForStableSize(
el: HTMLElement,
quietMs = 300,
maxMs = 4000,
): Promise<void> {
const start = Date.now();
let last = "";
let lastChange = Date.now();
while (Date.now() - start < maxMs) {
const size = `${el.scrollHeight}x${el.scrollWidth}`;
if (size !== last) {
last = size;
lastChange = Date.now();
} else if (Date.now() - lastChange >= quietMs) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// ---------------------------------------------------------------------------
// Measurement
// ---------------------------------------------------------------------------
/**
* A block overflows when its last fragment extends past the flow's right
* (an extra column) or bottom (taller than the column) edge.
*/
function overflows(flow: HTMLElement): boolean {
// Overflow columns extend the scroll width of a fixed-height multicol box.
if (flow.scrollWidth > flow.clientWidth + 1) return true;
const rect = flow.getBoundingClientRect();
const last = flow.lastElementChild;
if (!last) return false;
const lastRect = last.getBoundingClientRect();
return lastRect.right > rect.right + 0.5 || lastRect.bottom > rect.bottom + 0.5;
}
function takeTrailingHeading(
flow: HTMLElement,
opts: PaginateOptions,
): HTMLElement | null {
const last = flow.lastElementChild;
if (!last || !/^H[1-6]$/.test(last.tagName)) return null;
if (flow.childNodes.length <= 1) return null;
const flowRect = flow.getBoundingClientRect();
const lastRect = last.getBoundingClientRect();
const gapPx = mmToPx(opts.gap);
const columnWidthPx =
(flowRect.width - (opts.columns - 1) * gapPx) / opts.columns;
const lastColumnLeft =
flowRect.left + (opts.columns - 1) * (columnWidthPx + gapPx);
// Only move headings that sit in the final column; a heading in an earlier
// column still has content after it on the same page.
if (lastRect.left < lastColumnLeft - 0.5) return null;
flow.removeChild(last);
return last as HTMLElement;
}
// ---------------------------------------------------------------------------
// Splitting
// ---------------------------------------------------------------------------
const SPLITTABLE_CONTAINERS = new Set([
"section",
"article",
"aside",
"details",
"figure",
]);
const BLOCK_TAGS = new Set([
"p",
"div",
"ul",
"ol",
"table",
"blockquote",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"section",
"article",
"aside",
"details",
"figure",
]);
function splitToFit(node: Node, flow: HTMLElement): SplitResult | null {
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "p") {
return splitTextElementToFit(el, flow);
}
if (tag === "div" || tag === "blockquote" || tag === "li") {
return hasBlockChildren(el)
? splitContainerToFit(el, flow)
: splitTextElementToFit(el, flow);
}
if (tag === "ul" || tag === "ol") {
return splitListToFit(el, flow);
}
if (tag === "table") {
return splitTableToFit(el, flow);
}
if (SPLITTABLE_CONTAINERS.has(tag)) {
return splitContainerToFit(el, flow);
}
return null;
}
function hasBlockChildren(el: HTMLElement): boolean {
return Array.from(el.children).some((child) =>
BLOCK_TAGS.has(child.tagName.toLowerCase()),
);
}
function splitTextElementToFit(
el: HTMLElement,
flow: HTMLElement,
): SplitResult | null {
const total = countWords(el);
if (total < 2) return null;
let lo = 1;
let hi = total - 1;
let best = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const [head] = splitTextElementAt(el, mid);
flow.appendChild(head);
const fits = !overflows(flow);
flow.removeChild(head);
if (fits) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (best === 0) return null;
const [head, tail] = splitTextElementAt(el, best);
trimLeadingWhitespace(tail);
removeEmptyInline(tail);
return { head, tail: hasContent(tail) ? tail : null };
}
/**
* Split an element's inline content after `wordCount` words, preserving
* inline markup (strong/em/code/links) via a DOM Range.
*/
export function splitTextElementAt(
el: HTMLElement,
wordCount: number,
): [HTMLElement, HTMLElement] {
const head = el.cloneNode(false) as HTMLElement;
const tail = el.cloneNode(false) as HTMLElement;
const boundary = findWordBoundary(el, wordCount);
if (!boundary) {
head.appendChild(el.cloneNode(true));
return [head, tail];
}
const range = document.createRange();
range.setStart(el, 0);
range.setEnd(boundary.node, boundary.offset);
head.appendChild(range.cloneContents());
range.setStart(boundary.node, boundary.offset);
range.setEnd(el, el.childNodes.length);
tail.appendChild(range.cloneContents());
return [head, tail];
}
function countWords(el: Node): number {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let count = 0;
let node: Node | null;
while ((node = walker.nextNode())) {
const matches = (node.textContent || "").match(/\S+/g);
if (matches) count += matches.length;
}
return count;
}
function findWordBoundary(
el: Node,
wordCount: number,
): { node: Text; offset: number } | null {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let count = 0;
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent || "";
const re = /\S+/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text))) {
count++;
if (count === wordCount) {
return { node: node as Text, offset: match.index + match[0].length };
}
}
}
return null;
}
function splitListToFit(
list: HTMLElement,
flow: HTMLElement,
): SplitResult | null {
const items = Array.from(list.children).filter(
(child) => child.tagName === "LI",
) as HTMLElement[];
if (items.length === 0) return null;
if (items.length === 1) {
const split = splitToFit(items[0], flow);
if (!split) return null;
const headList = list.cloneNode(false) as HTMLElement;
headList.appendChild(split.head);
const tailList = list.cloneNode(false) as HTMLElement;
if (split.tail) tailList.appendChild(split.tail);
return { head: headList, tail: hasContent(tailList) ? tailList : null };
}
let lo = 1;
let hi = items.length - 1;
let best = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const head = list.cloneNode(false) as HTMLElement;
for (let i = 0; i < mid; i++) {
head.appendChild(items[i].cloneNode(true));
}
flow.appendChild(head);
const fits = !overflows(flow);
flow.removeChild(head);
if (fits) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (best === 0) return null;
const head = list.cloneNode(false) as HTMLElement;
const tail = list.cloneNode(false) as HTMLElement;
items.forEach((item, index) => {
(index < best ? head : tail).appendChild(item.cloneNode(true));
});
return { head, tail: hasContent(tail) ? tail : null };
}
function splitTableToFit(
table: HTMLElement,
flow: HTMLElement,
): SplitResult | null {
const tbody = table.querySelector("tbody") || table;
const rows = Array.from(tbody.children).filter(
(child) => child.tagName === "TR",
) as HTMLElement[];
if (rows.length < 2) return null;
let lo = 1;
let hi = rows.length - 1;
let best = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const head = cloneTableShell(table);
const headBody = head.querySelector("tbody")!;
for (let i = 0; i < mid; i++) {
headBody.appendChild(rows[i].cloneNode(true));
}
flow.appendChild(head);
const fits = !overflows(flow);
flow.removeChild(head);
if (fits) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (best === 0) return null;
const head = cloneTableShell(table);
const headBody = head.querySelector("tbody")!;
const tail = cloneTableShell(table);
const tailBody = tail.querySelector("tbody")!;
rows.forEach((row, index) => {
(index < best ? headBody : tailBody).appendChild(row.cloneNode(true));
});
return { head, tail: hasContent(tail) ? tail : null };
}
function cloneTableShell(table: HTMLElement): HTMLElement {
const clone = table.cloneNode(false) as HTMLElement;
for (const child of Array.from(table.children)) {
const tag = child.tagName;
if (tag === "THEAD" || tag === "COLGROUP" || tag === "CAPTION") {
clone.appendChild(child.cloneNode(true));
}
}
const tbody = document.createElement("tbody");
clone.appendChild(tbody);
return clone;
}
function splitContainerToFit(
el: HTMLElement,
flow: HTMLElement,
): SplitResult | null {
const children = Array.from(el.childNodes).filter(isMeaningfulNode);
if (children.length === 0) return null;
if (children.length === 1) {
const only = children[0];
if (only.nodeType === Node.ELEMENT_NODE) {
const split = splitToFit(only, flow);
if (split) {
const head = el.cloneNode(false) as HTMLElement;
head.appendChild(split.head);
const tail = el.cloneNode(false) as HTMLElement;
if (split.tail) tail.appendChild(split.tail);
return { head, tail: hasContent(tail) ? tail : null };
}
}
return null;
}
let lo = 1;
let hi = children.length - 1;
let best = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const head = el.cloneNode(false) as HTMLElement;
for (let i = 0; i < mid; i++) {
head.appendChild(children[i].cloneNode(true));
}
flow.appendChild(head);
const fits = !overflows(flow);
flow.removeChild(head);
if (fits) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (best === 0) return null;
const head = el.cloneNode(false) as HTMLElement;
const tail = el.cloneNode(false) as HTMLElement;
children.forEach((child, index) => {
(index < best ? head : tail).appendChild(child.cloneNode(true));
});
return { head, tail: hasContent(tail) ? tail : null };
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function isMeaningfulNode(node: Node): boolean {
if (node.nodeType === Node.TEXT_NODE) {
return !!node.textContent?.trim();
}
return node.nodeType === Node.ELEMENT_NODE;
}
function trimLeadingWhitespace(el: Node): void {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent || "";
if (!text.trim()) {
node.textContent = "";
continue;
}
node.textContent = text.replace(/^\s+/, "");
break;
}
}
function removeEmptyInline(el: Element): void {
for (const child of Array.from(el.children)) {
removeEmptyInline(child);
if (
child.children.length === 0 &&
!child.textContent?.trim() &&
!["IMG", "BR", "HR", "SVG"].includes(child.tagName)
) {
child.remove();
}
}
}
function hasContent(node: Node): boolean {
if (node.nodeType === Node.TEXT_NODE) {
return !!node.textContent?.trim();
}
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as HTMLElement;
if (["IMG", "BR", "HR", "SVG", "VIDEO", "CANVAS"].includes(el.tagName)) {
return true;
}
if (el.textContent?.trim()) return true;
return el.querySelector("img, svg, video, canvas, hr, br") !== null;
}
function describeNode(node: Node): string {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const text = (el.textContent || "").trim().slice(0, 40);
return `<${el.tagName.toLowerCase()}>${text ? ` ${text}…` : ""}`;
}
return (node.textContent || "").trim().slice(0, 40) || "文本节点";
}
+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();
}
+29
View File
@@ -0,0 +1,29 @@
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 millimetres. */
font: number;
/** Running header text. Empty hides the header. */
title: string;
/** Show `n / total` in the page footer. */
pageNumbers: boolean;
/** Append the export timestamp to the page footer. */
timestamp: 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;
timestamp?: string | boolean;
cutMarks?: string | boolean;
}
+1 -1
View File
@@ -172,7 +172,7 @@ customElement(
return ( return (
<div class="ttrpg-table"> <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"> <div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */} {/* 分组 tabs */}
<Show when={hasGroup()}> <Show when={hasGroup()}>
+74
View File
@@ -0,0 +1,74 @@
---
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="3"}
```
**在页脚显示导出时间:**
```markdown
:md-print[./rules.md]{timestamp=true}
```
**YAML 写法:**
```yaml
tag: md-print
body: ./rules.md
columns: 2
margin: 10
gap: 5
font: 3
title: 规则书
timestamp: true
```
点击组件上的「打印预览 / 导出 PDF」按钮打开预览。预览中会显示分页后的 A4 横版页面,可以直接打印或另存为 PDF。
## 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `columns` | number | `2` | 每个 A5 页面的列数(1–4) |
| `margin` | number | `10` | 页面边距(毫米) |
| `gap` | number | `5` | 列间距(毫米) |
| `font` | number | `3` | 正文字号(毫米) |
| `title` | string | — | 页眉标题 |
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示 `n / 总数` 页码 |
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。开启 `timestamp` 后,页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。
## 打印设置
在浏览器打印对话框中:
- 纸张:**A4**
- 方向:**横向**
- 边距:**无**
- 缩放:**100%**
- 勾选「背景图形」(如果内容包含背景色)
## 分页说明
- 内容按块级元素分页;段落、列表和表格可以在分页处拆分。
- 标题不会单独留在页面底部,会自动移到下一页。
- 预览中的分页结果与打印结果一致。
- `md-table` 只打印当前选中的标签页内容;需要完整表格时请使用普通 Markdown 表格。
+154
View File
@@ -166,6 +166,160 @@ icon.big .icon-label-stroke {
-webkit-tap-highlight-color: transparent; -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, 3mm);
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;
}
/* Alerts: the rem-based padding is oversized next to the mm-based print font,
and the first paragraph's margin stacks on top of the padding. */
.md-print-content .markdown-alert {
padding: 0.5em 0.75em;
margin-bottom: 0.45em;
}
.md-print-content .markdown-alert-title + * {
margin-top: 0;
}
.md-print-content .markdown-alert > :last-child {
margin-bottom: 0;
}
.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 {
position: absolute;
left: 0;
right: 0;
top: 3mm;
text-align: center;
font-size: 8pt;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.md-print-footer {
position: absolute;
bottom: 3mm;
left: 3mm;
max-width: calc(100% - 6mm);
font-size: 8pt;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.md-print-footer--left {
left: 3mm;
right: auto;
text-align: left;
}
.md-print-footer--right {
left: auto;
right: 3mm;
text-align: right;
}
@media print {
.md-print-sheet--preview {
transform: none;
box-shadow: none;
}
[data-print-hide] {
display: none !important;
}
}
/* heading flash highlight */ /* heading flash highlight */
@keyframes heading-flash { @keyframes heading-flash {