@jiyeqian/md2pdf 1.5.0 → 1.6.0

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
@@ -89,6 +89,7 @@ md2pdf 文件名.md --theme minimal --toc
89
89
  | `--no-lead` | 首段不作为导语放大 |
90
90
  | `-t, --toc` | 文首插入目录页(取自 H2,需 2 个以上),每项可点击跳转 |
91
91
  | `--no-outline` | 不生成 PDF 书签(**默认生成**,见下) |
92
+ | `--bibliography` | 将脚注收集为文末「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染) |
92
93
  | `--link-urls` | 正文链接后附 URL(纸质可读) |
93
94
  | `--landscape` | 横向页面 |
94
95
  | `--font-size <pt>` | 正文字号,默认 10.5 |
@@ -127,7 +128,139 @@ node ci/inspect-pdf.mjs out.pdf
127
128
  - 首个 H1 提升为报头大标题,正文不再重复;其后的首段自动成为导语。
128
129
  - YAML frontmatter 的 `name` / `description` 生成元信息条;description 里「适用于…」「不用于…」会自动拆成「适用 / 不适用」两栏。
129
130
  - H2 自动分节并加色块标记;表格深色表头+隔行浅底;有序列表用圆形序号。
130
- - 数学公式:正文里的 `$...$`(行内)与 `$$...$$`(独立成行)由内置 MathJax 渲染(SVG 输出,零字体依赖)。
131
+ - 数学公式:正文里的 `$...# md2pdf
132
+
133
+ 把 Markdown 排成**优雅的中文 A4 PDF**:报头大标题、元信息条、精心排过的表格/代码/引用/列表、页脚页码。
134
+ 不是 pandoc 的默认样式 —— 是可以直接拿去打印、发给别人看的版式。
135
+
136
+ **elegant 主题**(默认,墨蓝 + 古铜):
137
+
138
+ ![elegant 主题效果](docs/theme-elegant.png)
139
+
140
+ **minimal 主题**(黑白公文风):
141
+
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
+ ### 在仓库里开发
183
+
184
+ ```bash
185
+ git clone https://cnb.cool/jiyeqian/md2pdf.git
186
+ 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 著录格式渲染。
131
264
  - 相对路径图片自动解析成绝对地址,能正常进入 PDF。
132
265
 
133
266
  ## 改样式
package/assets/base.css CHANGED
@@ -208,6 +208,19 @@ 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
+ .references, .footnotes {
215
+ list-style: none; counter-reset: none;
216
+ margin: 4px 0 0; padding-left: 0;
217
+ }
218
+ .references li, .footnotes li {
219
+ position: relative; margin: 0 0 8px; padding-left: 0;
220
+ font-size: .9em; line-height: 1.75; text-align: left;
221
+ }
222
+ .references li::before, .footnotes li::before { content: none; }
223
+
211
224
  /* ---------- 文末 ---------- */
212
225
  .colophon {
213
226
  margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--rule);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiyeqian/md2pdf",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "把 Markdown 排成优雅的中文 A4 PDF(无头 Chrome 渲染,带报头、表格/代码排版与页脚页码)",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -47,6 +47,7 @@ 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
51
  | `--link-urls` | 链接后附 URL |
51
52
  | `--landscape` / `--font-size` / `--margin` | 横向 / 字号(默认 10.5pt)/ 页边距(默认 20mm) |
52
53
  | `--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.5.0';
43
+ const VERSION = '1.6.0';
44
44
 
45
45
  // Node ≥ 22 有全局 WebSocket;更老的版本退回到内置的极简实现
46
46
  let _WS;
@@ -68,6 +68,7 @@ md2pdf ${VERSION} —— Markdown → 优雅 PDF
68
68
  --no-lead 首段不作为导语
69
69
  -t, --toc 在文首插入目录页(取自二级标题,可点击跳转)
70
70
  --no-outline 不生成 PDF 书签(默认生成,阅读器侧边栏按标题成树)
71
+ --bibliography 将脚注收集为文末「参考文献」章节(BibTeX 脚注按 GB/T 7714 渲染)
71
72
  --link-urls 正文链接后附 URL
72
73
  --landscape 横向
73
74
  --font-size <pt> 正文字号(默认 10.5)
@@ -107,7 +108,7 @@ function parseArgs(argv) {
107
108
  inputs: [], theme: 'elegant', fontSize: 10.5,
108
109
  marginTop: 20, marginSide: 18, marginBottom: 18,
109
110
  footer: true, footerLeft: '', footerRight: '',
110
- meta: true, lead: true, toc: false, linkUrls: false, outline: true,
111
+ meta: true, lead: true, toc: false, linkUrls: false, outline: true, bibliography: false,
111
112
  landscape: false, keepHtml: false, htmlOnly: false, open: false, help: false,
112
113
  };
113
114
  for (let i = 0; i < argv.length; i++) {
@@ -142,6 +143,8 @@ function parseArgs(argv) {
142
143
  case '--no-toc': o.toc = false; break;
143
144
  case '--outline': o.outline = true; break;
144
145
  case '--no-outline': o.outline = false; break;
146
+ case '--bibliography': o.bibliography = true; break;
147
+ case '--no-bibliography': o.bibliography = false; break;
145
148
  case '--no-landscape': o.landscape = false; break;
146
149
  case '--no-link-urls': o.linkUrls = false; break;
147
150
  case '--no-keep-html': o.keepHtml = false; break;
@@ -208,6 +211,169 @@ function sectionize(html) {
208
211
  .join('\n');
209
212
  }
210
213
 
214
+ /* ---------------- 脚注与参考文献 ---------------- */
215
+
216
+ // 解析 markdown 脚注:[^id]: 定义(后续缩进行为内容)与正文 [^id] 引用
217
+ function parseFootnotes(body) {
218
+ const footnotes = [];
219
+ const lines = body.split('\n');
220
+ const out = [];
221
+ for (let i = 0; i < lines.length; i++) {
222
+ const m = /^\[\^([^\]]+)\]:[ \t]*(.*)$/.exec(lines[i]);
223
+ if (!m) { out.push(lines[i]); continue; }
224
+ const id = m[1];
225
+ let content = m[2];
226
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
227
+ content += '\n' + lines[i + 1].trim();
228
+ i++;
229
+ }
230
+ footnotes.push({ id, content });
231
+ }
232
+ return { body: out.join('\n'), footnotes };
233
+ }
234
+
235
+ // 脚注内容是否为 BibTeX(@type{...})
236
+ function isBibTeX(content) {
237
+ return /^\s*@[A-Za-z]+\s*\{/.test(content);
238
+ }
239
+
240
+ // 解析 BibTeX 条目(支持嵌套花括号、双引号与无引号数值)
241
+ function parseBibTeX(content) {
242
+ const t = content.trim();
243
+ const m = /^@([A-Za-z]+)\s*\{\s*([^,]*),\s*([\s\S]*)\}\s*$/.exec(t);
244
+ if (!m) return null;
245
+ const type = m[1].toLowerCase();
246
+ const key = m[2].trim();
247
+ const body = m[3];
248
+ const fields = {};
249
+ let i = 0;
250
+ const n = body.length;
251
+ while (i < n) {
252
+ while (i < n && /[\s,]/.test(body[i])) i++;
253
+ const nm = /^[A-Za-z][A-Za-z0-9_-]*/.exec(body.slice(i));
254
+ if (!nm) break;
255
+ const name = nm[0].toLowerCase();
256
+ i += nm[0].length;
257
+ while (i < n && /\s/.test(body[i])) i++;
258
+ if (body[i] !== '=') break;
259
+ i++;
260
+ while (i < n && /\s/.test(body[i])) i++;
261
+ let value = '';
262
+ if (body[i] === '{') {
263
+ let depth = 0, j = i;
264
+ while (j < n) {
265
+ if (body[j] === '{') depth++;
266
+ else if (body[j] === '}') { depth--; if (depth === 0) { j++; break; } }
267
+ j++;
268
+ }
269
+ value = body.slice(i + 1, j - 1);
270
+ i = j;
271
+ } else if (body[i] === '"') {
272
+ const j = body.indexOf('"', i + 1);
273
+ if (j < 0) break;
274
+ value = body.slice(i + 1, j);
275
+ i = j + 1;
276
+ } else {
277
+ const vm = /^[^,\s}]+/.exec(body.slice(i));
278
+ value = vm ? vm[0] : '';
279
+ i += value.length;
280
+ }
281
+ fields[name] = value.trim();
282
+ }
283
+ return { type, key, fields };
284
+ }
285
+
286
+ // 单个作者 → GB/T 7714 姓名格式(中文照写,西文「姓 名缩写.」)
287
+ function formatOneAuthor(a) {
288
+ const name = a.trim();
289
+ if (!name) return '';
290
+ if (/[\u4e00-\u9fa5]/.test(name)) return name;
291
+ const initials = g => g.split(/\s+/).map(w => (w[0] ? w[0].toUpperCase() + '.' : '')).join('');
292
+ if (name.includes(',')) {
293
+ const parts = name.split(',').map(s => s.trim());
294
+ return parts[1] ? parts[0].toUpperCase() + ' ' + initials(parts[1]) : parts[0].toUpperCase();
295
+ }
296
+ const words = name.split(/\s+/);
297
+ const last = words.pop();
298
+ return last.toUpperCase() + ' ' + initials(words.join(' '));
299
+ }
300
+
301
+ // 作者列表 → GB/T 7714(≤3 全列,>3 前 3 + 等/et al.)
302
+ function formatAuthors(authorStr) {
303
+ if (!authorStr) return '';
304
+ const authors = authorStr.split(/\s+and\s+/i).map(s => s.trim()).filter(Boolean);
305
+ if (!authors.length) return '';
306
+ const isCjk = /[\u4e00-\u9fa5]/.test(authors[0]);
307
+ if (authors.length <= 3) return authors.map(formatOneAuthor).join(', ');
308
+ return authors.slice(0, 3).map(formatOneAuthor).join(', ') + (isCjk ? ', 等' : ', et al.');
309
+ }
310
+
311
+ // BibTeX 条目 → GB/T 7714-2025 著录字符串
312
+ function formatGB7714(entry) {
313
+ const f = entry.fields;
314
+ const authors = formatAuthors(f.author);
315
+ const title = f.title || '';
316
+ const year = f.year || '';
317
+ switch (entry.type) {
318
+ case 'article': {
319
+ const vol = f.volume || '';
320
+ const num = f.number || '';
321
+ const volIssue = vol ? (num ? vol + '(' + num + ')' : vol) : (num ? '(' + num + ')' : '');
322
+ const pages = f.pages ? ': ' + f.pages : '';
323
+ let s = (authors ? authors + ' ' : '') + title + '[J]. ' + (f.journal || '') + ', ' + year + (volIssue ? ', ' + volIssue : '') + pages;
324
+ if (f.doi) s += '. DOI: ' + f.doi;
325
+ s += '.';
326
+ return s;
327
+ }
328
+ case 'inproceedings':
329
+ case 'conference': {
330
+ const pages = f.pages ? ': ' + f.pages : '';
331
+ return (authors ? authors + ' ' : '') + title + '[C]//' + (f.booktitle || '') + '. ' + (f.address || '') + ', ' + year + pages + '.';
332
+ }
333
+ case 'phdthesis':
334
+ case 'mastersthesis': {
335
+ return (authors ? authors + ' ' : '') + title + '[D]. ' + (f.address || '') + ': ' + (f.school || '') + ', ' + year + '.';
336
+ }
337
+ case 'book': {
338
+ let s = (authors ? authors + ' ' : '') + title + '[M]. ';
339
+ if (f.edition) s += f.edition + '. ';
340
+ const pub = f.address && f.publisher ? f.address + ': ' + f.publisher : (f.address || f.publisher || '');
341
+ s += pub + (pub ? ', ' : '') + year + '.';
342
+ return s;
343
+ }
344
+ case 'techreport': {
345
+ return (authors ? authors + ' ' : '') + title + '[R]. ' + (f.institution || '') + ', ' + year + '.';
346
+ }
347
+ default: {
348
+ let s = (authors ? authors + ' ' : '') + title + '[EB/OL]. ';
349
+ if (f.urldate) s += '(' + f.urldate + ')';
350
+ if (f.url) s += f.url;
351
+ s += '.';
352
+ return s;
353
+ }
354
+ }
355
+ }
356
+
357
+ // 渲染脚注列表;bibliography=true 时作为「参考文献」章节
358
+ function renderFootnotes(footnotes, bibliography) {
359
+ if (!footnotes.length) return '';
360
+ const items = footnotes.map((fn, i) => {
361
+ const num = i + 1;
362
+ let content;
363
+ if (isBibTeX(fn.content)) {
364
+ const entry = parseBibTeX(fn.content);
365
+ content = entry ? esc(formatGB7714(entry)) : esc(fn.content);
366
+ } else {
367
+ content = esc(fn.content);
368
+ }
369
+ return '<li id="fn-' + num + '">[' + num + '] ' + content + '</li>';
370
+ }).join('\n');
371
+ const cls = bibliography ? 'references' : 'footnotes';
372
+ 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>';
375
+ }
376
+
211
377
  /* ---------------- Chrome ---------------- */
212
378
 
213
379
  function findChrome() {
@@ -338,13 +504,22 @@ class Chrome {
338
504
 
339
505
  async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
340
506
  const src = await readFile(mdPath, 'utf8');
341
- const { fm, body } = splitFrontmatter(src);
507
+ const { fm, body: rawBody } = splitFrontmatter(src);
342
508
  const isSkill = path.basename(mdPath) === 'SKILL.md' || !!fm.name;
343
509
 
344
510
  // 数学公式:检测 $...$ 或 $...$,命中则注入 MathJax(SVG 输出,零字体依赖)
345
- const hasMath = /\$\$|\$[^$\n]+\$/.test(body);
511
+ const hasMath = /\$\$|\$[^$\n]+\$/.test(rawBody);
512
+
513
+ // 脚注:提取 [^id]: 定义,正文 [^id] 引用替换为编号上标
514
+ const { body, footnotes } = parseFootnotes(rawBody);
515
+ const fnIndex = new Map();
516
+ footnotes.forEach((fn, i) => fnIndex.set(fn.id, i + 1));
517
+ const bodyWithRefs = body.replace(/\[\^([^\]]+)\]/g, (m, id) => {
518
+ const n = fnIndex.get(id);
519
+ return n === undefined ? m : '<sup class="fnref"><a href="#fn-' + n + '">[' + n + ']</a></sup>';
520
+ });
346
521
 
347
- let html = marked.parse(body, { gfm: true, breaks: false, async: false });
522
+ let html = marked.parse(bodyWithRefs, { gfm: true, breaks: false, async: false });
348
523
 
349
524
  // 标题:正文首个 H1 → 报头
350
525
  let title = opts.title || fm.title || '';
@@ -402,6 +577,9 @@ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
402
577
 
403
578
  html = sectionize(html);
404
579
 
580
+ // 脚注/参考文献:追加到正文末尾
581
+ html += renderFootnotes(footnotes, opts.bibliography);
582
+
405
583
  // CSS
406
584
  const themeFile = path.join(ASSETS, `theme-${opts.theme}.css`);
407
585
  if (!existsSync(themeFile)) throw new Error(`未知主题:${opts.theme}(可用:elegant, minimal)`);