@jiyeqian/md2pdf 1.4.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/skill/SKILL.md ADDED
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: md-to-pdf
3
+ description: 把 Markdown 文件排成优雅的中文 A4 PDF(报头+元信息条+表格/代码/引用排版+页脚页码+PDF 书签大纲),支持 elegant 与 minimal 两套主题、可点击目录与批量转换。当用户要求"把 md 转成 PDF""导出成 PDF""打印成 PDF""生成优雅的 PDF""PDF 要有目录/书签"时使用。
4
+ agent_created: true
5
+ ---
6
+
7
+ # Markdown → 优雅 PDF
8
+
9
+ 有现成命令 `md2pdf`,直接用,不要临时手写 HTML + 调 Chrome。
10
+
11
+ ## 直接用
12
+
13
+ ```bash
14
+ md2pdf 文件名.md # 同目录同名输出 .pdf
15
+ md2pdf 文件名.md --open # 转完直接打开
16
+ md2pdf a.md b.md -o 输出目录/ # 批量(目录不存在会自动创建)
17
+ md2pdf 文件名.md --theme minimal --toc
18
+ ```
19
+
20
+ ## 程序在哪
21
+
22
+ - **命令入口**:npm 全局 bin 目录里的 `md2pdf`
23
+ - **程序本体**:npm 全局包 `@jiyeqian/md2pdf`(`node_modules/@jiyeqian/md2pdf/`)
24
+ - **源码仓库**:https://cnb.cool/jiyeqian/md2pdf (Public,唯一实现的源头)
25
+ - **本说明书**:仓库里的 `skill/SKILL.md`,安装时由 npm `postinstall` 复制到技能目录
26
+ (WorkBuddy 下即 `~/.workbuddy/skills/md-to-pdf/SKILL.md`)
27
+
28
+ **没装过 / 换了机器** —— 一条命令(命令与说明书一起装好):
29
+
30
+ ```bash
31
+ npm install -g @jiyeqian/md2pdf
32
+ ```
33
+
34
+ 若 `command -v md2pdf` 为空,就是没装,跑上面这条即可。
35
+
36
+ **升级**:`npm update -g @jiyeqian/md2pdf` · **卸载**:`npm uninstall -g @jiyeqian/md2pdf`
37
+
38
+ 环境变量:`MD2PDF_SKILL=0` 不装说明书;`MD2PDF_SKILL_DIR=<dir>` 指定技能目录。
39
+
40
+ ## 常用选项
41
+
42
+ | 选项 | 作用 |
43
+ | --- | --- |
44
+ | `-o, --output <path>` | 输出路径;多文件或目标是目录时作为输出目录 |
45
+ | `--theme <name>` | `elegant`(默认,墨蓝+古铜)| `minimal`(黑白公文风) |
46
+ | `--title` / `--kicker` | 覆盖标题 / 报头小标题 |
47
+ | `--no-meta` / `--no-lead` | 去掉元信息条 / 首段不作为导语 |
48
+ | `-t, --toc` | 文首插入目录页(取自 H2),条目可点击跳转 |
49
+ | `--no-outline` | 不生成 PDF 书签(默认生成) |
50
+ | `--link-urls` | 链接后附 URL |
51
+ | `--landscape` / `--font-size` / `--margin` | 横向 / 字号(默认 10.5pt)/ 页边距(默认 20mm) |
52
+ | `--no-footer` / `--footer-left` / `--footer-right` | 页脚控制 |
53
+ | `--keep-html` / `--open` | 留中间 HTML 调样式 / 转完打开 |
54
+ | `--html-only` | 只出 HTML 不启动浏览器(调样式、CI 校验用) |
55
+
56
+ 布尔选项支持 `--flag=false`。环境变量:`MD2PDF_CHROME`、`MD2PDF_NODE`、`MD2PDF_WS=mini`。
57
+ 完整列表见 `md2pdf --help` 或项目 README。
58
+
59
+ ## 排版规则(需要解释效果时看这里)
60
+
61
+ 首个 H1 提升为报头大标题、其后首段成为导语;frontmatter 的 name/description 生成元信息条
62
+ ("适用于…""不用于…"自动拆两栏);H2 自动分节并加 id 锚点;相对路径图片转 file://;
63
+ 页脚页码由 CDP `Page.printToPDF` 生成(CLI `--print-to-pdf` 不支持页眉页脚模板)。
64
+
65
+ **目录页与书签是两件事**,别混:
66
+ - **目录页**(`-t`,默认关)= 文首排的一张目录,条目是文档内可点击的内链
67
+ - **PDF 书签**(默认开,`--no-outline` 关)= 阅读器侧边栏的章节大纲树
68
+ 由 Chrome 按 HTML 的 h1–h6 结构生成(报头标题为根,H2/H3 嵌套);阅读器侧栏
69
+ 需用户自己展开(macOS 预览按 ⌘⌥3)。用户问"为什么侧栏是空的"时先确认是不是
70
+ 老版本 Chrome 没生成(参数不被支持时会静默退回无书签渲染)。
71
+
72
+ 验一份 PDF 到底有没有书签:
73
+
74
+ ```bash
75
+ node ci/inspect-pdf.mjs out.pdf # 打印书签树、内链/外链数量
76
+ ```
77
+
78
+ 改样式:`assets/base.css`(骨架)与 `assets/theme-*.css`(配色)。
79
+ 用户态改 npm 全局包里的 `assets/`;开发态(见下)改仓库即时生效。
80
+
81
+ ## 开发这个工具
82
+
83
+ 源码仓库就是唯一实现:
84
+
85
+ ```bash
86
+ git clone https://cnb.cool/jiyeqian/md2pdf.git
87
+ cd md2pdf && npm link # 切到开发态:命令指回仓库,改代码立即生效
88
+ ```
89
+
90
+
91
+ 改完代码跑校验(几秒钟、无需浏览器):
92
+
93
+ ```bash
94
+ bash ci/validate.sh # 本地与 CNB 云原生构建跑的是同一套
95
+ ```
96
+
97
+ 推送后 CNB 自动跑同一套校验;打 tag 则自动 `npm publish` 发新版,用户 `npm update -g` 即可拿到。
98
+ 发版前记得同步 `src/md2pdf.mjs` 的 `VERSION` 与 `package.json` 的 `version`(校验会检查一致)。
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm postinstall —— 把 skill/SKILL.md 装进 Agent 技能目录(WorkBuddy)。
4
+ *
5
+ * 纯 Node、零依赖。默认只在存在 ~/.workbuddy 时安装,保持与旧 install.sh 一致:
6
+ * MD2PDF_SKILL=0 跳过安装
7
+ * MD2PDF_SKILL_DIR=<d> 覆盖落点(默认 ~/.workbuddy/skills/md-to-pdf)
8
+ *
9
+ * 技能安装失败只告警、不失败 —— 不能让 npm install 因为装技能而整体失败。
10
+ */
11
+
12
+ import { mkdir, copyFile } from 'node:fs/promises';
13
+ import { existsSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const SRC = path.join(ROOT, 'skill', 'SKILL.md');
20
+
21
+ if (process.env.MD2PDF_SKILL === '0') process.exit(0);
22
+ if (!existsSync(SRC)) process.exit(0);
23
+
24
+ const home = process.env.HOME || os.homedir();
25
+ let skillDir = process.env.MD2PDF_SKILL_DIR;
26
+ if (!skillDir && home && existsSync(path.join(home, '.workbuddy'))) {
27
+ skillDir = path.join(home, '.workbuddy', 'skills', 'md-to-pdf');
28
+ }
29
+ if (!skillDir) process.exit(0);
30
+
31
+ try {
32
+ await mkdir(skillDir, { recursive: true });
33
+ await copyFile(SRC, path.join(skillDir, 'SKILL.md'));
34
+ console.log(`md2pdf: 已安装技能说明书 → ${path.join(skillDir, 'SKILL.md')}`);
35
+ } catch (e) {
36
+ console.warn(`md2pdf: 技能说明书安装失败(${e.message}),命令本身不受影响`);
37
+ }
package/src/md2pdf.mjs ADDED
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md2pdf —— 把 Markdown 排成优雅的 A4 PDF(无头 Chrome + CDP)
4
+ *
5
+ * 用法:md2pdf <file.md> [file2.md ...] [选项]
6
+ *
7
+ * 选项:
8
+ * -o, --output <path> 输出 PDF(单文件时可用;多文件时忽略)
9
+ * --theme <name> elegant(默认)| minimal
10
+ * --title <text> 覆盖标题(默认取正文首个 H1,其次 frontmatter.title,其次文件名)
11
+ * --kicker <text> 报头小标题(如"专利实务 · 技能文档")
12
+ * --no-meta 不生成元信息条
13
+ * --no-lead 首段不作为导语放大
14
+ * -t, --toc 生成目录(取自 H2)
15
+ * --link-urls 正文链接后附 URL
16
+ * --landscape 横向页面
17
+ * --font-size <pt> 正文字号,默认 10.5
18
+ * --margin <mm> 页边距,默认 20(上下同为 18/20,左右同值);可写 "20,18"
19
+ * --no-footer 不输出页脚页码
20
+ * --footer-left <text> 页脚左文字(默认空)
21
+ * --footer-right <text> 页脚右文字(默认空)
22
+ * --colophon <text> 文末落款(默认:来源文件名)
23
+ * --keep-html 保留中间 HTML,便于调样式
24
+ * --open 完成后打开 PDF
25
+ * -h, --help
26
+ *
27
+ * 环境变量:
28
+ * MD2PDF_CHROME 指定 Chrome/Edge/Chromium 可执行文件路径
29
+ * MD2PDF_WS=mini 强制使用内置 WebSocket 实现(Node < 22 时自动启用)
30
+ */
31
+
32
+ import { readFile, writeFile, mkdtemp, rm, mkdir } from 'node:fs/promises';
33
+ import { existsSync, statSync } from 'node:fs';
34
+ import path from 'node:path';
35
+ import os from 'node:os';
36
+ import http from 'node:http';
37
+ import { spawn, spawnSync } from 'node:child_process';
38
+ import { fileURLToPath, pathToFileURL } from 'node:url';
39
+
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const ASSETS = path.join(ROOT, 'assets');
42
+
43
+ const VERSION = '1.4.0';
44
+
45
+ // Node ≥ 22 有全局 WebSocket;更老的版本退回到内置的极简实现
46
+ let _WS;
47
+ async function getWSClass() {
48
+ if (_WS) return _WS;
49
+ if (process.env.MD2PDF_WS === 'mini' || typeof WebSocket === 'undefined') {
50
+ _WS = (await import('./ws.mjs')).MiniWebSocket;
51
+ } else {
52
+ _WS = WebSocket;
53
+ }
54
+ return _WS;
55
+ }
56
+
57
+ const HELP = `
58
+ md2pdf ${VERSION} —— Markdown → 优雅 PDF
59
+
60
+ md2pdf <file.md> [file2.md ...] [选项]
61
+
62
+ 选项:
63
+ -o, --output <path> 输出路径(默认与输入同目录同名 .pdf)
64
+ --theme <name> elegant(默认,墨蓝+古铜)| minimal(黑白公文)
65
+ --title <text> 覆盖标题
66
+ --kicker <text> 报头小标题
67
+ --no-meta 不生成元信息条
68
+ --no-lead 首段不作为导语
69
+ -t, --toc 在文首插入目录页(取自二级标题,可点击跳转)
70
+ --no-outline 不生成 PDF 书签(默认生成,阅读器侧边栏按标题成树)
71
+ --link-urls 正文链接后附 URL
72
+ --landscape 横向
73
+ --font-size <pt> 正文字号(默认 10.5)
74
+ --margin <mm> 页边距(默认 20;可写 "20,18" = 上下,左右)
75
+ --no-footer 不要页脚页码
76
+ --footer-left/--footer-right <text>
77
+ --colophon <text> 文末落款
78
+ --keep-html 保留中间 HTML
79
+ --html-only 只生成 HTML,不启动浏览器(调试样式 / CI 校验用)
80
+ --open 完成后打开 PDF
81
+ -h, --help
82
+
83
+ 示例:
84
+ md2pdf SKILL.md
85
+ md2pdf notes.md --theme minimal --toc --open
86
+ md2pdf a.md b.md -o /tmp/ # 多文件时 -o 视为输出目录
87
+ `;
88
+
89
+ /* ---------------- 参数 ---------------- */
90
+
91
+ // 展开 --flag=value / --flag=false 写法
92
+ function expandArgs(argv) {
93
+ const out = [];
94
+ for (const a of argv) {
95
+ const m = /^(--[a-z][a-z-]*)=(.*)$/i.exec(a);
96
+ if (m && !['--output', '--footer-left', '--footer-right'].includes(m[1])) {
97
+ const falsey = /^(false|0|no)$/i.test(m[2]);
98
+ out.push(falsey ? `--no-${m[1].slice(2)}` : m[1]);
99
+ if (!falsey && m[2] !== '') out.push(m[2]);
100
+ } else out.push(a);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function parseArgs(argv) {
106
+ const o = {
107
+ inputs: [], theme: 'elegant', fontSize: 10.5,
108
+ marginTop: 20, marginSide: 18, marginBottom: 18,
109
+ footer: true, footerLeft: '', footerRight: '',
110
+ meta: true, lead: true, toc: false, linkUrls: false, outline: true,
111
+ landscape: false, keepHtml: false, htmlOnly: false, open: false, help: false,
112
+ };
113
+ for (let i = 0; i < argv.length; i++) {
114
+ const a = argv[i];
115
+ const next = () => argv[++i];
116
+ switch (a) {
117
+ case '-h': case '--help': o.help = true; break;
118
+ case '-o': case '--output': o.output = next(); break;
119
+ case '--theme': o.theme = next(); break;
120
+ case '--title': o.title = next(); break;
121
+ case '--kicker': o.kicker = next(); break;
122
+ case '--no-meta': o.meta = false; break;
123
+ case '--no-lead': o.lead = false; break;
124
+ case '-t': case '--toc': o.toc = true; break;
125
+ case '--link-urls': o.linkUrls = true; break;
126
+ case '--landscape': o.landscape = true; break;
127
+ case '--font-size': o.fontSize = parseFloat(next()); break;
128
+ case '--margin': {
129
+ const v = next(); const m = String(v).split(',').map(s => parseFloat(s.trim()));
130
+ o.marginTop = m[0]; o.marginBottom = m[0]; o.marginSide = m[1] ?? m[0];
131
+ break;
132
+ }
133
+ case '--no-footer': o.footer = false; break;
134
+ case '--footer-left': o.footerLeft = next(); break;
135
+ case '--footer-right': o.footerRight = next(); break;
136
+ case '--colophon': o.colophon = next(); break;
137
+ case '--keep-html': o.keepHtml = true; break;
138
+ case '--html-only': o.htmlOnly = true; break;
139
+ case '--no-html-only': o.htmlOnly = false; break;
140
+ case '--open': o.open = true; break;
141
+ case '--no-open': o.open = false; break;
142
+ case '--no-toc': o.toc = false; break;
143
+ case '--outline': o.outline = true; break;
144
+ case '--no-outline': o.outline = false; break;
145
+ case '--no-landscape': o.landscape = false; break;
146
+ case '--no-link-urls': o.linkUrls = false; break;
147
+ case '--no-keep-html': o.keepHtml = false; break;
148
+ case '--version': o.version = true; break;
149
+ default:
150
+ if (a.startsWith('-')) { console.warn(`md2pdf: 忽略未知选项 ${a}`); }
151
+ else o.inputs.push(a);
152
+ }
153
+ }
154
+ return o;
155
+ }
156
+
157
+ /* ---------------- 工具 ---------------- */
158
+
159
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
160
+ const stripTags = s => String(s).replace(/<[^>]+>/g, '').trim();
161
+
162
+ function splitFrontmatter(src) {
163
+ const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src);
164
+ if (!m) return { fm: {}, body: src };
165
+ const fm = {};
166
+ for (const line of m[1].split(/\r?\n/)) {
167
+ const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*)$/.exec(line);
168
+ if (kv) {
169
+ let v = kv[2].trim().replace(/^["']|["']$/g, '');
170
+ fm[kv[1]] = v;
171
+ } else if (/^\s+/.test(line) && Object.keys(fm).length) {
172
+ const k = Object.keys(fm).at(-1);
173
+ fm[k] = (fm[k] + ' ' + line.trim()).trim();
174
+ }
175
+ }
176
+ return { fm, body: src.slice(m[0].length) };
177
+ }
178
+
179
+ function buildMeta(fm, isSkill) {
180
+ if (!fm.name && !fm.description) return '';
181
+ const items = [];
182
+ if (fm.name) {
183
+ items.push([isSkill ? 'SKILL NAME' : 'NAME', `<code>${esc(fm.name)}</code>`]);
184
+ }
185
+ if (fm.description) {
186
+ const d = fm.description;
187
+ const suit = /适用于([^;。]+)/.exec(d);
188
+ const notsuit = /不用于([^;。]+)/.exec(d);
189
+ if (suit || notsuit) {
190
+ if (suit) items.push(['适用', esc(suit[1].trim())]);
191
+ if (notsuit) items.push(['不适用', esc(notsuit[1].trim())]);
192
+ } else if (fm.name) {
193
+ items.push(['说明', esc(d)]);
194
+ } else {
195
+ items.push(['DESCRIPTION', esc(d)]);
196
+ }
197
+ }
198
+ if (!items.length) return '';
199
+ const html = items.map(([l, v]) =>
200
+ `<div class="meta-item"><span class="meta-label">${esc(l)}</span><span class="meta-value">${v}</span></div>`
201
+ ).join('\n ');
202
+ return `<div class="meta">\n ${html}\n</div>`;
203
+ }
204
+
205
+ function sectionize(html) {
206
+ return html.split(/(?=<h2[\s>])/)
207
+ .map(p => (p.trim().startsWith('<h2') ? `<section>\n${p}\n</section>` : p))
208
+ .join('\n');
209
+ }
210
+
211
+ /* ---------------- Chrome ---------------- */
212
+
213
+ function findChrome() {
214
+ if (process.env.MD2PDF_CHROME && existsSync(process.env.MD2PDF_CHROME)) return process.env.MD2PDF_CHROME;
215
+ const candidates = [
216
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
217
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
218
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
219
+ '/usr/bin/google-chrome',
220
+ '/usr/bin/google-chrome-stable',
221
+ '/usr/bin/chromium',
222
+ '/usr/bin/chromium-browser',
223
+ '/snap/bin/chromium',
224
+ ];
225
+ for (const c of candidates) if (existsSync(c)) return c;
226
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
227
+ const w = spawnSync('which', [name], { encoding: 'utf8' });
228
+ if (w.status === 0 && w.stdout.trim()) return w.stdout.trim();
229
+ }
230
+ throw new Error('未找到 Chrome/Edge/Chromium,请用环境变量 MD2PDF_CHROME 指定路径');
231
+ }
232
+
233
+ const getJson = (port, p) => new Promise((res, rej) => {
234
+ const req = http.get({ host: '127.0.0.1', port, path: p }, r => {
235
+ let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(e); } });
236
+ });
237
+ req.on('error', rej);
238
+ req.setTimeout(2000, () => req.destroy(new Error('timeout')));
239
+ });
240
+
241
+ async function waitDevTools(port, ms) {
242
+ const t0 = Date.now();
243
+ while (Date.now() - t0 < ms) {
244
+ try { await getJson(port, '/json/version'); return true; } catch { await new Promise(r => setTimeout(r, 200)); }
245
+ }
246
+ throw new Error('Chrome DevTools 启动超时');
247
+ }
248
+
249
+ class Chrome {
250
+ constructor(bin, tmpRoot) { this.bin = bin; this.tmpRoot = tmpRoot; }
251
+ async start() {
252
+ this.userDataDir = await mkdtemp(path.join(this.tmpRoot, 'chrome-'));
253
+ const port = 9200 + Math.floor(Math.random() * 700);
254
+ this.port = port;
255
+ this.proc = spawn(this.bin, [
256
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--disable-extensions',
257
+ '--disable-background-networking', '--no-first-run', '--no-default-browser-check',
258
+ '--disable-features=Translate,OptimizationHints',
259
+ `--user-data-dir=${this.userDataDir}`,
260
+ `--remote-debugging-port=${port}`,
261
+ 'about:blank',
262
+ ], { stdio: 'ignore' });
263
+ this.proc.on('error', () => {});
264
+ await waitDevTools(port, 20000);
265
+ const list = await getJson(port, '/json/list');
266
+ const page = list.find(t => t.type === 'page');
267
+ if (!page) throw new Error('未找到可打印的页面目标');
268
+ const WS = await getWSClass();
269
+ this.ws = new WS(page.webSocketDebuggerUrl);
270
+ await new Promise((res, rej) => { this.ws.onopen = res; this.ws.onerror = rej; });
271
+ this.id = 0; this.pending = new Map();
272
+ this.ws.onmessage = e => {
273
+ const m = JSON.parse(e.data);
274
+ if (m.id && this.pending.has(m.id)) { this.pending.get(m.id)(m); this.pending.delete(m.id); }
275
+ };
276
+ await this.send('Page.enable');
277
+ }
278
+ send(method, params) {
279
+ const id = ++this.id;
280
+ return new Promise((res, rej) => {
281
+ this.pending.set(id, m => (m.error ? rej(new Error(m.error.message)) : res(m.result)));
282
+ this.ws.send(JSON.stringify({ id, method, params }));
283
+ });
284
+ }
285
+ async print(htmlPath, opts) {
286
+ const loaded = new Promise(res => {
287
+ const h = ev => {
288
+ const m = JSON.parse(ev.data);
289
+ if (m.method === 'Page.loadEventFired') { this.ws.removeEventListener('message', h); res(); }
290
+ };
291
+ this.ws.addEventListener('message', h);
292
+ });
293
+ await this.send('Page.navigate', { url: pathToFileURL(htmlPath).href });
294
+ await loaded;
295
+ await new Promise(r => setTimeout(r, 250));
296
+ const base = {
297
+ printBackground: true,
298
+ preferCSSPageSize: true,
299
+ landscape: !!opts.landscape,
300
+ displayHeaderFooter: !!opts.footer,
301
+ headerTemplate: '<span></span>',
302
+ footerTemplate: opts.footerTemplate || '<span></span>',
303
+ marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0,
304
+ };
305
+ // PDF 书签:Chrome 按 h1–h6 结构写成 /Outlines 树,阅读器侧边栏可点击跳转。
306
+ // 老版本 Chrome 不认这个参数,出错就退回不带书签的渲染(页脚页码仍保留)。
307
+ let res;
308
+ if (opts.outline) {
309
+ res = await this.send('Page.printToPDF', { ...base, generateDocumentOutline: true });
310
+ if (res.error) res = await this.send('Page.printToPDF', base);
311
+ } else {
312
+ res = await this.send('Page.printToPDF', base);
313
+ }
314
+ if (res.error) throw new Error(`渲染失败:${res.error.message}`);
315
+ return Buffer.from(res.data, 'base64');
316
+ }
317
+ async stop() {
318
+ try { this.ws && this.ws.close(); } catch {}
319
+ try { this.proc && this.proc.kill('SIGKILL'); } catch {}
320
+ try { this.userDataDir && await rm(this.userDataDir, { recursive: true, force: true }); } catch {}
321
+ }
322
+ }
323
+
324
+ /* ---------------- 渲染 ---------------- */
325
+
326
+ async function renderOne(mdPath, opts, chrome, marked, tmpRoot) {
327
+ const src = await readFile(mdPath, 'utf8');
328
+ const { fm, body } = splitFrontmatter(src);
329
+ const isSkill = path.basename(mdPath) === 'SKILL.md' || !!fm.name;
330
+
331
+ let html = marked.parse(body, { gfm: true, breaks: false, async: false });
332
+
333
+ // 标题:正文首个 H1 → 报头
334
+ let title = opts.title || fm.title || '';
335
+ const h1 = /<h1(?:\s[^>]*)?>([\s\S]*?)<\/h1>/.exec(html);
336
+ if (h1) {
337
+ const t = stripTags(h1[1]);
338
+ if (!title) title = t;
339
+ html = html.replace(h1[0], '');
340
+ }
341
+ if (!title) title = path.basename(mdPath, path.extname(mdPath));
342
+
343
+ // 导语:移除 H1 后的第一段
344
+ let lead = '';
345
+ if (opts.lead) {
346
+ const p = /<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/.exec(html);
347
+ if (p && stripTags(p[1]).length > 12 && p.index < 2000) {
348
+ lead = p[1];
349
+ html = html.replace(p[0], '');
350
+ }
351
+ }
352
+
353
+ // 链接 / 图片
354
+ html = html.replace(/<a\s+href="([^"]*)"([^>]*)>/g, (m, href, rest) =>
355
+ `<a href="${href}" class="ref"${rest}>`);
356
+ if (opts.linkUrls) {
357
+ html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/g,
358
+ (m, href, text) => `${m} <span class="link-url">(${esc(href)})</span>`);
359
+ }
360
+ const dir = path.dirname(path.resolve(mdPath));
361
+ html = html.replace(/<img\s+([^>]*?)src="([^"]+)"([^>]*)>/g, (m, pre, src, post) => {
362
+ if (/^(https?:|data:|file:)/.test(src)) return m;
363
+ const abs = path.resolve(dir, src);
364
+ return `<img ${pre}src="${pathToFileURL(abs).href}"${post}>`;
365
+ });
366
+
367
+ // 章节锚点:给二级标题补 id,供目录内链与(Chrome 生成的)PDF 书签定位
368
+ const secIds = [];
369
+ html = html.replace(/<h2(\s[^>]*)?>([\s\S]*?)<\/h2>/g, (m, attrs = '', inner = '') => {
370
+ const has = /\sid="/.test(attrs);
371
+ if (has) {
372
+ secIds.push({ id: /\sid="([^"]+)"/.exec(attrs)[1], text: stripTags(inner) });
373
+ return m;
374
+ }
375
+ const id = `sec-${secIds.length + 1}`;
376
+ secIds.push({ id, text: stripTags(inner) });
377
+ return `<h2${attrs} id="${id}">${inner}</h2>`;
378
+ });
379
+
380
+ // 目录:文首一张可点击的目录页(仅当二级标题多于一个才值得排)
381
+ let toc = '';
382
+ if (opts.toc && secIds.length > 1) {
383
+ toc = `<div class="toc"><div class="toc-title">目 录</div><ol>` +
384
+ secIds.map(s => `<li><a href="#${s.id}">${esc(s.text)}</a></li>`).join('') + `</ol></div>`;
385
+ }
386
+
387
+ html = sectionize(html);
388
+
389
+ // CSS
390
+ const themeFile = path.join(ASSETS, `theme-${opts.theme}.css`);
391
+ if (!existsSync(themeFile)) throw new Error(`未知主题:${opts.theme}(可用:elegant, minimal)`);
392
+ const base = await readFile(path.join(ASSETS, 'base.css'), 'utf8');
393
+ const theme = await readFile(themeFile, 'utf8');
394
+ const css = (base + '\n' + theme)
395
+ .replace(/\{\{PAGE_SIZE\}\}/g, opts.landscape ? 'A4 landscape' : 'A4')
396
+ .replace(/\{\{MARGIN_TOP\}\}/g, `${opts.marginTop}mm`)
397
+ .replace(/\{\{MARGIN_BOTTOM\}\}/g, `${opts.marginBottom}mm`)
398
+ .replace(/\{\{MARGIN_SIDE\}\}/g, `${opts.marginSide}mm`)
399
+ .replace(/\{\{FONT_SIZE\}\}/g, `${opts.fontSize}pt`);
400
+
401
+ const kicker = opts.kicker || fm.kicker || fm.category || (isSkill ? '技能文档' : '');
402
+ const colophonLeft = opts.colophon ?? (isSkill && fm.name ? `SKILL · ${fm.name}` : path.basename(mdPath));
403
+ const colophonRight = opts.colophon ? '' : title;
404
+
405
+ // 用函数形式替换:既支持多处占位符,也避免用户文本里的 $& 被当作替换模式
406
+ const fill = (tpl, map) => Object.entries(map).reduce(
407
+ (s, [k, v]) => s.split(k).join(v), tpl);
408
+
409
+ const shell = await readFile(path.join(ASSETS, 'shell.html'), 'utf8');
410
+ const out = fill(shell, {
411
+ '{{TITLE}}': esc(title),
412
+ '{{CSS}}': css,
413
+ '{{KICKER}}': esc(kicker),
414
+ '{{LEAD}}': lead,
415
+ '{{META}}': opts.meta ? buildMeta(fm, isSkill) : '',
416
+ '{{TOC}}': toc,
417
+ '{{BODY}}': html,
418
+ '{{COLOPHON_LEFT}}': esc(colophonLeft),
419
+ '{{COLOPHON_RIGHT}}': esc(colophonRight),
420
+ });
421
+
422
+ if (opts.htmlOnly) return { title, html: out };
423
+
424
+ const tmpDir = await mkdtemp(path.join(tmpRoot, 'doc-'));
425
+ const htmlPath = path.join(tmpDir, 'index.html');
426
+ await writeFile(htmlPath, out, 'utf8');
427
+
428
+ const footerTemplate = `<div style="width:100%;font-size:8px;color:#8a8578;font-family:-apple-system,'PingFang SC',sans-serif;letter-spacing:.5px;display:flex;justify-content:space-between;padding:0 12mm;">
429
+ <span style="flex:1;text-align:left;">${opts.footerLeft || ''}</span>
430
+ <span style="flex:1;text-align:center;"><span class="pageNumber"></span> / <span class="totalPages"></span></span>
431
+ <span style="flex:1;text-align:right;">${opts.footerRight || ''}</span>
432
+ </div>`;
433
+
434
+ const buf = await chrome.print(htmlPath, { footer: opts.footer, footerTemplate, landscape: opts.landscape, outline: opts.outline });
435
+
436
+ if (opts.keepHtml) {
437
+ await writeFile(mdPath.replace(/\.md$/i, '.html'), out, 'utf8');
438
+ } else {
439
+ await rm(tmpDir, { recursive: true, force: true });
440
+ }
441
+ return { title, buf };
442
+ }
443
+
444
+ /* ---------------- main ---------------- */
445
+
446
+ function isDir(p) {
447
+ try { return statSync(p).isDirectory(); } catch { return false; }
448
+ }
449
+
450
+ function resolveOutput(mdPath, opts) {
451
+ const ext = opts.htmlOnly ? '.html' : '.pdf';
452
+ if (!opts.output) return mdPath.replace(/\.md$/i, ext);
453
+ const o = path.resolve(opts.output);
454
+ if (opts.inputs.length > 1 || isDir(o) || o.endsWith(path.sep)) {
455
+ return path.join(o, path.basename(mdPath, path.extname(mdPath)) + ext);
456
+ }
457
+ return o;
458
+ }
459
+
460
+ async function main() {
461
+ const opts = parseArgs(expandArgs(process.argv.slice(2)));
462
+ if (opts.help) { console.log(HELP); return; }
463
+ if (opts.version) { console.log(VERSION); return; }
464
+ if (!opts.inputs.length) { console.log(HELP); process.exitCode = 1; return; }
465
+
466
+ const { Marked } = await import(pathToFileURL(path.join(ROOT, 'vendor', 'marked.esm.js')).href);
467
+ const marked = new Marked({ gfm: true });
468
+
469
+ const tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'md2pdf-'));
470
+ // --html-only 不需要浏览器(CI / 调样式时用)
471
+ const chrome = opts.htmlOnly ? null : new Chrome(findChrome(), tmpRoot);
472
+ let failed = false;
473
+ try {
474
+ if (chrome) await chrome.start();
475
+ for (const input of opts.inputs) {
476
+ const mdPath = path.resolve(input);
477
+ if (!existsSync(mdPath)) { console.error(`✗ 找不到文件:${input}`); failed = true; continue; }
478
+ try {
479
+ const res = await renderOne(mdPath, opts, chrome, marked, tmpRoot);
480
+ const out = resolveOutput(mdPath, opts);
481
+ await mkdir(path.dirname(out), { recursive: true });
482
+ if (opts.htmlOnly) {
483
+ await writeFile(out, res.html, 'utf8');
484
+ console.log(`✓ ${path.basename(out)} (HTML ${(res.html.length / 1024).toFixed(0)} KB)`);
485
+ continue;
486
+ }
487
+ const buf = res.buf;
488
+ await writeFile(out, buf);
489
+ let pages = '';
490
+ const info = spawnSync('pdfinfo', [out], { encoding: 'utf8' });
491
+ if (info.status === 0) {
492
+ const m = /Pages:\s+(\d+)/.exec(info.stdout);
493
+ if (m) pages = `,${m[1]} 页`;
494
+ }
495
+ console.log(`✓ ${path.basename(out)} (${(buf.length / 1024).toFixed(0)} KB${pages})`);
496
+ if (opts.open) {
497
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
498
+ spawn(cmd, [out], { stdio: 'ignore' });
499
+ }
500
+ } catch (e) {
501
+ console.error(`✗ ${input}:${e.message}`);
502
+ failed = true;
503
+ }
504
+ }
505
+ } finally {
506
+ if (chrome) await chrome.stop();
507
+ await rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
508
+ }
509
+ if (failed) process.exitCode = 1;
510
+ }
511
+
512
+ main().catch(e => { console.error('md2pdf 失败:', e.message); process.exit(1); });