@jiyeqian/md2pdf 1.5.0 → 1.7.3
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 +37 -156
- package/assets/base.css +14 -0
- package/package.json +1 -1
- package/skill/SKILL.md +2 -0
- package/src/md2pdf.mjs +224 -7
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
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
### 在仓库里开发
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
git clone https://cnb.cool/jiyeqian/md2pdf.git
|
|
56
|
-
cd md2pdf
|
|
57
|
-
npm link # 把 bin/md2pdf 软链进 PATH,指向仓库本身,改代码立即生效
|
|
58
|
-
```
|
|
32
|
+
| `MD2PDF_SKILL_DIR=<dir>` | 说明书落点,默认 `~/.workbuddy/skills/md-to-pdf` |
|
|
59
33
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
### 依赖
|
|
63
|
-
|
|
64
|
-
| 依赖 | 要求 | 说明 |
|
|
65
|
-
| --- | --- | --- |
|
|
66
|
-
| Node.js | ≥ 18(建议 ≥ 22) | < 22 时自动启用内置 WebSocket 实现;`MD2PDF_NODE` 可指定 |
|
|
67
|
-
| Chrome / Edge / Chromium | 任一 | 只用来渲染,不联网;`MD2PDF_CHROME` 可指定路径 |
|
|
68
|
-
|
|
69
|
-
零 npm 依赖 —— Markdown 解析器(marked)已内置在 `vendor/`,装好即用。
|
|
34
|
+
**依赖**:Node.js ≥ 18(建议 ≥ 22),以及 Chrome / Edge / Chromium 任一(只渲染、不联网)。零 npm 运行时依赖——`marked` 与 MathJax 已内置在 `vendor/`。
|
|
70
35
|
|
|
71
36
|
## 用法
|
|
72
37
|
|
|
@@ -81,23 +46,19 @@ md2pdf 文件名.md --theme minimal --toc
|
|
|
81
46
|
|
|
82
47
|
| 选项 | 作用 |
|
|
83
48
|
| --- | --- |
|
|
84
|
-
| `-o, --output <path>` |
|
|
85
|
-
| `--theme <name>` | `elegant
|
|
86
|
-
| `--title <text>`
|
|
87
|
-
| `--
|
|
88
|
-
|
|
|
89
|
-
| `--no-
|
|
90
|
-
|
|
|
91
|
-
| `--
|
|
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
|
+
| `--numbering <mode>` | 章节编号:`auto`(默认,识别到已有编号则不动)| `force`(强制)| `none`(不加) |
|
|
92
57
|
| `--link-urls` | 正文链接后附 URL(纸质可读) |
|
|
93
|
-
| `--landscape` |
|
|
94
|
-
| `--
|
|
95
|
-
| `--
|
|
96
|
-
| `--
|
|
97
|
-
| `--footer-left / --footer-right <text>` | 页脚左右文字 |
|
|
98
|
-
| `--colophon <text>` | 文末落款(默认:来源文件名) |
|
|
99
|
-
| `--keep-html` | 保留中间 HTML,方便调样式 |
|
|
100
|
-
| `--html-only` | 只生成 HTML,不启动浏览器(调样式 / CI 校验用) |
|
|
58
|
+
| `--landscape` / `--font-size <pt>` / `--margin <mm>` | 横向 / 字号(默认 10.5)/ 页边距(默认 20) |
|
|
59
|
+
| `--no-footer` / `--footer-left` / `--footer-right` | 页脚控制 |
|
|
60
|
+
| `--colophon <text>` | 文末落款 |
|
|
61
|
+
| `--keep-html` / `--html-only` | 留中间 HTML 调样式 / 只出 HTML(CI 校验用) |
|
|
101
62
|
| `--open` | 完成后打开 PDF |
|
|
102
63
|
|
|
103
64
|
布尔选项支持 `--flag=false`。环境变量:`MD2PDF_CHROME`、`MD2PDF_NODE`、`MD2PDF_WS=mini`。
|
|
@@ -106,17 +67,10 @@ md2pdf 文件名.md --theme minimal --toc
|
|
|
106
67
|
|
|
107
68
|
| | 是什么 | 在哪看 | 怎么开 |
|
|
108
69
|
| --- | --- | --- | --- |
|
|
109
|
-
| **目录页** |
|
|
110
|
-
| **PDF 书签** |
|
|
70
|
+
| **目录页** | 文首一张目录,条目是**可点击内链** | 文档第 1 页 | `-t / --toc`(默认关) |
|
|
71
|
+
| **PDF 书签** | 阅读器侧栏的**章节大纲树** | 阅读器侧栏 | **默认开**,`--no-outline` 关 |
|
|
111
72
|
|
|
112
|
-
书签由 Chrome 按
|
|
113
|
-
所以只要文档用了标准标题层级,就有对应的大纲,不需要额外配置。
|
|
114
|
-
|
|
115
|
-
需要看侧栏的阅读器操作:macOS 预览需手动展开侧栏(**⌘⌥3**,或右上角侧栏按钮);
|
|
116
|
-
Acrobat / 福昕 / Chrome 内置阅读器点侧栏图标即可。侧栏是否自动展开由阅读器自身决定,
|
|
117
|
-
本工具不写 `/PageMode`(改这个字段要重写 PDF 目录对象,收益不值那份风险)。
|
|
118
|
-
|
|
119
|
-
自己验一份 PDF 的书签与内链:
|
|
73
|
+
书签由 Chrome 按 `h1`–`h6` 结构生成,无需额外配置。验一份 PDF 的书签与内链:
|
|
120
74
|
|
|
121
75
|
```bash
|
|
122
76
|
node ci/inspect-pdf.mjs out.pdf
|
|
@@ -124,107 +78,34 @@ node ci/inspect-pdf.mjs out.pdf
|
|
|
124
78
|
|
|
125
79
|
## 排版规则
|
|
126
80
|
|
|
127
|
-
- 首个 H1
|
|
128
|
-
- YAML frontmatter 的 `name` / `description`
|
|
129
|
-
- H2
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
|
|
133
|
-
## 改样式
|
|
81
|
+
- 首个 H1 提升为报头大标题;其后的首段自动成为导语。
|
|
82
|
+
- YAML frontmatter 的 `name` / `description` 生成元信息条;「适用于…/不用于…」自动拆两栏。
|
|
83
|
+
- H2 自动分节加色块;表格深色表头+隔行浅底;有序列表圆形序号。
|
|
84
|
+
- 数学公式:`$...$`(行内)与 `$$...$$`(独立成行)由内置 MathJax 渲染。
|
|
85
|
+
- 脚注:`[^id]` 引用 + `[^id]: 内容` 定义;BibTeX 脚注(`@article{...}` 等)按 GB/T 7714-2025 著录,`--bibliography` 收集为「参考文献」章节。
|
|
86
|
+
- 相对路径图片自动解析进 PDF。
|
|
134
87
|
|
|
135
|
-
|
|
136
|
-
assets/base.css 骨架(占位符 {{PAGE_SIZE}} {{MARGIN_*}} {{FONT_SIZE}})
|
|
137
|
-
assets/theme-elegant.css 墨蓝 + 古铜(默认)
|
|
138
|
-
assets/theme-minimal.css 黑白公文
|
|
139
|
-
assets/shell.html 页面骨架
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
改完直接重跑命令,不用重启任何东西。
|
|
88
|
+
改样式:`assets/base.css`(骨架)与 `assets/theme-*.css`(配色),改完重跑命令即生效。
|
|
143
89
|
|
|
144
|
-
##
|
|
145
|
-
|
|
146
|
-
```
|
|
147
|
-
Markdown ──(marked)──▶ HTML ──(模板+主题 CSS)──▶ 完整 HTML
|
|
148
|
-
──▶ 无头 Chrome(CDP Page.printToPDF)──▶ PDF
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
选 CDP 而不是 `chrome --print-to-pdf` 的原因:命令行版不支持页眉页脚模板,出不了页码。
|
|
152
|
-
`preferCSSPageSize: true` 让页面尺寸/边距完全由 CSS `@page` 控制。
|
|
153
|
-
|
|
154
|
-
## 校验与 CI
|
|
155
|
-
|
|
156
|
-
```bash
|
|
157
|
-
bash ci/validate.sh # 本地跑,和 CI 完全同一套检查(约几秒)
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
校验分四层,全部不需要浏览器:
|
|
161
|
-
|
|
162
|
-
1. **结构**:必需文件齐全、`bin/` 与安装脚本有可执行位、关键文件确实被 git 跟踪
|
|
163
|
-
2. **语法**:`sh -n`、`node --check`
|
|
164
|
-
3. **一致性**:版本号(package.json ↔ src);模板占位符 ↔ 替换逻辑双向闭合;
|
|
165
|
-
主题 CSS 里 `var(--x)` 全部有定义;占位符替换必须是全量的
|
|
166
|
-
4. **行为**:`--help`/`--version` 冒烟;`examples/demo.md` 端到端渲染到 HTML,
|
|
167
|
-
断言表格、代码块、引用、嵌套列表、目录、链接 URL、分节都在,且无占位符残留
|
|
168
|
-
与 `undefined` 泄漏;目录锚点与标题 `id` 一一对应
|
|
169
|
-
5. **接线**:PDF 书签这类"只存在于 PDF 里"的特性,CI 没有浏览器验不了结果,
|
|
170
|
-
就退一步断言参数真的传进了 `printToPDF`、开关真的从 `main` 接到了渲染 ——
|
|
171
|
-
光有 `case '--no-outline'` 不等于接到了
|
|
172
|
-
|
|
173
|
-
最后还有一步**守卫自测**:故意破坏一份副本(塞入未定义的占位符、改错主题变量名、
|
|
174
|
-
改乱版本号、把目录项退回纯文本、关掉书签参数…),断言校验确实会失败 ——
|
|
175
|
-
只会"全绿"的校验等于没有校验。
|
|
176
|
-
|
|
177
|
-
CNB 云原生构建在 push / PR 时跑同一脚本;打 tag 时发布到 npm
|
|
178
|
-
(见 `.cnb.yml`)。
|
|
179
|
-
|
|
180
|
-
### 发版
|
|
181
|
-
|
|
182
|
-
改完 `src/md2pdf.mjs` 的 `VERSION` 与 `package.json` 的 `version`(校验会检查两者一致),然后:
|
|
90
|
+
## 开发
|
|
183
91
|
|
|
184
92
|
```bash
|
|
185
|
-
git
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
也可以本地手动 `npm publish`。
|
|
190
|
-
|
|
191
|
-
注意 CNB **不允许删除 tag**,打错了只能升版本号再发一版。
|
|
192
|
-
|
|
193
|
-
## 目录结构
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
bin/md2pdf 启动器(解析软链、挑选 node)
|
|
197
|
-
src/md2pdf.mjs 主程序
|
|
198
|
-
src/ws.mjs Node < 22 时的极简 WebSocket 客户端
|
|
199
|
-
src/install-skill.mjs npm postinstall:把技能说明书装进 WorkBuddy
|
|
200
|
-
assets/ 样式与页面骨架
|
|
201
|
-
vendor/marked.esm.js 内置 Markdown 解析器
|
|
202
|
-
examples/demo.md 示例文档(含表格/代码/引用/嵌套列表)
|
|
203
|
-
ci/validate.sh 校验入口(本地与 CI 同一套)
|
|
204
|
-
ci/checks.mjs 一致性 + 端到端渲染断言
|
|
205
|
-
ci/inspect-pdf.mjs 读出 PDF 的书签树与链接注解(本地验证 outline 用)
|
|
206
|
-
skill/SKILL.md Agent 技能说明书(postinstall 会装到技能目录)
|
|
93
|
+
git clone https://cnb.cool/jiyeqian/md2pdf.git
|
|
94
|
+
cd md2pdf
|
|
95
|
+
npm link # 命令指向仓库,改代码立即生效
|
|
96
|
+
bash ci/validate.sh # 本地与 CI 同一套校验(无需浏览器)
|
|
207
97
|
```
|
|
208
98
|
|
|
209
|
-
|
|
99
|
+
发版:同步 `src/md2pdf.mjs` 的 `VERSION` 与 `package.json` 的 `version`,然后 `git tag v1.x.x && git push origin v1.x.x`,CNB 流水线会自动 `npm publish`(需配置 `NPM_TOKEN`)。
|
|
210
100
|
|
|
211
101
|
## 常见问题
|
|
212
102
|
|
|
213
|
-
|
|
214
|
-
升级:`npm update -g @jiyeqian/md2pdf`。
|
|
215
|
-
|
|
216
|
-
**npm 装不上** → 确认 npm registry 可达;也可以 clone 仓库后 `npm link` 本地开发。
|
|
103
|
+
**找不到 Chrome** → `export MD2PDF_CHROME=/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome`
|
|
217
104
|
|
|
218
|
-
|
|
105
|
+
**Node 版本老** → 升级到 22+;不升也能用(自动走内置 WebSocket)。
|
|
219
106
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
**PDF 里目录不能点击** → Chrome 打印不保留内部锚点跳转,目录是纯文本。
|
|
223
|
-
|
|
224
|
-
**想改默认字号/边距** → 直接改命令行参数;要永久生效就改 `src/md2pdf.mjs` 里 `parseArgs` 的默认值。
|
|
107
|
+
**想改默认字号/边距** → 改命令行参数;永久生效改 `src/md2pdf.mjs` 里 `parseArgs` 的默认值。
|
|
225
108
|
|
|
226
109
|
## License
|
|
227
110
|
|
|
228
|
-
MIT
|
|
229
|
-
|
|
230
|
-
第三方组件:`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)。均随仓库分发以便零依赖安装。
|
|
111
|
+
MIT。第三方组件:`vendor/marked.esm.js`(marked,MIT)、`vendor/mathjax/tex-svg.js`(MathJax,Apache-2.0),均随仓库分发以便零依赖安装。
|
package/assets/base.css
CHANGED
|
@@ -208,6 +208,20 @@ hr {
|
|
|
208
208
|
}
|
|
209
209
|
img { max-width: 100%; height: auto; display: block; margin: 8px auto 14px; }
|
|
210
210
|
|
|
211
|
+
/* ---------- 脚注与参考文献 ---------- */
|
|
212
|
+
sup.fnref { font-size: .7em; line-height: 0; }
|
|
213
|
+
sup.fnref a { border-bottom: none; color: var(--accent); }
|
|
214
|
+
a.fnref-back { border-bottom: none; color: var(--accent); font-weight: 600; }
|
|
215
|
+
.references, .footnotes {
|
|
216
|
+
list-style: none; counter-reset: none;
|
|
217
|
+
margin: 4px 0 0; padding-left: 0;
|
|
218
|
+
}
|
|
219
|
+
.references li, .footnotes li {
|
|
220
|
+
position: relative; margin: 0 0 8px; padding-left: 0;
|
|
221
|
+
font-size: .9em; line-height: 1.75; text-align: left;
|
|
222
|
+
}
|
|
223
|
+
.references li::before, .footnotes li::before { content: none; }
|
|
224
|
+
|
|
211
225
|
/* ---------- 文末 ---------- */
|
|
212
226
|
.colophon {
|
|
213
227
|
margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--rule);
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -47,6 +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 [footnote\|bib]` | 将脚注收集为「参考文献」章节(默认 footnote;bib 为未来支持) |
|
|
51
|
+
| `--numbering <mode>` | 章节编号:auto(默认)| force(强制)| none(不加) |
|
|
50
52
|
| `--link-urls` | 链接后附 URL |
|
|
51
53
|
| `--landscape` / `--font-size` / `--margin` | 横向 / 字号(默认 10.5pt)/ 页边距(默认 20mm) |
|
|
52
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.
|
|
43
|
+
const VERSION = '1.7.3';
|
|
44
44
|
|
|
45
45
|
// Node ≥ 22 有全局 WebSocket;更老的版本退回到内置的极简实现
|
|
46
46
|
let _WS;
|
|
@@ -68,6 +68,8 @@ md2pdf ${VERSION} —— Markdown → 优雅 PDF
|
|
|
68
68
|
--no-lead 首段不作为导语
|
|
69
69
|
-t, --toc 在文首插入目录页(取自二级标题,可点击跳转)
|
|
70
70
|
--no-outline 不生成 PDF 书签(默认生成,阅读器侧边栏按标题成树)
|
|
71
|
+
--bibliography [footnote|bib] 将脚注收集为「参考文献」章节(默认 footnote;bib 为未来支持)
|
|
72
|
+
--numbering <mode> 章节编号:auto(默认,识别到已有编号则不动)| force(强制)| none(不加)
|
|
71
73
|
--link-urls 正文链接后附 URL
|
|
72
74
|
--landscape 横向
|
|
73
75
|
--font-size <pt> 正文字号(默认 10.5)
|
|
@@ -107,7 +109,8 @@ function parseArgs(argv) {
|
|
|
107
109
|
inputs: [], theme: 'elegant', fontSize: 10.5,
|
|
108
110
|
marginTop: 20, marginSide: 18, marginBottom: 18,
|
|
109
111
|
footer: true, footerLeft: '', footerRight: '',
|
|
110
|
-
meta: true, lead: true, toc: false, linkUrls: false, outline: true,
|
|
112
|
+
meta: true, lead: true, toc: false, linkUrls: false, outline: true, bibliography: false,
|
|
113
|
+
numbering: 'auto',
|
|
111
114
|
landscape: false, keepHtml: false, htmlOnly: false, open: false, help: false,
|
|
112
115
|
};
|
|
113
116
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -142,6 +145,14 @@ function parseArgs(argv) {
|
|
|
142
145
|
case '--no-toc': o.toc = false; break;
|
|
143
146
|
case '--outline': o.outline = true; break;
|
|
144
147
|
case '--no-outline': o.outline = false; 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;
|
|
155
|
+
case '--no-bibliography': o.bibliography = false; break;
|
|
145
156
|
case '--no-landscape': o.landscape = false; break;
|
|
146
157
|
case '--no-link-urls': o.linkUrls = false; break;
|
|
147
158
|
case '--no-keep-html': o.keepHtml = false; break;
|
|
@@ -208,6 +219,190 @@ function sectionize(html) {
|
|
|
208
219
|
.join('\n');
|
|
209
220
|
}
|
|
210
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
|
+
|
|
244
|
+
/* ---------------- 脚注与参考文献 ---------------- */
|
|
245
|
+
|
|
246
|
+
// 解析 markdown 脚注:[^id]: 定义(后续缩进行为内容)与正文 [^id] 引用
|
|
247
|
+
function parseFootnotes(body) {
|
|
248
|
+
const footnotes = [];
|
|
249
|
+
const lines = body.split('\n');
|
|
250
|
+
const out = [];
|
|
251
|
+
for (let i = 0; i < lines.length; i++) {
|
|
252
|
+
const m = /^\[\^([^\]]+)\]:[ \t]*(.*)$/.exec(lines[i]);
|
|
253
|
+
if (!m) { out.push(lines[i]); continue; }
|
|
254
|
+
const id = m[1];
|
|
255
|
+
let content = m[2];
|
|
256
|
+
while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
|
|
257
|
+
content += '\n' + lines[i + 1].trim();
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
footnotes.push({ id, content });
|
|
261
|
+
}
|
|
262
|
+
return { body: out.join('\n'), footnotes };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 脚注内容是否为 BibTeX(@type{...})
|
|
266
|
+
function isBibTeX(content) {
|
|
267
|
+
return /^\s*@[A-Za-z]+\s*\{/.test(content);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 解析 BibTeX 条目(支持嵌套花括号、双引号与无引号数值)
|
|
271
|
+
function parseBibTeX(content) {
|
|
272
|
+
const t = content.trim();
|
|
273
|
+
const m = /^@([A-Za-z]+)\s*\{\s*([^,]*),\s*([\s\S]*)\}\s*$/.exec(t);
|
|
274
|
+
if (!m) return null;
|
|
275
|
+
const type = m[1].toLowerCase();
|
|
276
|
+
const key = m[2].trim();
|
|
277
|
+
const body = m[3];
|
|
278
|
+
const fields = {};
|
|
279
|
+
let i = 0;
|
|
280
|
+
const n = body.length;
|
|
281
|
+
while (i < n) {
|
|
282
|
+
while (i < n && /[\s,]/.test(body[i])) i++;
|
|
283
|
+
const nm = /^[A-Za-z][A-Za-z0-9_-]*/.exec(body.slice(i));
|
|
284
|
+
if (!nm) break;
|
|
285
|
+
const name = nm[0].toLowerCase();
|
|
286
|
+
i += nm[0].length;
|
|
287
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
288
|
+
if (body[i] !== '=') break;
|
|
289
|
+
i++;
|
|
290
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
291
|
+
let value = '';
|
|
292
|
+
if (body[i] === '{') {
|
|
293
|
+
let depth = 0, j = i;
|
|
294
|
+
while (j < n) {
|
|
295
|
+
if (body[j] === '{') depth++;
|
|
296
|
+
else if (body[j] === '}') { depth--; if (depth === 0) { j++; break; } }
|
|
297
|
+
j++;
|
|
298
|
+
}
|
|
299
|
+
value = body.slice(i + 1, j - 1);
|
|
300
|
+
i = j;
|
|
301
|
+
} else if (body[i] === '"') {
|
|
302
|
+
const j = body.indexOf('"', i + 1);
|
|
303
|
+
if (j < 0) break;
|
|
304
|
+
value = body.slice(i + 1, j);
|
|
305
|
+
i = j + 1;
|
|
306
|
+
} else {
|
|
307
|
+
const vm = /^[^,\s}]+/.exec(body.slice(i));
|
|
308
|
+
value = vm ? vm[0] : '';
|
|
309
|
+
i += value.length;
|
|
310
|
+
}
|
|
311
|
+
fields[name] = value.trim();
|
|
312
|
+
}
|
|
313
|
+
return { type, key, fields };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 单个作者 → GB/T 7714 姓名格式(中文照写,西文「姓 名缩写.」)
|
|
317
|
+
function formatOneAuthor(a) {
|
|
318
|
+
const name = a.trim();
|
|
319
|
+
if (!name) return '';
|
|
320
|
+
if (/[\u4e00-\u9fa5]/.test(name)) return name;
|
|
321
|
+
const initials = g => g.split(/\s+/).map(w => (w[0] ? w[0].toUpperCase() + '.' : '')).join('');
|
|
322
|
+
if (name.includes(',')) {
|
|
323
|
+
const parts = name.split(',').map(s => s.trim());
|
|
324
|
+
return parts[1] ? parts[0].toUpperCase() + ' ' + initials(parts[1]) : parts[0].toUpperCase();
|
|
325
|
+
}
|
|
326
|
+
const words = name.split(/\s+/);
|
|
327
|
+
const last = words.pop();
|
|
328
|
+
return last.toUpperCase() + ' ' + initials(words.join(' '));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 作者列表 → GB/T 7714(≤3 全列,>3 前 3 + 等/et al.)
|
|
332
|
+
function formatAuthors(authorStr) {
|
|
333
|
+
if (!authorStr) return '';
|
|
334
|
+
const authors = authorStr.split(/\s+and\s+/i).map(s => s.trim()).filter(Boolean);
|
|
335
|
+
if (!authors.length) return '';
|
|
336
|
+
const isCjk = /[\u4e00-\u9fa5]/.test(authors[0]);
|
|
337
|
+
if (authors.length <= 3) return authors.map(formatOneAuthor).join(', ');
|
|
338
|
+
return authors.slice(0, 3).map(formatOneAuthor).join(', ') + (isCjk ? ', 等' : ', et al.');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// BibTeX 条目 → GB/T 7714-2025 著录字符串
|
|
342
|
+
function formatGB7714(entry) {
|
|
343
|
+
const f = entry.fields;
|
|
344
|
+
const authors = formatAuthors(f.author);
|
|
345
|
+
const title = f.title || '';
|
|
346
|
+
const year = f.year || '';
|
|
347
|
+
switch (entry.type) {
|
|
348
|
+
case 'article': {
|
|
349
|
+
const vol = f.volume || '';
|
|
350
|
+
const num = f.number || '';
|
|
351
|
+
const volIssue = vol ? (num ? vol + '(' + num + ')' : vol) : (num ? '(' + num + ')' : '');
|
|
352
|
+
const pages = f.pages ? ': ' + f.pages : '';
|
|
353
|
+
let s = (authors ? authors + ' ' : '') + title + '[J]. ' + (f.journal || '') + ', ' + year + (volIssue ? ', ' + volIssue : '') + pages;
|
|
354
|
+
if (f.doi) s += '. DOI: ' + f.doi;
|
|
355
|
+
s += '.';
|
|
356
|
+
return s;
|
|
357
|
+
}
|
|
358
|
+
case 'inproceedings':
|
|
359
|
+
case 'conference': {
|
|
360
|
+
const pages = f.pages ? ': ' + f.pages : '';
|
|
361
|
+
return (authors ? authors + ' ' : '') + title + '[C]//' + (f.booktitle || '') + '. ' + (f.address || '') + ', ' + year + pages + '.';
|
|
362
|
+
}
|
|
363
|
+
case 'phdthesis':
|
|
364
|
+
case 'mastersthesis': {
|
|
365
|
+
return (authors ? authors + ' ' : '') + title + '[D]. ' + (f.address || '') + ': ' + (f.school || '') + ', ' + year + '.';
|
|
366
|
+
}
|
|
367
|
+
case 'book': {
|
|
368
|
+
let s = (authors ? authors + ' ' : '') + title + '[M]. ';
|
|
369
|
+
if (f.edition) s += f.edition + '. ';
|
|
370
|
+
const pub = f.address && f.publisher ? f.address + ': ' + f.publisher : (f.address || f.publisher || '');
|
|
371
|
+
s += pub + (pub ? ', ' : '') + year + '.';
|
|
372
|
+
return s;
|
|
373
|
+
}
|
|
374
|
+
case 'techreport': {
|
|
375
|
+
return (authors ? authors + ' ' : '') + title + '[R]. ' + (f.institution || '') + ', ' + year + '.';
|
|
376
|
+
}
|
|
377
|
+
default: {
|
|
378
|
+
let s = (authors ? authors + ' ' : '') + title + '[EB/OL]. ';
|
|
379
|
+
if (f.urldate) s += '(' + f.urldate + ')';
|
|
380
|
+
if (f.url) s += f.url;
|
|
381
|
+
s += '.';
|
|
382
|
+
return s;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 渲染脚注列表;bibliography=true 时作为「参考文献」章节
|
|
388
|
+
function renderFootnotes(footnotes, bibliography, marked) {
|
|
389
|
+
if (!footnotes.length) return '';
|
|
390
|
+
const items = footnotes.map((fn, i) => {
|
|
391
|
+
const num = i + 1;
|
|
392
|
+
let content;
|
|
393
|
+
if (isBibTeX(fn.content)) {
|
|
394
|
+
const entry = parseBibTeX(fn.content);
|
|
395
|
+
content = entry ? esc(formatGB7714(entry)) : esc(fn.content);
|
|
396
|
+
} else {
|
|
397
|
+
content = marked.parseInline(fn.content, { gfm: true });
|
|
398
|
+
}
|
|
399
|
+
return '<li id="fn-' + num + '"><a href="#fnref-' + num + '" class="fnref-back">[' + num + ']</a> ' + content + '</li>';
|
|
400
|
+
}).join('\n');
|
|
401
|
+
const cls = bibliography ? 'references' : 'footnotes';
|
|
402
|
+
const list = '<ol class="' + cls + '">\n' + items + '\n</ol>';
|
|
403
|
+
return '<h2>' + (bibliography ? '参考文献' : '脚注') + '</h2>\n' + list;
|
|
404
|
+
}
|
|
405
|
+
|
|
211
406
|
/* ---------------- Chrome ---------------- */
|
|
212
407
|
|
|
213
408
|
function findChrome() {
|
|
@@ -338,13 +533,33 @@ class Chrome {
|
|
|
338
533
|
|
|
339
534
|
async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
|
|
340
535
|
const src = await readFile(mdPath, 'utf8');
|
|
341
|
-
const { fm, body } = splitFrontmatter(src);
|
|
536
|
+
const { fm, body: rawBody } = splitFrontmatter(src);
|
|
342
537
|
const isSkill = path.basename(mdPath) === 'SKILL.md' || !!fm.name;
|
|
343
538
|
|
|
344
539
|
// 数学公式:检测 $...$ 或 $...$,命中则注入 MathJax(SVG 输出,零字体依赖)
|
|
345
|
-
const hasMath = /\$\$|\$[^$\n]+\$/.test(
|
|
540
|
+
const hasMath = /\$\$|\$[^$\n]+\$/.test(rawBody);
|
|
541
|
+
|
|
542
|
+
// 脚注:提取 [^id]: 定义,正文 [^id] 引用替换为编号上标
|
|
543
|
+
const { body, footnotes } = parseFootnotes(rawBody);
|
|
544
|
+
const fnIndex = new Map();
|
|
545
|
+
footnotes.forEach((fn, i) => fnIndex.set(fn.id, i + 1));
|
|
546
|
+
const fnRefCount = new Map();
|
|
547
|
+
const bodyWithRefs = body.replace(/\[\^([^\]]+)\]/g, (m, id) => {
|
|
548
|
+
const n = fnIndex.get(id);
|
|
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>';
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
let html = marked.parse(bodyWithRefs, { gfm: true, breaks: false, async: false });
|
|
346
557
|
|
|
347
|
-
|
|
558
|
+
// 脚注/参考文献:先追加到正文末尾,再编号,使参考文献章节纳入编号体系
|
|
559
|
+
html += renderFootnotes(footnotes, opts.bibliography, marked);
|
|
560
|
+
|
|
561
|
+
// 章节编号:H2 起编号,H1 作为文档标题不动
|
|
562
|
+
html = numberHeadings(html, opts.numbering);
|
|
348
563
|
|
|
349
564
|
// 标题:正文首个 H1 → 报头
|
|
350
565
|
let title = opts.title || fm.title || '';
|
|
@@ -367,8 +582,10 @@ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
|
|
|
367
582
|
}
|
|
368
583
|
|
|
369
584
|
// 链接 / 图片
|
|
370
|
-
html = html.replace(/<a\s+href="([^"]*)"([^>]*)>/g, (m, href, rest) =>
|
|
371
|
-
|
|
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
|
+
});
|
|
372
589
|
if (opts.linkUrls) {
|
|
373
590
|
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/g,
|
|
374
591
|
(m, href, text) => `${m} <span class="link-url">(${esc(href)})</span>`);
|