@jiyeqian/md2pdf 1.6.0 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # md2pdf
2
2
 
3
- 把 Markdown 排成**优雅的中文 A4 PDF**:报头大标题、元信息条、精心排过的表格/代码/引用/列表、页脚页码。
4
- 不是 pandoc 的默认样式 —— 是可以直接拿去打印、发给别人看的版式。
3
+ 把 Markdown 排成**优雅的中文 A4 PDF**:报头大标题、元信息条、精心排过的表格/代码/引用/列表、页脚页码,还支持数学公式与参考文献。不是 pandoc 的默认样式——是可以直接拿去打印、发给别人看的版式。
5
4
 
6
5
  **elegant 主题**(默认,墨蓝 + 古铜):
7
6
 
@@ -13,30 +12,13 @@
13
12
 
14
13
  仓库:https://cnb.cool/jiyeqian/md2pdf
15
14
 
16
- ## 两部分:命令 + 说明书
17
-
18
- 这个工具是两层结构,各自独立存在、各自分发:
19
-
20
- | 层 | 是什么 | 给谁用 | 落在哪 |
21
- | --- | --- | --- | --- |
22
- | **命令** `md2pdf` | 真正的程序(Node + 无头 Chrome 渲染) | 你、任何脚本 | npm 全局安装到 `node_modules/@jiyeqian/md2pdf/` |
23
- | **技能说明书** `skill/SKILL.md` | 告诉 Agent「有 `md2pdf` 这个命令、怎么用」 | WorkBuddy 等 Agent 运行时 | `~/.workbuddy/skills/md-to-pdf/` |
24
-
25
- npm 的 `postinstall` 一次装两样:环境里有 WorkBuddy(`~/.workbuddy` 存在)就顺带装说明书,
26
- 没有就只装命令。只要命令用 `MD2PDF_SKILL=0` 跳过。
27
-
28
- > 为什么说明书不在程序里?因为「怎么用」是给 Agent 看的,「能转换」是给系统跑的 ——
29
- > 混在一起会让换机器时多一份要同步的实现。说明书只有一份,就在仓库 `skill/`。
30
-
31
15
  ## 安装
32
16
 
33
- **一条命令**(需要 Node ≥ 18,建议 ≥ 22):
34
-
35
17
  ```bash
36
- npm install -g @jiyeqian/md2pdf
18
+ npm install -g @jiyeqian/md2pdf # 需要 Node ≥ 18(建议 ≥ 22)
37
19
  ```
38
20
 
39
- 它会装上 `md2pdf` 命令;`postinstall` 顺带把技能说明书装进 `~/.workbuddy`(存在时)。
21
+ 装上即可用 `md2pdf` 命令;npm 的 `postinstall` 会把技能说明书装进 `~/.workbuddy`(存在时,供 Agent 使用)。
40
22
 
41
23
  ```bash
42
24
  md2pdf 你的文档.md --open # 装完试一下
@@ -47,26 +29,9 @@ md2pdf 你的文档.md --open # 装完试一下
47
29
  | 变量 | 作用 |
48
30
  | --- | --- |
49
31
  | `MD2PDF_SKILL=0` | 安装时不装 Agent 技能说明书 |
50
- | `MD2PDF_SKILL_DIR=<dir>` | 说明书落点,默认 `~/.workbuddy/skills/md-to-pdf`(`~/.workbuddy` 不存在时默认不装) |
51
-
52
- ### 在仓库里开发
53
-
54
- ```bash
55
- git clone https://cnb.cool/jiyeqian/md2pdf.git
56
- cd md2pdf
57
- npm link # 把 bin/md2pdf 软链进 PATH,指向仓库本身,改代码立即生效
58
- ```
59
-
60
- `npm link` 之后命令就是仓库本身,改完立即生效,不需要重装。
61
-
62
- ### 依赖
63
-
64
- | 依赖 | 要求 | 说明 |
65
- | --- | --- | --- |
66
- | Node.js | ≥ 18(建议 ≥ 22) | < 22 时自动启用内置 WebSocket 实现;`MD2PDF_NODE` 可指定 |
67
- | Chrome / Edge / Chromium | 任一 | 只用来渲染,不联网;`MD2PDF_CHROME` 可指定路径 |
32
+ | `MD2PDF_SKILL_DIR=<dir>` | 说明书落点,默认 `~/.workbuddy/skills/md-to-pdf` |
68
33
 
69
- npm 依赖 —— Markdown 解析器(marked)已内置在 `vendor/`,装好即用。
34
+ **依赖**:Node.js 18(建议 22),以及 Chrome / Edge / Chromium 任一(只渲染、不联网)。零 npm 运行时依赖——`marked` 与 MathJax 已内置在 `vendor/`。
70
35
 
71
36
  ## 用法
72
37
 
@@ -81,24 +46,20 @@ md2pdf 文件名.md --theme minimal --toc
81
46
 
82
47
  | 选项 | 作用 |
83
48
  | --- | --- |
84
- | `-o, --output <path>` | 输出路径;多文件或目标是目录时,作为输出目录 |
85
- | `--theme <name>` | `elegant`(默认,墨蓝+古铜)| `minimal`(黑白公文风) |
86
- | `--title <text>` | 覆盖标题(默认:正文首个 H1 frontmatter.title 文件名) |
87
- | `--kicker <text>` | 报头小标题;`SKILL.md` 默认显示「技能文档」 |
88
- | `--no-meta` | 不要 frontmatter 元信息条 |
89
- | `--no-lead` | 首段不作为导语放大 |
90
- | `-t, --toc` | 文首插入目录页(取自 H2,需 2 个以上),每项可点击跳转 |
91
- | `--no-outline` | 不生成 PDF 书签(**默认生成**,见下) |
92
- | `--bibliography` | 将脚注收集为文末「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染) |
49
+ | `-o, --output <path>` | 输出路径;多文件或目标是目录时作为输出目录 |
50
+ | `--theme <name>` | `elegant`(默认)| `minimal` |
51
+ | `--title <text>` / `--kicker <text>` | 覆盖标题 / 报头小标题 |
52
+ | `--no-meta` / `--no-lead` | 不要元信息条 / 首段不作为导语 |
53
+ | `-t, --toc` | 文首插入目录页(取自 H2),条目可点击跳转 |
54
+ | `--no-outline` | 不生成 PDF 书签(默认生成) |
55
+ | `--bibliography [footnote\|bib]` | 参考文献模式,**默认启用**(`footnote`,不加参数也生效);`bib` 为未来支持 |
56
+ | `--no-bibliography` | 关闭参考文献模式(脚注作为普通脚注) |
57
+ | `--numbering <mode>` | 章节编号:`auto`(默认,识别到已有编号则不动)| `force`(强制)| `none`(不加) |
93
58
  | `--link-urls` | 正文链接后附 URL(纸质可读) |
94
- | `--landscape` | 横向页面 |
95
- | `--font-size <pt>` | 正文字号,默认 10.5 |
96
- | `--margin <mm>` | 页边距,默认 20;可写 `"20,18"`(上下,左右) |
97
- | `--no-footer` | 不要页脚页码 |
98
- | `--footer-left / --footer-right <text>` | 页脚左右文字 |
99
- | `--colophon <text>` | 文末落款(默认:来源文件名) |
100
- | `--keep-html` | 保留中间 HTML,方便调样式 |
101
- | `--html-only` | 只生成 HTML,不启动浏览器(调样式 / CI 校验用) |
59
+ | `--landscape` / `--font-size <pt>` / `--margin <mm>` | 横向 / 字号(默认 10.5)/ 页边距(默认 20) |
60
+ | `--no-footer` / `--footer-left` / `--footer-right` | 页脚控制 |
61
+ | `--colophon <text>` | 文末落款 |
62
+ | `--keep-html` / `--html-only` | 留中间 HTML 调样式 / 只出 HTML(CI 校验用) |
102
63
  | `--open` | 完成后打开 PDF |
103
64
 
104
65
  布尔选项支持 `--flag=false`。环境变量:`MD2PDF_CHROME`、`MD2PDF_NODE`、`MD2PDF_WS=mini`。
@@ -107,17 +68,10 @@ md2pdf 文件名.md --theme minimal --toc
107
68
 
108
69
  | | 是什么 | 在哪看 | 怎么开 |
109
70
  | --- | --- | --- | --- |
110
- | **目录页** | 排在文首的一张目录,条目是**可点击的内链** | 文档第 1 页 | `-t / --toc`(默认关) |
111
- | **PDF 书签** | PDF 阅读器侧边栏里的**章节大纲树**(可折叠、点击跳转) | 阅读器侧栏 | **默认开**,`--no-outline` 关 |
71
+ | **目录页** | 文首一张目录,条目是**可点击内链** | 文档第 1 页 | `-t / --toc`(默认关) |
72
+ | **PDF 书签** | 阅读器侧栏的**章节大纲树** | 阅读器侧栏 | **默认开**,`--no-outline` 关 |
112
73
 
113
- 书签由 Chrome 按 HTML 的 `h1`–`h6` 结构生成(报头标题为根,H2/H3 逐层嵌套),
114
- 所以只要文档用了标准标题层级,就有对应的大纲,不需要额外配置。
115
-
116
- 需要看侧栏的阅读器操作:macOS 预览需手动展开侧栏(**⌘⌥3**,或右上角侧栏按钮);
117
- Acrobat / 福昕 / Chrome 内置阅读器点侧栏图标即可。侧栏是否自动展开由阅读器自身决定,
118
- 本工具不写 `/PageMode`(改这个字段要重写 PDF 目录对象,收益不值那份风险)。
119
-
120
- 自己验一份 PDF 的书签与内链:
74
+ 书签由 Chrome 按 `h1`–`h6` 结构生成,无需额外配置。验一份 PDF 的书签与内链:
121
75
 
122
76
  ```bash
123
77
  node ci/inspect-pdf.mjs out.pdf
@@ -125,239 +79,34 @@ node ci/inspect-pdf.mjs out.pdf
125
79
 
126
80
  ## 排版规则
127
81
 
128
- - 首个 H1 提升为报头大标题,正文不再重复;其后的首段自动成为导语。
129
- - YAML frontmatter 的 `name` / `description` 生成元信息条;description 里「适用于…」「不用于…」会自动拆成「适用 / 不适用」两栏。
130
- - H2 自动分节并加色块标记;表格深色表头+隔行浅底;有序列表用圆形序号。
131
- - 数学公式:正文里的 `$...# md2pdf
132
-
133
- Markdown 排成**优雅的中文 A4 PDF**:报头大标题、元信息条、精心排过的表格/代码/引用/列表、页脚页码。
134
- 不是 pandoc 的默认样式 —— 是可以直接拿去打印、发给别人看的版式。
135
-
136
- **elegant 主题**(默认,墨蓝 + 古铜):
137
-
138
- ![elegant 主题效果](docs/theme-elegant.png)
82
+ - 首个 H1 提升为报头大标题;其后的首段自动成为导语。
83
+ - YAML frontmatter 的 `name` / `description` 生成元信息条;「适用于…/不用于…」自动拆两栏。
84
+ - H2 自动分节加色块;表格深色表头+隔行浅底;有序列表圆形序号。
85
+ - 数学公式:`$...$`(行内)与 `$$...$$`(独立成行)由内置 MathJax 渲染。
86
+ - 脚注:`[^id]` 引用 + `[^id]: 内容` 定义;BibTeX 脚注(`@article{...}` 等)按 GB/T 7714-2025 著录,默认收集为「参考文献」章节(`--no-bibliography` 关闭)。
87
+ - 相对路径图片自动解析进 PDF
139
88
 
140
- **minimal 主题**(黑白公文风):
89
+ 改样式:`assets/base.css`(骨架)与 `assets/theme-*.css`(配色),改完重跑命令即生效。
141
90
 
142
- ![minimal 主题效果](docs/theme-minimal.png)
143
-
144
- 仓库:https://cnb.cool/jiyeqian/md2pdf
145
-
146
- ## 两部分:命令 + 说明书
147
-
148
- 这个工具是两层结构,各自独立存在、各自分发:
149
-
150
- | 层 | 是什么 | 给谁用 | 落在哪 |
151
- | --- | --- | --- | --- |
152
- | **命令** `md2pdf` | 真正的程序(Node + 无头 Chrome 渲染) | 你、任何脚本 | npm 全局安装到 `node_modules/@jiyeqian/md2pdf/` |
153
- | **技能说明书** `skill/SKILL.md` | 告诉 Agent「有 `md2pdf` 这个命令、怎么用」 | WorkBuddy 等 Agent 运行时 | `~/.workbuddy/skills/md-to-pdf/` |
154
-
155
- npm 的 `postinstall` 一次装两样:环境里有 WorkBuddy(`~/.workbuddy` 存在)就顺带装说明书,
156
- 没有就只装命令。只要命令用 `MD2PDF_SKILL=0` 跳过。
157
-
158
- > 为什么说明书不在程序里?因为「怎么用」是给 Agent 看的,「能转换」是给系统跑的 ——
159
- > 混在一起会让换机器时多一份要同步的实现。说明书只有一份,就在仓库 `skill/`。
160
-
161
- ## 安装
162
-
163
- **一条命令**(需要 Node ≥ 18,建议 ≥ 22):
164
-
165
- ```bash
166
- npm install -g @jiyeqian/md2pdf
167
- ```
168
-
169
- 它会装上 `md2pdf` 命令;`postinstall` 顺带把技能说明书装进 `~/.workbuddy`(存在时)。
170
-
171
- ```bash
172
- md2pdf 你的文档.md --open # 装完试一下
173
- ```
174
-
175
- **更新**:`npm update -g @jiyeqian/md2pdf` · **卸载**:`npm uninstall -g @jiyeqian/md2pdf`
176
-
177
- | 变量 | 作用 |
178
- | --- | --- |
179
- | `MD2PDF_SKILL=0` | 安装时不装 Agent 技能说明书 |
180
- | `MD2PDF_SKILL_DIR=<dir>` | 说明书落点,默认 `~/.workbuddy/skills/md-to-pdf`(`~/.workbuddy` 不存在时默认不装) |
181
-
182
- ### 在仓库里开发
91
+ ## 开发
183
92
 
184
93
  ```bash
185
94
  git clone https://cnb.cool/jiyeqian/md2pdf.git
186
95
  cd md2pdf
187
- npm link # 把 bin/md2pdf 软链进 PATH,指向仓库本身,改代码立即生效
188
- ```
189
-
190
- `npm link` 之后命令就是仓库本身,改完立即生效,不需要重装。
191
-
192
- ### 依赖
193
-
194
- | 依赖 | 要求 | 说明 |
195
- | --- | --- | --- |
196
- | Node.js | ≥ 18(建议 ≥ 22) | < 22 时自动启用内置 WebSocket 实现;`MD2PDF_NODE` 可指定 |
197
- | Chrome / Edge / Chromium | 任一 | 只用来渲染,不联网;`MD2PDF_CHROME` 可指定路径 |
198
-
199
- 零 npm 依赖 —— Markdown 解析器(marked)已内置在 `vendor/`,装好即用。
200
-
201
- ## 用法
202
-
203
- ```bash
204
- md2pdf 文件名.md # 同目录输出同名 .pdf
205
- md2pdf 文件名.md --open # 转完直接打开
206
- md2pdf a.md b.md -o 输出目录/ # 批量(共用一个浏览器实例,很快)
207
- md2pdf 文件名.md --theme minimal --toc
208
- ```
209
-
210
- ### 选项
211
-
212
- | 选项 | 作用 |
213
- | --- | --- |
214
- | `-o, --output <path>` | 输出路径;多文件或目标是目录时,作为输出目录 |
215
- | `--theme <name>` | `elegant`(默认,墨蓝+古铜)| `minimal`(黑白公文风) |
216
- | `--title <text>` | 覆盖标题(默认:正文首个 H1 → frontmatter.title → 文件名) |
217
- | `--kicker <text>` | 报头小标题;`SKILL.md` 默认显示「技能文档」 |
218
- | `--no-meta` | 不要 frontmatter 元信息条 |
219
- | `--no-lead` | 首段不作为导语放大 |
220
- | `-t, --toc` | 文首插入目录页(取自 H2,需 2 个以上),每项可点击跳转 |
221
- | `--no-outline` | 不生成 PDF 书签(**默认生成**,见下) |
222
- | `--bibliography` | 将脚注收集为文末「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染) |
223
- | `--link-urls` | 正文链接后附 URL(纸质可读) |
224
- | `--landscape` | 横向页面 |
225
- | `--font-size <pt>` | 正文字号,默认 10.5 |
226
- | `--margin <mm>` | 页边距,默认 20;可写 `"20,18"`(上下,左右) |
227
- | `--no-footer` | 不要页脚页码 |
228
- | `--footer-left / --footer-right <text>` | 页脚左右文字 |
229
- | `--colophon <text>` | 文末落款(默认:来源文件名) |
230
- | `--keep-html` | 保留中间 HTML,方便调样式 |
231
- | `--html-only` | 只生成 HTML,不启动浏览器(调样式 / CI 校验用) |
232
- | `--open` | 完成后打开 PDF |
233
-
234
- 布尔选项支持 `--flag=false`。环境变量:`MD2PDF_CHROME`、`MD2PDF_NODE`、`MD2PDF_WS=mini`。
235
-
236
- ### 目录与书签是两件事
237
-
238
- | | 是什么 | 在哪看 | 怎么开 |
239
- | --- | --- | --- | --- |
240
- | **目录页** | 排在文首的一张目录,条目是**可点击的内链** | 文档第 1 页 | `-t / --toc`(默认关) |
241
- | **PDF 书签** | PDF 阅读器侧边栏里的**章节大纲树**(可折叠、点击跳转) | 阅读器侧栏 | **默认开**,`--no-outline` 关 |
242
-
243
- 书签由 Chrome 按 HTML 的 `h1`–`h6` 结构生成(报头标题为根,H2/H3 逐层嵌套),
244
- 所以只要文档用了标准标题层级,就有对应的大纲,不需要额外配置。
245
-
246
- 需要看侧栏的阅读器操作:macOS 预览需手动展开侧栏(**⌘⌥3**,或右上角侧栏按钮);
247
- Acrobat / 福昕 / Chrome 内置阅读器点侧栏图标即可。侧栏是否自动展开由阅读器自身决定,
248
- 本工具不写 `/PageMode`(改这个字段要重写 PDF 目录对象,收益不值那份风险)。
249
-
250
- 自己验一份 PDF 的书签与内链:
251
-
252
- ```bash
253
- node ci/inspect-pdf.mjs out.pdf
254
- ```
255
-
256
- ## 排版规则
257
-
258
- - 首个 H1 提升为报头大标题,正文不再重复;其后的首段自动成为导语。
259
- - YAML frontmatter 的 `name` / `description` 生成元信息条;description 里「适用于…」「不用于…」会自动拆成「适用 / 不适用」两栏。
260
- - H2 自动分节并加色块标记;表格深色表头+隔行浅底;有序列表用圆形序号。
261
- (行内)与 `$...$`(独立成行)由内置 MathJax 渲染(SVG 输出,零字体依赖)。
262
- - 脚注:正文 `[^id]` 引用 + 文末 `[^id]: 内容` 定义;`--bibliography` 时收集为「参考文献」章节。
263
- - BibTeX 脚注(`@article{...}`、`@book{...}` 等)自动按 GB/T 7714-2025 著录格式渲染。
264
- - 相对路径图片自动解析成绝对地址,能正常进入 PDF。
265
-
266
- ## 改样式
267
-
268
- ```
269
- assets/base.css 骨架(占位符 {{PAGE_SIZE}} {{MARGIN_*}} {{FONT_SIZE}})
270
- assets/theme-elegant.css 墨蓝 + 古铜(默认)
271
- assets/theme-minimal.css 黑白公文
272
- assets/shell.html 页面骨架
96
+ npm link # 命令指向仓库,改代码立即生效
97
+ bash ci/validate.sh # 本地与 CI 同一套校验(无需浏览器)
273
98
  ```
274
99
 
275
- 改完直接重跑命令,不用重启任何东西。
276
-
277
- ## 它是怎么工作的
278
-
279
- ```
280
- Markdown ──(marked)──▶ HTML ──(模板+主题 CSS)──▶ 完整 HTML
281
- ──▶ 无头 Chrome(CDP Page.printToPDF)──▶ PDF
282
- ```
283
-
284
- 选 CDP 而不是 `chrome --print-to-pdf` 的原因:命令行版不支持页眉页脚模板,出不了页码。
285
- `preferCSSPageSize: true` 让页面尺寸/边距完全由 CSS `@page` 控制。
286
-
287
- ## 校验与 CI
288
-
289
- ```bash
290
- bash ci/validate.sh # 本地跑,和 CI 完全同一套检查(约几秒)
291
- ```
292
-
293
- 校验分四层,全部不需要浏览器:
294
-
295
- 1. **结构**:必需文件齐全、`bin/` 与安装脚本有可执行位、关键文件确实被 git 跟踪
296
- 2. **语法**:`sh -n`、`node --check`
297
- 3. **一致性**:版本号(package.json ↔ src);模板占位符 ↔ 替换逻辑双向闭合;
298
- 主题 CSS 里 `var(--x)` 全部有定义;占位符替换必须是全量的
299
- 4. **行为**:`--help`/`--version` 冒烟;`examples/demo.md` 端到端渲染到 HTML,
300
- 断言表格、代码块、引用、嵌套列表、目录、链接 URL、分节都在,且无占位符残留
301
- 与 `undefined` 泄漏;目录锚点与标题 `id` 一一对应
302
- 5. **接线**:PDF 书签这类"只存在于 PDF 里"的特性,CI 没有浏览器验不了结果,
303
- 就退一步断言参数真的传进了 `printToPDF`、开关真的从 `main` 接到了渲染 ——
304
- 光有 `case '--no-outline'` 不等于接到了
305
-
306
- 最后还有一步**守卫自测**:故意破坏一份副本(塞入未定义的占位符、改错主题变量名、
307
- 改乱版本号、把目录项退回纯文本、关掉书签参数…),断言校验确实会失败 ——
308
- 只会"全绿"的校验等于没有校验。
309
-
310
- CNB 云原生构建在 push / PR 时跑同一脚本;打 tag 时发布到 npm
311
- (见 `.cnb.yml`)。
312
-
313
- ### 发版
314
-
315
- 改完 `src/md2pdf.mjs` 的 `VERSION` 与 `package.json` 的 `version`(校验会检查两者一致),然后:
316
-
317
- ```bash
318
- git tag v1.4.0 && git push origin v1.4.0
319
- ```
320
-
321
- 流水线会自动:校验 → `npm publish --access public`(发布到 npm,需在 CNB 项目里配置 `NPM_TOKEN` secret)。
322
- 也可以本地手动 `npm publish`。
323
-
324
- 注意 CNB **不允许删除 tag**,打错了只能升版本号再发一版。
325
-
326
- ## 目录结构
327
-
328
- ```
329
- bin/md2pdf 启动器(解析软链、挑选 node)
330
- src/md2pdf.mjs 主程序
331
- src/ws.mjs Node < 22 时的极简 WebSocket 客户端
332
- src/install-skill.mjs npm postinstall:把技能说明书装进 WorkBuddy
333
- assets/ 样式与页面骨架
334
- vendor/marked.esm.js 内置 Markdown 解析器
335
- examples/demo.md 示例文档(含表格/代码/引用/嵌套列表)
336
- ci/validate.sh 校验入口(本地与 CI 同一套)
337
- ci/checks.mjs 一致性 + 端到端渲染断言
338
- ci/inspect-pdf.mjs 读出 PDF 的书签树与链接注解(本地验证 outline 用)
339
- skill/SKILL.md Agent 技能说明书(postinstall 会装到技能目录)
340
- ```
341
-
342
-
100
+ 发版:同步 `src/md2pdf.mjs` 的 `VERSION` 与 `package.json` 的 `version`,然后 `git tag v1.x.x && git push origin v1.x.x`,CNB 流水线会自动 `npm publish`(需配置 `NPM_TOKEN`)。
343
101
 
344
102
  ## 常见问题
345
103
 
346
- **装到哪了 / 怎么升级** npm 全局包在 `npm root -g` 下的 `@jiyeqian/md2pdf`,命令软链进 npm 的 bin 目录。
347
- 升级:`npm update -g @jiyeqian/md2pdf`。
348
-
349
- **npm 装不上** → 确认 npm registry 可达;也可以 clone 仓库后 `npm link` 本地开发。
104
+ **找不到 Chrome** → `export MD2PDF_CHROME=/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome`
350
105
 
351
- **找不到 Chrome** → `export MD2PDF_CHROME=/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome`
106
+ **Node 版本老** 升级到 22+;不升也能用(自动走内置 WebSocket)。
352
107
 
353
- **Node 版本老** 升级到 22+;不想升也能用(自动走内置 WebSocket),只是没在老版本上充分测试。
354
-
355
- **PDF 里目录不能点击** → Chrome 打印不保留内部锚点跳转,目录是纯文本。
356
-
357
- **想改默认字号/边距** → 直接改命令行参数;要永久生效就改 `src/md2pdf.mjs` 里 `parseArgs` 的默认值。
108
+ **想改默认字号/边距**改命令行参数;永久生效改 `src/md2pdf.mjs` 里 `parseArgs` 的默认值。
358
109
 
359
110
  ## License
360
111
 
361
- MIT
362
-
363
- 第三方组件:`vendor/marked.esm.js` 来自 [marked](https://github.com/markedjs/marked)(MIT License);`vendor/mathjax/tex-svg.js` 来自 [MathJax](https://github.com/mathjax/MathJax)(Apache-2.0 License)。均随仓库分发以便零依赖安装。
112
+ MIT。第三方组件:`vendor/marked.esm.js`(marked,MIT)、`vendor/mathjax/tex-svg.js`(MathJax,Apache-2.0),均随仓库分发以便零依赖安装。
package/assets/base.css CHANGED
@@ -52,6 +52,7 @@ h1 {
52
52
  margin: 0 0 12px;
53
53
  line-height: 1.25;
54
54
  letter-spacing: 0;
55
+ text-align: center;
55
56
  }
56
57
 
57
58
  .lead {
@@ -155,18 +156,18 @@ tbody td:first-child { font-weight: 600; color: var(--ink); }
155
156
  /* ---------- 列表 ---------- */
156
157
  ol, ul { margin: 0 0 14px; padding-left: 0; }
157
158
  ol { list-style: none; counter-reset: step; }
158
- ol li { position: relative; padding-left: 30px; margin-bottom: 11px; text-align: justify; }
159
- ol li:last-child { margin-bottom: 0; }
160
- ol li::before {
159
+ ol > li { position: relative; padding-left: 30px; margin-bottom: 11px; text-align: justify; }
160
+ ol > li:last-child { margin-bottom: 0; }
161
+ ol > li::before {
161
162
  counter-increment: step; content: counter(step);
162
163
  position: absolute; left: 0; top: .28em;
163
164
  width: 1.55em; height: 1.55em; line-height: 1.55em; text-align: center;
164
165
  font-family: var(--font-mono); font-size: .82em; font-weight: 700;
165
166
  }
166
167
  ul { list-style: none; }
167
- ul li { position: relative; padding-left: 18px; margin-bottom: 11px; text-align: justify; }
168
- ul li:last-child { margin-bottom: 0; }
169
- ul li::before {
168
+ ul > li { position: relative; padding-left: 18px; margin-bottom: 11px; text-align: justify; }
169
+ ul > li:last-child { margin-bottom: 0; }
170
+ ul > li::before {
170
171
  content: ""; position: absolute; left: 2px; top: .82em;
171
172
  width: 5px; height: 5px; border-radius: 50%;
172
173
  }
@@ -211,6 +212,7 @@ img { max-width: 100%; height: auto; display: block; margin: 8px auto 14px; }
211
212
  /* ---------- 脚注与参考文献 ---------- */
212
213
  sup.fnref { font-size: .7em; line-height: 0; }
213
214
  sup.fnref a { border-bottom: none; color: var(--accent); }
215
+ a.fnref-back { border-bottom: none; color: var(--accent); font-weight: 600; }
214
216
  .references, .footnotes {
215
217
  list-style: none; counter-reset: none;
216
218
  margin: 4px 0 0; padding-left: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiyeqian/md2pdf",
3
- "version": "1.6.0",
3
+ "version": "1.7.4",
4
4
  "description": "把 Markdown 排成优雅的中文 A4 PDF(无头 Chrome 渲染,带报头、表格/代码排版与页脚页码)",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -47,7 +47,8 @@ npm install -g @jiyeqian/md2pdf
47
47
  | `--no-meta` / `--no-lead` | 去掉元信息条 / 首段不作为导语 |
48
48
  | `-t, --toc` | 文首插入目录页(取自 H2),条目可点击跳转 |
49
49
  | `--no-outline` | 不生成 PDF 书签(默认生成) |
50
- | `--bibliography` | 将脚注收集为「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染) |
50
+ | `--bibliography [footnote\|bib]` | 参考文献模式,默认启用(footnote);bib 为未来支持。`--no-bibliography` 关闭 |
51
+ | `--numbering <mode>` | 章节编号:auto(默认)| force(强制)| none(不加) |
51
52
  | `--link-urls` | 链接后附 URL |
52
53
  | `--landscape` / `--font-size` / `--margin` | 横向 / 字号(默认 10.5pt)/ 页边距(默认 20mm) |
53
54
  | `--no-footer` / `--footer-left` / `--footer-right` | 页脚控制 |
package/src/md2pdf.mjs CHANGED
@@ -40,7 +40,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
40
40
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
41
  const ASSETS = path.join(ROOT, 'assets');
42
42
 
43
- const VERSION = '1.6.0';
43
+ const VERSION = '1.7.4';
44
44
 
45
45
  // Node ≥ 22 有全局 WebSocket;更老的版本退回到内置的极简实现
46
46
  let _WS;
@@ -68,7 +68,8 @@ md2pdf ${VERSION} —— Markdown → 优雅 PDF
68
68
  --no-lead 首段不作为导语
69
69
  -t, --toc 在文首插入目录页(取自二级标题,可点击跳转)
70
70
  --no-outline 不生成 PDF 书签(默认生成,阅读器侧边栏按标题成树)
71
- --bibliography 将脚注收集为文末「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染)
71
+ --bibliography [footnote|bib] 将脚注收集为「参考文献」章节(默认 footnote;bib 为未来支持)
72
+ --numbering <mode> 章节编号:auto(默认,识别到已有编号则不动)| force(强制)| none(不加)
72
73
  --link-urls 正文链接后附 URL
73
74
  --landscape 横向
74
75
  --font-size <pt> 正文字号(默认 10.5)
@@ -108,7 +109,8 @@ function parseArgs(argv) {
108
109
  inputs: [], theme: 'elegant', fontSize: 10.5,
109
110
  marginTop: 20, marginSide: 18, marginBottom: 18,
110
111
  footer: true, footerLeft: '', footerRight: '',
111
- meta: true, lead: true, toc: false, linkUrls: false, outline: true, bibliography: false,
112
+ meta: true, lead: true, toc: false, linkUrls: false, outline: true, bibliography: 'footnote',
113
+ numbering: 'auto',
112
114
  landscape: false, keepHtml: false, htmlOnly: false, open: false, help: false,
113
115
  };
114
116
  for (let i = 0; i < argv.length; i++) {
@@ -143,7 +145,13 @@ function parseArgs(argv) {
143
145
  case '--no-toc': o.toc = false; break;
144
146
  case '--outline': o.outline = true; break;
145
147
  case '--no-outline': o.outline = false; break;
146
- case '--bibliography': o.bibliography = true; break;
148
+ case '--bibliography': {
149
+ const n = argv[i + 1];
150
+ if (n && ['footnote', 'bib'].includes(n)) { o.bibliography = n; i++; }
151
+ else o.bibliography = 'footnote';
152
+ break;
153
+ }
154
+ case '--numbering': o.numbering = next(); break;
147
155
  case '--no-bibliography': o.bibliography = false; break;
148
156
  case '--no-landscape': o.landscape = false; break;
149
157
  case '--no-link-urls': o.linkUrls = false; break;
@@ -211,6 +219,28 @@ function sectionize(html) {
211
219
  .join('\n');
212
220
  }
213
221
 
222
+ /* ---------------- 章节编号 ---------------- */
223
+
224
+ // 标题编号前缀识别(阿拉伯/中文/罗马数字、第X章、括号编号等)
225
+ const HEADING_NUM_RE = /^\s*(?:\d+(?:\.\d+)*[、..)]\s?|[一二三四五六七八九十百]+[、..]|第[一二三四五六七八九十百\d]+[章节篇]|\([一二三四五六七八九十\d]+\)|[IVX]+[.、])\s*/;
226
+
227
+ // 章节编号:mode 为 auto(默认,识别到已有编号则不动)/ force(强制编号)/ none(不动)
228
+ function numberHeadings(html, mode) {
229
+ if (mode === 'none') return html;
230
+ const re = /<h([2-6])([^>]*)>([\s\S]*?)<\/h\1>/g;
231
+ const all = [...html.matchAll(re)];
232
+ if (!all.length) return html;
233
+ if (mode !== 'force' && all.some(m => HEADING_NUM_RE.test(stripTags(m[3])))) return html;
234
+ const counters = [0, 0, 0, 0, 0];
235
+ return html.replace(re, (m, level, attrs, inner) => {
236
+ const lvl = parseInt(level, 10) - 2;
237
+ counters[lvl]++;
238
+ for (let k = lvl + 1; k < 5; k++) counters[k] = 0;
239
+ const num = counters.slice(0, lvl + 1).join('.');
240
+ return '<h' + level + attrs + '>' + num + ' ' + inner.replace(HEADING_NUM_RE, '') + '</h' + level + '>';
241
+ });
242
+ }
243
+
214
244
  /* ---------------- 脚注与参考文献 ---------------- */
215
245
 
216
246
  // 解析 markdown 脚注:[^id]: 定义(后续缩进行为内容)与正文 [^id] 引用
@@ -355,7 +385,7 @@ function formatGB7714(entry) {
355
385
  }
356
386
 
357
387
  // 渲染脚注列表;bibliography=true 时作为「参考文献」章节
358
- function renderFootnotes(footnotes, bibliography) {
388
+ function renderFootnotes(footnotes, bibliography, marked) {
359
389
  if (!footnotes.length) return '';
360
390
  const items = footnotes.map((fn, i) => {
361
391
  const num = i + 1;
@@ -364,14 +394,13 @@ function renderFootnotes(footnotes, bibliography) {
364
394
  const entry = parseBibTeX(fn.content);
365
395
  content = entry ? esc(formatGB7714(entry)) : esc(fn.content);
366
396
  } else {
367
- content = esc(fn.content);
397
+ content = marked.parseInline(fn.content, { gfm: true });
368
398
  }
369
- return '<li id="fn-' + num + '">[' + num + '] ' + content + '</li>';
399
+ return '<li id="fn-' + num + '"><a href="#fnref-' + num + '" class="fnref-back">[' + num + ']</a> ' + content + '</li>';
370
400
  }).join('\n');
371
401
  const cls = bibliography ? 'references' : 'footnotes';
372
402
  const list = '<ol class="' + cls + '">\n' + items + '\n</ol>';
373
- if (bibliography) return '<section><h2 id="sec-refs">参考文献</h2>\n' + list + '</section>';
374
- return '<section><h2 id="sec-footnotes">脚注</h2>\n' + list + '</section>';
403
+ return '<h2>' + (bibliography ? '参考文献' : '脚注') + '</h2>\n' + list;
375
404
  }
376
405
 
377
406
  /* ---------------- Chrome ---------------- */
@@ -514,13 +543,24 @@ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
514
543
  const { body, footnotes } = parseFootnotes(rawBody);
515
544
  const fnIndex = new Map();
516
545
  footnotes.forEach((fn, i) => fnIndex.set(fn.id, i + 1));
546
+ const fnRefCount = new Map();
517
547
  const bodyWithRefs = body.replace(/\[\^([^\]]+)\]/g, (m, id) => {
518
548
  const n = fnIndex.get(id);
519
- return n === undefined ? m : '<sup class="fnref"><a href="#fn-' + n + '">[' + n + ']</a></sup>';
549
+ if (n === undefined) return m;
550
+ const c = (fnRefCount.get(id) || 0) + 1;
551
+ fnRefCount.set(id, c);
552
+ const refId = c === 1 ? 'fnref-' + n : 'fnref-' + n + '-' + c;
553
+ return '<sup class="fnref" id="' + refId + '"><a href="#fn-' + n + '">[' + n + ']</a></sup>';
520
554
  });
521
555
 
522
556
  let html = marked.parse(bodyWithRefs, { gfm: true, breaks: false, async: false });
523
557
 
558
+ // 脚注/参考文献:先追加到正文末尾,再编号,使参考文献章节纳入编号体系
559
+ html += renderFootnotes(footnotes, opts.bibliography, marked);
560
+
561
+ // 章节编号:H2 起编号,H1 作为文档标题不动
562
+ html = numberHeadings(html, opts.numbering);
563
+
524
564
  // 标题:正文首个 H1 → 报头
525
565
  let title = opts.title || fm.title || '';
526
566
  const h1 = /<h1(?:\s[^>]*)?>([\s\S]*?)<\/h1>/.exec(html);
@@ -542,8 +582,10 @@ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
542
582
  }
543
583
 
544
584
  // 链接 / 图片
545
- html = html.replace(/<a\s+href="([^"]*)"([^>]*)>/g, (m, href, rest) =>
546
- `<a href="${href}" class="ref"${rest}>`);
585
+ html = html.replace(/<a\s+href="([^"]*)"([^>]*)>/g, (m, href, rest) => {
586
+ const cls = /\sclass=/.test(rest) ? '' : ' class="ref"';
587
+ return `<a href="${href}"${cls}${rest}>`;
588
+ });
547
589
  if (opts.linkUrls) {
548
590
  html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/g,
549
591
  (m, href, text) => `${m} <span class="link-url">(${esc(href)})</span>`);
@@ -577,9 +619,6 @@ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
577
619
 
578
620
  html = sectionize(html);
579
621
 
580
- // 脚注/参考文献:追加到正文末尾
581
- html += renderFootnotes(footnotes, opts.bibliography);
582
-
583
622
  // CSS
584
623
  const themeFile = path.join(ASSETS, `theme-${opts.theme}.css`);
585
624
  if (!existsSync(themeFile)) throw new Error(`未知主题:${opts.theme}(可用:elegant, minimal)`);