Compare commits
5
Commits
bd7871a800
...
2ba6ed74c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba6ed74c7 | ||
|
|
8957e3d2f1 | ||
|
|
1ce76dca64 | ||
|
|
80da5364e5 | ||
|
|
4a19336445 |
@@ -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.
|
||||||
|
|||||||
@@ -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"}` | 图片标记 |
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
@@ -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 格式文件。
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) || "文本节点";
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { PrintConfig } from "./types";
|
||||||
|
import { createPageElement } from "./paginate";
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user