feat(md-print): add timestamp footer option

This commit is contained in:
2026-10-23 13:26:34 +08:00
parent 1ce76dca64
commit 8957e3d2f1
13 changed files with 126 additions and 23 deletions
+8 -3
View File
@@ -48,7 +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`, `cutMarks` | | `: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` |
@@ -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. 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 - Body: markdown path, optional #section
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` - Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
```markdown ```markdown
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"} :md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
``` ```
```markdown
:md-print[./rules.md]{timestamp=true}
```
````markdown ````markdown
```yaml role=tag ```yaml role=tag
tag: md-print tag: md-print
@@ -134,6 +138,7 @@ margin: 10
gap: 5 gap: 5
font: 3 font: 3
title: 规则书 title: 规则书
timestamp: true
``` ```
```` ````
+11 -1
View File
@@ -471,7 +471,7 @@ data-config:
- 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分 - 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分
- 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列 - 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列
- 预览中可直接打印或另存为 PDF - 预览中可直接打印或另存为 PDF
- 支持页眉标题、页码和中缝裁切线 - 支持页眉标题、页码、导出时间戳和中缝裁切线
**属性:** **属性:**
@@ -483,6 +483,7 @@ data-config:
| `font` | number | `3` | 正文字号(毫米) | | `font` | number | `3` | 正文字号(毫米) |
| `title` | string | - | 页眉标题 | | `title` | string | - | 页眉标题 |
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 | | `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 |
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 | | `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
**YAML 写法:** **YAML 写法:**
@@ -495,8 +496,17 @@ margin: 10
gap: 5 gap: 5
font: 3 font: 3
title: 规则书 title: 规则书
timestamp: true
``` ```
**导出时间戳:**
```markdown
:md-print[./rules.md]{timestamp=true}
```
开启后页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。
**打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。 **打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。
### 🧶 叙事线组件 (md-yarn-spinner) ### 🧶 叙事线组件 (md-yarn-spinner)
+8 -3
View File
@@ -48,7 +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`, `cutMarks` | | `: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` |
@@ -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. 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 - Body: markdown path, optional #section
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `cutMarks` - Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
```markdown ```markdown
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"} :md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
``` ```
```markdown
:md-print[./rules.md]{timestamp=true}
```
````markdown ````markdown
```yaml role=tag ```yaml role=tag
tag: md-print tag: md-print
@@ -134,6 +138,7 @@ margin: 10
gap: 5 gap: 5
font: 3 font: 3
title: 规则书 title: 规则书
timestamp: true
``` ```
```` ````
+4 -2
View File
@@ -77,16 +77,18 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
"font", "font",
"title", "title",
"pageNumbers", "pageNumbers",
"timestamp",
"cutMarks", "cutMarks",
], ],
summary: summary:
"Paginates a markdown document into A4 landscape sheets for printing.", "Paginates a markdown document into A4 landscape sheets for printing.",
details: 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", refKind: "md",
examples: [ examples: [
':md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}', ':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```",
], ],
}, },
{ {
+29 -11
View File
@@ -28,6 +28,11 @@ import {
} from "./paginate"; } from "./paginate";
import { exportPrintPDF } from "./print-export"; 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 { export interface PrintPreviewProps {
src: string; src: string;
section?: string; section?: string;
@@ -69,6 +74,23 @@ export function PrintPreview(props: PrintPreviewProps) {
articlePath: props.articlePath, 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 runPagination = async () => {
const md = content(); const md = content();
const source = sourceEl(); const source = sourceEl();
@@ -88,18 +110,10 @@ export function PrintPreview(props: PrintPreviewProps) {
await waitForStableSize(source, 300, 4000); await waitForStableSize(source, 300, 4000);
const result = paginate(source, options(), mount); const result = paginate(source, options(), mount);
result.pages.forEach((page, index) => { applyFooter(
const footer = page.querySelector(".md-print-footer"); result.pages,
if (footer) { props.config.timestamp ? formatTimestamp(new Date()) : "",
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",
); );
}
});
setPages(result.pages); setPages(result.pages);
setWarnings(result.warnings); setWarnings(result.warnings);
} catch (e) { } catch (e) {
@@ -164,6 +178,10 @@ export function PrintPreview(props: PrintPreviewProps) {
const handleExport = async () => { const handleExport = async () => {
try { 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); await exportPrintPDF(pages(), props.config, title(), props.articlePath);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
+9
View File
@@ -12,6 +12,7 @@ describe("normalizePrintConfig", () => {
gap: "-5", gap: "-5",
font: "20", font: "20",
pageNumbers: "false", pageNumbers: "false",
timestamp: "true",
cutMarks: "no", cutMarks: "no",
}); });
@@ -20,6 +21,7 @@ describe("normalizePrintConfig", () => {
expect(config.gap).toBe(0); expect(config.gap).toBe(0);
expect(config.font).toBe(6); expect(config.font).toBe(6);
expect(config.pageNumbers).toBe(false); expect(config.pageNumbers).toBe(false);
expect(config.timestamp).toBe(true);
expect(config.cutMarks).toBe(false); expect(config.cutMarks).toBe(false);
}); });
@@ -41,6 +43,7 @@ describe("normalizePrintConfig", () => {
font: 3, font: 3,
title: "规则书", title: "规则书",
pageNumbers: true, pageNumbers: true,
timestamp: true,
cutMarks: false, cutMarks: false,
}); });
@@ -51,7 +54,13 @@ describe("normalizePrintConfig", () => {
font: 3, font: 3,
title: "规则书", title: "规则书",
pageNumbers: true, pageNumbers: true,
timestamp: true,
cutMarks: false, cutMarks: false,
}); });
}); });
test("timestamp defaults to false", () => {
expect(normalizePrintConfig().timestamp).toBe(false);
expect(normalizePrintConfig({ timestamp: "false" }).timestamp).toBe(false);
});
}); });
+2
View File
@@ -7,6 +7,7 @@ export const PRINT_DEFAULTS: PrintConfig = {
font: 3, font: 3,
title: "", title: "",
pageNumbers: true, pageNumbers: true,
timestamp: false,
cutMarks: true, cutMarks: true,
}; };
@@ -47,6 +48,7 @@ export function normalizePrintConfig(
title: title:
typeof input.title === "string" ? input.title : PRINT_DEFAULTS.title, typeof input.title === "string" ? input.title : PRINT_DEFAULTS.title,
pageNumbers: toBoolean(input.pageNumbers, PRINT_DEFAULTS.pageNumbers), pageNumbers: toBoolean(input.pageNumbers, PRINT_DEFAULTS.pageNumbers),
timestamp: toBoolean(input.timestamp, PRINT_DEFAULTS.timestamp),
cutMarks: toBoolean(input.cutMarks, PRINT_DEFAULTS.cutMarks), cutMarks: toBoolean(input.cutMarks, PRINT_DEFAULTS.cutMarks),
}; };
} }
+1
View File
@@ -15,6 +15,7 @@ customElement<PrintProps>(
font: "3", font: "3",
title: "", title: "",
pageNumbers: "true", pageNumbers: "true",
timestamp: "false",
cutMarks: "true", cutMarks: "true",
}, },
(props, { element }) => { (props, { element }) => {
+36 -1
View File
@@ -2,7 +2,42 @@
* @jest-environment jsdom * @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", () => { describe("splitTextElementAt", () => {
test("splits at a word boundary", () => { test("splits at a word boundary", () => {
+1 -1
View File
@@ -72,7 +72,7 @@ export function createPageElement(opts: PaginateOptions): {
flow.style.width = "100%"; flow.style.width = "100%";
page.appendChild(flow); page.appendChild(flow);
if (opts.pageNumbers) { if (opts.pageNumbers || opts.timestamp) {
const footer = document.createElement("div"); const footer = document.createElement("div");
footer.className = "md-print-footer"; footer.className = "md-print-footer";
page.appendChild(footer); page.appendChild(footer);
+3
View File
@@ -11,6 +11,8 @@ export interface PrintConfig {
title: string; title: string;
/** Show `n / total` in the page footer. */ /** Show `n / total` in the page footer. */
pageNumbers: boolean; pageNumbers: boolean;
/** Append the export timestamp to the page footer. */
timestamp: boolean;
/** Draw the centre cut line between the two A5 pages. */ /** Draw the centre cut line between the two A5 pages. */
cutMarks: boolean; cutMarks: boolean;
} }
@@ -22,5 +24,6 @@ export interface PrintProps {
font?: string | number; font?: string | number;
title?: string; title?: string;
pageNumbers?: string | boolean; pageNumbers?: string | boolean;
timestamp?: string | boolean;
cutMarks?: string | boolean; cutMarks?: string | boolean;
} }
+10
View File
@@ -20,6 +20,12 @@ title: 打印版式
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"} :md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
``` ```
**在页脚显示导出时间:**
```markdown
:md-print[./rules.md]{timestamp=true}
```
**YAML 写法:** **YAML 写法:**
```yaml ```yaml
@@ -30,6 +36,7 @@ margin: 10
gap: 5 gap: 5
font: 3 font: 3
title: 规则书 title: 规则书
timestamp: true
``` ```
点击组件上的「打印预览 / 导出 PDF」按钮打开预览。预览中会显示分页后的 A4 横版页面,可以直接打印或另存为 PDF。 点击组件上的「打印预览 / 导出 PDF」按钮打开预览。预览中会显示分页后的 A4 横版页面,可以直接打印或另存为 PDF。
@@ -44,8 +51,11 @@ title: 规则书
| `font` | number | `3` | 正文字号(毫米) | | `font` | number | `3` | 正文字号(毫米) |
| `title` | string | — | 页眉标题 | | `title` | string | — | 页眉标题 |
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示 `n / 总数` 页码 | | `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示 `n / 总数` 页码 |
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 | | `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。开启 `timestamp` 后,页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。
## 打印设置 ## 打印设置
在浏览器打印对话框中: 在浏览器打印对话框中:
+3
View File
@@ -274,9 +274,12 @@ icon.big .icon-label-stroke {
position: absolute; position: absolute;
bottom: 3mm; bottom: 3mm;
left: 3mm; left: 3mm;
max-width: calc(100% - 6mm);
font-size: 8pt; font-size: 8pt;
color: #6b7280; color: #6b7280;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.md-print-footer--left { .md-print-footer--left {