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-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
```
````
+11 -1
View File
@@ -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)
+8 -3
View File
@@ -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
```
````
+4 -2
View File
@@ -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```",
],
},
{
+30 -12
View File
@@ -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));
+9
View File
@@ -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);
});
});
+2
View File
@@ -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),
};
}
+1
View File
@@ -15,6 +15,7 @@ customElement<PrintProps>(
font: "3",
title: "",
pageNumbers: "true",
timestamp: "false",
cutMarks: "true",
},
(props, { element }) => {
+36 -1
View File
@@ -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", () => {
+1 -1
View File
@@ -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);
+3
View File
@@ -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;
}
+10
View File
@@ -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` 的内容。
## 打印设置
在浏览器打印对话框中:
+3
View File
@@ -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 {