From 8957e3d2f131dce7bc770fdae712f1bb5943dfd9 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 23 Oct 2026 13:26:34 +0800 Subject: [PATCH] feat(md-print): add timestamp footer option --- .agents/skills/tttk-authoring/reference.md | 11 ++++-- docs/markdown.md | 12 ++++++- docs/reference.md | 11 ++++-- src/cli/validate/known.ts | 6 ++-- src/components/md-print/PrintPreview.tsx | 42 +++++++++++++++------- src/components/md-print/config.test.ts | 9 +++++ src/components/md-print/config.ts | 2 ++ src/components/md-print/index.tsx | 1 + src/components/md-print/paginate.test.ts | 37 ++++++++++++++++++- src/components/md-print/paginate.ts | 2 +- src/components/md-print/types.ts | 3 ++ src/doc-entries/md-print.md | 10 ++++++ src/styles.css | 3 ++ 13 files changed, 126 insertions(+), 23 deletions(-) diff --git a/.agents/skills/tttk-authoring/reference.md b/.agents/skills/tttk-authoring/reference.md index bad7c6a..27773ff 100644 --- a/.agents/skills/tttk-authoring/reference.md +++ b/.agents/skills/tttk-authoring/reference.md @@ -48,7 +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-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` | | `:md-link` | path, optional #section | — | | `:md-embed` | path, optional #section | `headingBase` | | `:md-bg` | image path or CSS color | `fit` | @@ -116,15 +116,19 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l 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 and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale). +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`, `cutMarks` +- 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 @@ -134,6 +138,7 @@ margin: 10 gap: 5 font: 3 title: 规则书 +timestamp: true ``` ```` diff --git a/docs/markdown.md b/docs/markdown.md index d44c83c..6ba6246 100644 --- a/docs/markdown.md +++ b/docs/markdown.md @@ -471,7 +471,7 @@ data-config: - 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分 - 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列 - 预览中可直接打印或另存为 PDF -- 支持页眉标题、页码和中缝裁切线 +- 支持页眉标题、页码、导出时间戳和中缝裁切线 **属性:** @@ -483,6 +483,7 @@ data-config: | `font` | number | `3` | 正文字号(毫米) | | `title` | string | - | 页眉标题 | | `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 | +| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 | | `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 | **YAML 写法:** @@ -495,8 +496,17 @@ 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) diff --git a/docs/reference.md b/docs/reference.md index bad7c6a..27773ff 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -48,7 +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-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` | | `:md-link` | path, optional #section | — | | `:md-embed` | path, optional #section | `headingBase` | | `:md-bg` | image path or CSS color | `fit` | @@ -116,15 +116,19 @@ Prefer the `yaml role=tag` form — it is the only form that supports template l 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 and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale). +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`, `cutMarks` +- 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 @@ -134,6 +138,7 @@ margin: 10 gap: 5 font: 3 title: 规则书 +timestamp: true ``` ```` diff --git a/src/cli/validate/known.ts b/src/cli/validate/known.ts index 1f46cff..5657baf 100644 --- a/src/cli/validate/known.ts +++ b/src/cli/validate/known.ts @@ -77,16 +77,18 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [ "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 and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).", + "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"}', - "```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 3\ntitle: 规则书\n```", + ':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```", ], }, { diff --git a/src/components/md-print/PrintPreview.tsx b/src/components/md-print/PrintPreview.tsx index dba2c20..03d3e71 100644 --- a/src/components/md-print/PrintPreview.tsx +++ b/src/components/md-print/PrintPreview.tsx @@ -28,6 +28,11 @@ import { } 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; @@ -69,6 +74,23 @@ export function PrintPreview(props: PrintPreviewProps) { 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(); @@ -88,18 +110,10 @@ export function PrintPreview(props: PrintPreviewProps) { 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}`; - // 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", - ); - } - }); + applyFooter( + result.pages, + props.config.timestamp ? formatTimestamp(new Date()) : "", + ); setPages(result.pages); setWarnings(result.warnings); } catch (e) { @@ -164,6 +178,10 @@ export function PrintPreview(props: PrintPreviewProps) { 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)); diff --git a/src/components/md-print/config.test.ts b/src/components/md-print/config.test.ts index 77bb068..e5360c5 100644 --- a/src/components/md-print/config.test.ts +++ b/src/components/md-print/config.test.ts @@ -12,6 +12,7 @@ describe("normalizePrintConfig", () => { gap: "-5", font: "20", pageNumbers: "false", + timestamp: "true", cutMarks: "no", }); @@ -20,6 +21,7 @@ describe("normalizePrintConfig", () => { 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); }); @@ -41,6 +43,7 @@ describe("normalizePrintConfig", () => { font: 3, title: "规则书", pageNumbers: true, + timestamp: true, cutMarks: false, }); @@ -51,7 +54,13 @@ describe("normalizePrintConfig", () => { 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); + }); }); diff --git a/src/components/md-print/config.ts b/src/components/md-print/config.ts index a4e156f..164ae63 100644 --- a/src/components/md-print/config.ts +++ b/src/components/md-print/config.ts @@ -7,6 +7,7 @@ export const PRINT_DEFAULTS: PrintConfig = { font: 3, title: "", pageNumbers: true, + timestamp: false, cutMarks: true, }; @@ -47,6 +48,7 @@ export function normalizePrintConfig( 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), }; } diff --git a/src/components/md-print/index.tsx b/src/components/md-print/index.tsx index 82e2cc3..f0d618f 100644 --- a/src/components/md-print/index.tsx +++ b/src/components/md-print/index.tsx @@ -15,6 +15,7 @@ customElement( font: "3", title: "", pageNumbers: "true", + timestamp: "false", cutMarks: "true", }, (props, { element }) => { diff --git a/src/components/md-print/paginate.test.ts b/src/components/md-print/paginate.test.ts index d5e01bd..9fc1799 100644 --- a/src/components/md-print/paginate.test.ts +++ b/src/components/md-print/paginate.test.ts @@ -2,7 +2,42 @@ * @jest-environment jsdom */ -import { splitTextElementAt } from "./paginate"; +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", () => { diff --git a/src/components/md-print/paginate.ts b/src/components/md-print/paginate.ts index 685fd84..8c6e07e 100644 --- a/src/components/md-print/paginate.ts +++ b/src/components/md-print/paginate.ts @@ -72,7 +72,7 @@ export function createPageElement(opts: PaginateOptions): { flow.style.width = "100%"; page.appendChild(flow); - if (opts.pageNumbers) { + if (opts.pageNumbers || opts.timestamp) { const footer = document.createElement("div"); footer.className = "md-print-footer"; page.appendChild(footer); diff --git a/src/components/md-print/types.ts b/src/components/md-print/types.ts index 3f72630..7782dd7 100644 --- a/src/components/md-print/types.ts +++ b/src/components/md-print/types.ts @@ -11,6 +11,8 @@ export interface PrintConfig { 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; } @@ -22,5 +24,6 @@ export interface PrintProps { font?: string | number; title?: string; pageNumbers?: string | boolean; + timestamp?: string | boolean; cutMarks?: string | boolean; } diff --git a/src/doc-entries/md-print.md b/src/doc-entries/md-print.md index 6bffaad..ef05862 100644 --- a/src/doc-entries/md-print.md +++ b/src/doc-entries/md-print.md @@ -20,6 +20,12 @@ title: 打印版式 :md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"} ``` +**在页脚显示导出时间:** + +```markdown +:md-print[./rules.md]{timestamp=true} +``` + **YAML 写法:** ```yaml @@ -30,6 +36,7 @@ margin: 10 gap: 5 font: 3 title: 规则书 +timestamp: true ``` 点击组件上的「打印预览 / 导出 PDF」按钮打开预览。预览中会显示分页后的 A4 横版页面,可以直接打印或另存为 PDF。 @@ -44,8 +51,11 @@ title: 规则书 | `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` 的内容。 + ## 打印设置 在浏览器打印对话框中: diff --git a/src/styles.css b/src/styles.css index 5dc72a1..5dd14b3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -274,9 +274,12 @@ icon.big .icon-label-stroke { 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 {