feat(md-print): add timestamp footer option
This commit is contained in:
@@ -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```",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ customElement<PrintProps>(
|
||||
font: "3",
|
||||
title: "",
|
||||
pageNumbers: "true",
|
||||
timestamp: "false",
|
||||
cutMarks: "true",
|
||||
},
|
||||
(props, { element }) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 的内容。
|
||||
|
||||
## 打印设置
|
||||
|
||||
在浏览器打印对话框中:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user