@puwenhui/dsh-md2pdf 0.1.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 +168 -0
- package/cordis.patch.yml +13 -0
- package/lib/guide-style.css +104 -0
- package/lib/plugin.mjs +519 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# @puwenhui/dsh-md2pdf
|
|
2
|
+
|
|
3
|
+
DSH 插件:**把 Markdown 转成 PDF**(同时产出同名单文件 HTML)。在对话里直接说「把某个 .md 转成 PDF」即可,也可在浏览器里点链接预览/下载产物。
|
|
4
|
+
|
|
5
|
+
定位:把原 `webphone-frontend-sdk/tools/md2pdf.mjs` 的能力**插件化**——同一套渲染管线(markdown-it + highlight.js + guide-style.css + 本机 Chromium 打印),从「命令行/拖拽」升级为「对话即用」。
|
|
6
|
+
|
|
7
|
+
## 能力一览
|
|
8
|
+
|
|
9
|
+
| 面 | 说明 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| 模型工具 `md2pdf_convert` | 对话中把 1 个或多个 .md(或目录)转成 PDF |
|
|
12
|
+
| HTTP 端点 `/dsh-md2pdf/download` | 浏览器直接预览/下载刚生成的 PDF/HTML |
|
|
13
|
+
|
|
14
|
+
**页眉自动生成**:取文档**第一个一级标题**(`# xxx`),没有则回退文件名主干;页脚固定为「当前页 / 总页数」。页眉无需人工指定。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
从 npm 安装(已发布):
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
dsh plugin --profile web add @puwenhui/dsh-md2pdf
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
也可以从本地目录安装(开发中/未发布时):
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
dsh plugin --profile web add file:D:/ds-harness/dsh-md2pdf-plugin
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
包内声明了 `dsh.bundle.patch`,`dsh` 会**自动**把本包追加进 profile 的 `dsh.profile.bundles`,无需手改任何 profile 清单。
|
|
31
|
+
|
|
32
|
+
安装后**重启 `dsh web`**(bundle 层只在启动时组装):
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
& node D:\deepseek-harness\apps\cli\lib\bin.js web
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
验证是否挂上(不必启动服务):
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
& node D:\deepseek-harness\apps\cli\lib\bin.js --profile web --dump-config | Select-String md2pdf
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### ⚠️ 安装时实测到的坑:`dsh plugin add` 会剪掉 profile 的安装回退链接
|
|
45
|
+
|
|
46
|
+
本插件是在**真实 web profile** 上实测安装的,过程中踩到一个 DSH 层面的坑,记录在此以免重踩:
|
|
47
|
+
|
|
48
|
+
- `dsh plugin add` 会在 profile 目录里跑一次 `pnpm`。pnpm 的默认行为是**清理 extraneous(不在 lockfile 里的)条目**。
|
|
49
|
+
- `$DSH_HOME/profiles/web/node_modules` 里除了 pnpm 管的依赖,还可能有 DSH 安装器放的**安装回退链接**(指向 dsh 安装目录里的内置包)。这些不在 profile 的 lockfile 里,于是会被 pnpm 当作 extraneous 删掉。
|
|
50
|
+
- 后果:profile 下次启动时报
|
|
51
|
+
`Cannot find package '@deepseek-ai/dsh-api-session-controller' imported from ...\profiles\web\`,**整个宿主起不来**(报错只提内置包,容易被误判为插件问题)。
|
|
52
|
+
|
|
53
|
+
**判断方法**:把 composed config 里每个插件行的 `name` 逐个从 profile 目录解析一遍,
|
|
54
|
+
只要有一个解析不到,宿主就一定起不来:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
& node D:\deepseek-harness\apps\cli\lib\bin.js --profile web --dump-config # 取全部 name
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**修复**:把缺失的内置包补回 `$DSH_HOME/profiles/node_modules/@deepseek-ai/`
|
|
61
|
+
(该目录是「安装依赖闭包的镜像」,由 `healProfilesModuleFallback` **只增不删**地维护;
|
|
62
|
+
而 pnpm 只作用于 `profiles/web`,不会碰它 —— 所以补在这里既正确又不会被下次安装再次剪掉)。
|
|
63
|
+
本次实测缺失的 7 个及其来源:
|
|
64
|
+
|
|
65
|
+
| 包 | 指向 |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `@deepseek-ai/dsh-api-session-controller` | `packages/api/session-controller` |
|
|
68
|
+
| `@deepseek-ai/dsh-api-settings-controller` | `packages/api/settings-controller` |
|
|
69
|
+
| `@deepseek-ai/dsh-api-workspace-controller` | `packages/api/workspace-controller` |
|
|
70
|
+
| `@deepseek-ai/dsh-client-ui-session` | `packages/client/ui-session` |
|
|
71
|
+
| `@deepseek-ai/dsh-client-ui-approval` | `packages/client/ui-approval` |
|
|
72
|
+
| `@deepseek-ai/dsh-client-ui-chat` | `packages/client/ui-chat` |
|
|
73
|
+
| `@deepseek-ai/dsh-client-ui-schedule` | `packages/client/ui-schedule` |
|
|
74
|
+
|
|
75
|
+
(`packages/` 指 dsh 安装/检出目录,例如 `D:\deepseek-harness\packages\`。)
|
|
76
|
+
|
|
77
|
+
> 该问题与插件内容无关:任何 `dsh plugin add` 都可能触发。若 `dsh plugin add` 被中断
|
|
78
|
+
> (例如网络卡在某个 optional 包的拉取上而超时),更容易留下这种半成品状态。
|
|
79
|
+
|
|
80
|
+
## 用法
|
|
81
|
+
|
|
82
|
+
对话里直接说,例如:
|
|
83
|
+
|
|
84
|
+
- 「把 `D:\docs\手册.md` 转成 PDF」
|
|
85
|
+
- 「把 `docs/` 目录下所有 md 都转成 PDF」
|
|
86
|
+
- 「转这个 md,页眉写『某某手册』」
|
|
87
|
+
|
|
88
|
+
工具参数:
|
|
89
|
+
|
|
90
|
+
| 参数 | 必填 | 说明 |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `inputs` | ✅ | 一个或多个 .md 文件路径,或包含 .md 的目录(取其中一层全部 .md) |
|
|
93
|
+
| `out_dir` | | 输出目录;**缺省与源 .md 同目录**(沿用原脚本行为) |
|
|
94
|
+
| `title` | | 强制指定页眉标题(缺省自动取文档一级标题) |
|
|
95
|
+
| `label` | | 页眉右侧标签(缺省用插件配置 `headerLabel`,通常为空) |
|
|
96
|
+
|
|
97
|
+
产物与源文件同名:`手册.md` → `手册.pdf` + `手册.html`。相对路径按**会话工作目录**解析。
|
|
98
|
+
|
|
99
|
+
返回结果里带 `downloadUrl`,形如
|
|
100
|
+
`http://127.0.0.1:3080/dsh-md2pdf/download?path=...&disposition=inline`,
|
|
101
|
+
浏览器打开即预览(`disposition=attachment` 则下载)。
|
|
102
|
+
|
|
103
|
+
## 配置(可选,全部有默认值)
|
|
104
|
+
|
|
105
|
+
改 `cordis.patch.yml` 的 `config` 段:
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
- insert:
|
|
109
|
+
- id: md2pdf
|
|
110
|
+
name: "@puwenhui/dsh-md2pdf"
|
|
111
|
+
config:
|
|
112
|
+
chromePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
|
113
|
+
cssPath: "C:\\path\\to\\your.css"
|
|
114
|
+
headerLabel: "263 云通信"
|
|
115
|
+
pageFormat: "A4"
|
|
116
|
+
downloadBaseUrl: "http://127.0.0.1:3080"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
也可用环境变量 `MD2PDF_CHROME` 指定浏览器。
|
|
120
|
+
|
|
121
|
+
## 依赖与本机要求
|
|
122
|
+
|
|
123
|
+
- 运行时依赖:`markdown-it`、`highlight.js`、`puppeteer-core`(装在插件自己的 `node_modules`,随包解析,不依赖宿主)。
|
|
124
|
+
- **需要本机已装 Chrome 或 Edge**(`puppeteer-core` 不下载浏览器)。未探测到时报错会列出全部候选路径与三种解决办法。
|
|
125
|
+
- 自动探测顺序:`MD2PDF_CHROME` → `config.chromePath` → Chrome 常见安装位 → Edge 常见安装位 → Playwright 的 chromium 缓存。
|
|
126
|
+
|
|
127
|
+
## 自检
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
cd D:\ds-harness\dsh-md2pdf-plugin
|
|
131
|
+
node scripts/smoke.mjs # 29 项断言:模块契约→工具注册→真渲染→下载端点→DSH schema 校验
|
|
132
|
+
node scripts/convert.mjs <a.md> [--out <dir>] [--title <t>] [--label <l>] # 命令行直转(等价原 md2pdf.mjs)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
smoke 用假 `ctx` 把整条链路跑一遍,退出码即结果;convert 是不走 DSH 宿主的 CLI 入口,用于脚本批处理或工具尚未加载时。
|
|
136
|
+
|
|
137
|
+
## 与原 `tools/md2pdf.mjs` 的关系
|
|
138
|
+
|
|
139
|
+
渲染管线、页眉页脚版式、A4 页边距、`guide-style.css` 全部保持一致,产物外观相同。移植过程中**修掉了原脚本的两个真实缺陷**:
|
|
140
|
+
|
|
141
|
+
1. **标题渲染成了 `<hh1>`**:原 `heading_open` 规则写 `` `<h${tokens[idx].tag} ...>` ``,而 markdown-it 的 `token.tag` 已经是 `h1`,于是产出 `<hh1>`——未知元素会退化成 `inline`,**所有标题的一级/二级样式全部丢失**(实测原稿 h1 与正文同字号)。现改为 `<${tokens[idx].tag} ...>`,标题按 CSS 正常缩放(h1 = 1.9em)。
|
|
142
|
+
2. **带 UTF-8 BOM 的 .md 取不到页眉标题**:BOM 让首行 `#` 前多一个 `\uFEFF`,`^#` 匹配失败,页眉退化成文件名。现读取时统一去 BOM,并把正则放宽到允许 ATX 标题前最多 3 个空格。注意:**本仓库的《SDK 接入手册》正是带 BOM 的文件**,所以这个 bug 在原脚本下是必然触发的。
|
|
143
|
+
|
|
144
|
+
两项均已在 `scripts/smoke.mjs` 里加了回归断言。
|
|
145
|
+
|
|
146
|
+
## 已知边界
|
|
147
|
+
|
|
148
|
+
- 目录输入只取**一层** `.md`,不递归。
|
|
149
|
+
- 下载端点只服务**本进程本次运行中由本插件产出**的文件(内存白名单),宿主重启后旧链接失效;端点同时限定 loopback 直连、无代理转发头、Origin 与 Host 同源,**无法读取任意文件**。
|
|
150
|
+
- 单个 .md 过大(几十 MB 级)时 Chromium 打印会明显变慢;工具 `timeoutMs` 设为 180 秒。
|
|
151
|
+
|
|
152
|
+
## 目录结构
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
dsh-md2pdf-plugin/
|
|
156
|
+
├── package.json # 声明 dsh.bundle.patch(决定 dsh plugin 自动加入 bundles)
|
|
157
|
+
├── cordis.patch.yml # 本插件贡献的插件行 + 可选 config
|
|
158
|
+
├── lib/
|
|
159
|
+
│ ├── plugin.mjs # 宿主半边:md2pdf_convert 工具 + 下载端点
|
|
160
|
+
│ └── guide-style.css # 默认导出样式(与原 tools/guide-style.css 同源)
|
|
161
|
+
└── scripts/ # 不随包发布(files 未包含)
|
|
162
|
+
├── smoke.mjs # 自检
|
|
163
|
+
└── convert.mjs # 命令行直转入口
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 本组合包贡献的插件行。声明了 dsh.bundle 后,`dsh plugin --profile web add <本包>`
|
|
2
|
+
# 会自动把本包加进 profile 的 dsh.profile.bundles,无需手改 profile 清单。
|
|
3
|
+
#
|
|
4
|
+
# 全部配置项都有内置默认值,因此这里默认不覆盖任何字段;
|
|
5
|
+
# 需要固定页眉右侧标签等再打开下面的 config。
|
|
6
|
+
- insert:
|
|
7
|
+
- id: md2pdf
|
|
8
|
+
name: "@puwenhui/dsh-md2pdf"
|
|
9
|
+
# config:
|
|
10
|
+
# headerLabel: "263 云通信" # 页眉右侧固定标签(缺省空)
|
|
11
|
+
# pageFormat: "A4" # A4 / Letter / A3 ...
|
|
12
|
+
# chromePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
|
13
|
+
# cssPath: "C:\\path\\to\\your.css"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出样式(HTML 与 PDF 共用 —— 一致性由同一条渲染管线保证)
|
|
3
|
+
*
|
|
4
|
+
* 与 D:\workspace-frontend\webphone-frontend-sdk\tools\guide-style.css 同源:
|
|
5
|
+
* 插件默认样式即该文件,保证「插件产物」与「原 md2pdf.mjs 产物」逐像素一致。
|
|
6
|
+
*
|
|
7
|
+
* - 屏幕态(HTML 单文件):GitHub 风格、居中栏宽;
|
|
8
|
+
* - 打印态(Chromium → PDF):A4、页边距由 page.pdf() 控制,此处处理
|
|
9
|
+
* 分页友好(表格/代码块尽量不跨页断裂、标题不孤行)。
|
|
10
|
+
* - 中文字体栈:Windows 优先微软雅黑,macOS 苹方。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--fg: #1f2329;
|
|
15
|
+
--fg-dim: #57606a;
|
|
16
|
+
--border: #d0d7de;
|
|
17
|
+
--bg-code: #f6f8fa;
|
|
18
|
+
--accent: #0969da;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
|
|
23
|
+
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
font: 14px/1.75 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
|
27
|
+
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
|
28
|
+
color: var(--fg);
|
|
29
|
+
margin: 0 auto;
|
|
30
|
+
padding: 40px 32px;
|
|
31
|
+
max-width: 880px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@media print {
|
|
35
|
+
body { max-width: none; padding: 0; font-size: 12px; }
|
|
36
|
+
a { color: var(--accent); text-decoration: none; }
|
|
37
|
+
h1, h2, h3, h4 { break-after: avoid; }
|
|
38
|
+
table, pre, .hljs { break-inside: avoid; }
|
|
39
|
+
tr, td, th { break-inside: avoid; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
h1, h2, h3, h4 { line-height: 1.35; break-after: avoid; }
|
|
43
|
+
h1 { font-size: 1.9em; border-bottom: 2px solid var(--border); padding-bottom: .35em; }
|
|
44
|
+
h2 { font-size: 1.45em; border-bottom: 1px solid var(--border); padding-bottom: .3em; margin-top: 2em; }
|
|
45
|
+
h3 { font-size: 1.2em; margin-top: 1.6em; }
|
|
46
|
+
h4 { font-size: 1.05em; }
|
|
47
|
+
|
|
48
|
+
p, li { color: var(--fg); }
|
|
49
|
+
strong { color: #000; }
|
|
50
|
+
|
|
51
|
+
code, pre {
|
|
52
|
+
font-family: ui-monospace, 'Cascadia Code', Consolas, 'Courier New', monospace;
|
|
53
|
+
}
|
|
54
|
+
code {
|
|
55
|
+
background: var(--bg-code);
|
|
56
|
+
padding: .15em .4em;
|
|
57
|
+
border-radius: 4px;
|
|
58
|
+
font-size: .92em;
|
|
59
|
+
}
|
|
60
|
+
pre {
|
|
61
|
+
background: var(--bg-code);
|
|
62
|
+
border: 1px solid var(--border);
|
|
63
|
+
border-radius: 6px;
|
|
64
|
+
padding: 12px 14px;
|
|
65
|
+
overflow-x: auto;
|
|
66
|
+
line-height: 1.6;
|
|
67
|
+
}
|
|
68
|
+
pre code { background: none; padding: 0; font-size: .88em; }
|
|
69
|
+
|
|
70
|
+
table {
|
|
71
|
+
border-collapse: collapse;
|
|
72
|
+
width: 100%;
|
|
73
|
+
margin: 1em 0;
|
|
74
|
+
font-size: .95em;
|
|
75
|
+
}
|
|
76
|
+
th, td {
|
|
77
|
+
border: 1px solid var(--border);
|
|
78
|
+
padding: 6px 10px;
|
|
79
|
+
text-align: left;
|
|
80
|
+
vertical-align: top;
|
|
81
|
+
}
|
|
82
|
+
th { background: var(--bg-code); font-weight: 600; }
|
|
83
|
+
|
|
84
|
+
blockquote {
|
|
85
|
+
margin: 1em 0;
|
|
86
|
+
padding: 4px 16px;
|
|
87
|
+
border-left: 4px solid var(--accent);
|
|
88
|
+
background: #f0f6ff;
|
|
89
|
+
color: var(--fg);
|
|
90
|
+
}
|
|
91
|
+
blockquote p { margin: .5em 0; }
|
|
92
|
+
|
|
93
|
+
hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
|
|
94
|
+
|
|
95
|
+
ul, ol { padding-left: 1.6em; }
|
|
96
|
+
li { margin: .25em 0; }
|
|
97
|
+
|
|
98
|
+
/* highlight.js(浅色主题内联要点,避免额外主题文件) */
|
|
99
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: #cf222e; }
|
|
100
|
+
.hljs-string, .hljs-attr { color: #0a3069; }
|
|
101
|
+
.hljs-comment { color: #6e7781; }
|
|
102
|
+
.hljs-number, .hljs-literal { color: #0550ae; }
|
|
103
|
+
.hljs-title, .hljs-name { color: #8250df; }
|
|
104
|
+
.hljs-tag { color: #116329; }
|
package/lib/plugin.mjs
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @puwenhui/dsh-md2pdf —— DSH 插件:Markdown → 单文件 HTML + A4 PDF
|
|
3
|
+
*
|
|
4
|
+
* ⭐ 一致性:HTML 与 PDF 出自同一条渲染管线(markdown-it + highlight.js + guide-style.css),
|
|
5
|
+
* PDF 即该 HTML 在 Chromium 里的打印快照,二者所见一致。
|
|
6
|
+
* 默认样式即 lib/guide-style.css,与原 tools/guide-style.css 同源 —— 插件产物与
|
|
7
|
+
* 原 `node tools/md2pdf.mjs` 产物保持一致。
|
|
8
|
+
*
|
|
9
|
+
* ⭐ 页眉自动生成:取文档第一个 `# 一级标题`;没有则回退文件名主干。
|
|
10
|
+
* 页脚固定为「当前页 / 总页数」。
|
|
11
|
+
*
|
|
12
|
+
* ⭐ 本插件只注册一个模型工具 `md2pdf_convert`,外加一个 loopback 限定的
|
|
13
|
+
* HTTP 端点 `/dsh-md2pdf/download`,供浏览器直接预览/下载刚生成的产物。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ PDF 打印用本机已装 Chrome/Edge(puppeteer-core,不下载浏览器)。
|
|
16
|
+
* 探测失败时给出候选路径清单,可用 MD2PDF_CHROME 或 config.chromePath 指定。
|
|
17
|
+
*
|
|
18
|
+
* 依赖解析:本模块只 import 自身依赖与 node: 内置模块,不 import 任何
|
|
19
|
+
* `@deepseek-ai/dsh-*` 包 —— 保证作为外部插件加载时零解析风险
|
|
20
|
+
* (parameters/output 已按 dsh-tools 的 JSON Schema 白名单子集手写)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import z from '@deepseek-ai/schemastery'
|
|
27
|
+
import hljs from 'highlight.js'
|
|
28
|
+
import MarkdownIt from 'markdown-it'
|
|
29
|
+
import puppeteer from 'puppeteer-core'
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
32
|
+
const DEFAULT_CSS = resolve(HERE, 'guide-style.css')
|
|
33
|
+
|
|
34
|
+
/** 下载端点路径(与 apply 里注册的路由、工具返回的 URL 共用同一常量)。 */
|
|
35
|
+
const DOWNLOAD_PATH = '/dsh-md2pdf/download'
|
|
36
|
+
|
|
37
|
+
/* ── 本机浏览器探测 ─────────────────────────────────────────── */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 候选浏览器可执行文件。环境变量优先,其后是 Windows 常见安装位,
|
|
41
|
+
* 最后兜底扫描 Playwright 的 chromium 缓存(本机可能已存在)。
|
|
42
|
+
* @returns 候选绝对路径(可能不存在,按顺序探测)。
|
|
43
|
+
*/
|
|
44
|
+
function chromeCandidates() {
|
|
45
|
+
const explicit = process.env.MD2PDF_CHROME?.trim()
|
|
46
|
+
const candidates = [
|
|
47
|
+
explicit === undefined || explicit === '' ? undefined : explicit,
|
|
48
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
49
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
50
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
51
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
52
|
+
]
|
|
53
|
+
for (const path of playwrightChromiumCandidates()) candidates.push(path)
|
|
54
|
+
return candidates.filter((candidate) => candidate !== undefined)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 扫描 `%LOCALAPPDATA%\ms-playwright` 下的 chromium 缓存(版本目录名不固定)。 */
|
|
58
|
+
function playwrightChromiumCandidates() {
|
|
59
|
+
const root = process.env.LOCALAPPDATA === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: join(process.env.LOCALAPPDATA, 'ms-playwright')
|
|
62
|
+
if (root === undefined || !existsSync(root)) return []
|
|
63
|
+
const found = []
|
|
64
|
+
try {
|
|
65
|
+
for (const entry of readdirSync(root)) {
|
|
66
|
+
if (entry.startsWith('chromium-')) {
|
|
67
|
+
found.push(join(root, entry, 'chrome-win64', 'chrome.exe'))
|
|
68
|
+
} else if (entry.startsWith('chromium_headless_shell-')) {
|
|
69
|
+
found.push(join(root, entry, 'chrome-headless-shell-win64', 'chrome-headless-shell.exe'))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* 缓存目录不可读时静默跳过:候选清单本就可选 */
|
|
74
|
+
}
|
|
75
|
+
return found
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 取第一个存在的浏览器可执行文件。
|
|
80
|
+
* @param preferred - 显式指定的路径(config.chromePath),优先于自动探测。
|
|
81
|
+
* @returns 可执行文件绝对路径,或 undefined。
|
|
82
|
+
*/
|
|
83
|
+
function findChrome(preferred) {
|
|
84
|
+
if (preferred !== undefined && preferred !== '' && existsSync(preferred)) return preferred
|
|
85
|
+
return chromeCandidates().find((candidate) => existsSync(candidate))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 未探测到浏览器时的报错文案(含候选清单与两种解决办法)。 */
|
|
89
|
+
function chromeMissingMessage() {
|
|
90
|
+
return '未找到本机 Chrome/Edge。'
|
|
91
|
+
+ '解决办法:① 安装 Chrome/Edge;② 设环境变量 MD2PDF_CHROME=<chrome.exe 路径>;'
|
|
92
|
+
+ '③ 在插件行的 config.chromePath 指定。已探测:' + chromeCandidates().join(' | ')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── Markdown 渲染(标题锚点 + 代码高亮)────────────────────── */
|
|
96
|
+
|
|
97
|
+
let markdownSingleton
|
|
98
|
+
|
|
99
|
+
/** 惰性创建并复用 markdown-it 实例(render 本身无状态,可安全复用)。 */
|
|
100
|
+
function markdown() {
|
|
101
|
+
if (markdownSingleton !== undefined) return markdownSingleton
|
|
102
|
+
const md = new MarkdownIt({
|
|
103
|
+
html: true,
|
|
104
|
+
linkify: true,
|
|
105
|
+
highlight(code, lang) {
|
|
106
|
+
if (lang !== '' && hljs.getLanguage(lang) !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
|
|
109
|
+
} catch {
|
|
110
|
+
/* 高亮失败回退纯文本 */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ''
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
md.renderer.rules.heading_open = (tokens, idx) => {
|
|
117
|
+
const text = tokens[idx + 1]?.content ?? ''
|
|
118
|
+
const id = text
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/\s+/g, '-')
|
|
123
|
+
// token.tag 已经是完整标签名('h1'/'h2'…),不能再补一个 'h',
|
|
124
|
+
// 否则渲染出 <hh1> —— 未知元素会退化成 inline,标题样式全部丢失。
|
|
125
|
+
return `<${tokens[idx].tag} id="${id}">`
|
|
126
|
+
}
|
|
127
|
+
markdownSingleton = md
|
|
128
|
+
return md
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 读取 Markdown 原文并去掉可能存在的 UTF-8 BOM。
|
|
133
|
+
* Windows 上大量 .md 带 BOM(本仓库的接入手册就是),若不去掉:
|
|
134
|
+
* ① 首行的 `#` 前面多一个 \uFEFF,一级标题正则匹配不到,页眉会退化成文件名;
|
|
135
|
+
* ② BOM 会被当作正文渲染出来。
|
|
136
|
+
* @param path - .md 绝对路径。
|
|
137
|
+
* @returns 去 BOM 后的 UTF-8 文本。
|
|
138
|
+
*/
|
|
139
|
+
function readMarkdown(path) {
|
|
140
|
+
return readFileSync(path, 'utf8').replace(/^\uFEFF/, '')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 页眉/`<title>` 用标题:取文档第一个一级标题,没有则回退文件名主干。
|
|
145
|
+
* 允许 ATX 标题前最多 3 个空格(CommonMark);`##` 不会被误判为一级标题。
|
|
146
|
+
* @param source - Markdown 原文(应已去 BOM)。
|
|
147
|
+
* @param fallback - 回退标题(通常为文件主干名)。
|
|
148
|
+
* @returns 文档标题。
|
|
149
|
+
*/
|
|
150
|
+
function docTitle(source, fallback) {
|
|
151
|
+
const matched = source.match(/^ {0,3}#\s+(.+)$/m)
|
|
152
|
+
return matched?.[1]?.trim() ?? fallback
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** HTML 转义(页眉/页脚模板与 `<title>` 共用)。 */
|
|
156
|
+
function escapeHtml(value) {
|
|
157
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ── 渲染与打印 ─────────────────────────────────────────────── */
|
|
161
|
+
|
|
162
|
+
/** 组装单文件 HTML(样式内联,产物可离线分发)。 */
|
|
163
|
+
function buildHtml(body, title, css) {
|
|
164
|
+
return `<!doctype html>
|
|
165
|
+
<html lang="zh-CN">
|
|
166
|
+
<head>
|
|
167
|
+
<meta charset="UTF-8">
|
|
168
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
169
|
+
<title>${escapeHtml(title)}</title>
|
|
170
|
+
<style>
|
|
171
|
+
${css}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
${body}
|
|
176
|
+
</body>
|
|
177
|
+
</html>`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 页眉模板:左标题、右标签(标签缺省为空)。 */
|
|
181
|
+
function headerTemplate(title, label) {
|
|
182
|
+
return `
|
|
183
|
+
<div style="width:100%;font-size:8px;color:#8a94a6;
|
|
184
|
+
font-family:'Microsoft YaHei',sans-serif;
|
|
185
|
+
padding:0 14mm;display:flex;justify-content:space-between;">
|
|
186
|
+
<span>${escapeHtml(title)}</span>
|
|
187
|
+
<span>${escapeHtml(label)}</span>
|
|
188
|
+
</div>`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 页脚模板:当前页 / 总页数。 */
|
|
192
|
+
const FOOTER_TEMPLATE = `
|
|
193
|
+
<div style="width:100%;font-size:8px;color:#8a94a6;text-align:center;
|
|
194
|
+
font-family:'Microsoft YaHei',sans-serif;">
|
|
195
|
+
<span class="pageNumber"></span> / <span class="totalPages"></span>
|
|
196
|
+
</div>`
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 用已启动的浏览器把一个 Markdown 渲染成 HTML + PDF 两个文件。
|
|
200
|
+
* @param browser - 复用的 puppeteer 浏览器实例。
|
|
201
|
+
* @param job - 单文件作业(已解析的绝对路径与页眉参数)。
|
|
202
|
+
* @returns 产物信息。
|
|
203
|
+
*/
|
|
204
|
+
async function renderOne(browser, job) {
|
|
205
|
+
const source = readMarkdown(job.input)
|
|
206
|
+
const title = job.headerTitle ?? docTitle(source, basename(job.input, '.md'))
|
|
207
|
+
const html = buildHtml(markdown().render(source), title, job.css)
|
|
208
|
+
|
|
209
|
+
mkdirSync(dirname(job.htmlPath), { recursive: true })
|
|
210
|
+
writeFileSync(job.htmlPath, html, 'utf8')
|
|
211
|
+
|
|
212
|
+
const page = await browser.newPage()
|
|
213
|
+
try {
|
|
214
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
215
|
+
// 不传 path:直接取回字节自行落盘,避免不同 puppeteer 版本对 path 的差异。
|
|
216
|
+
const pdf = await page.pdf({
|
|
217
|
+
format: job.pageFormat,
|
|
218
|
+
printBackground: true,
|
|
219
|
+
displayHeaderFooter: true,
|
|
220
|
+
margin: { top: '20mm', bottom: '18mm', left: '14mm', right: '14mm' },
|
|
221
|
+
headerTemplate: headerTemplate(title, job.headerLabel),
|
|
222
|
+
footerTemplate: FOOTER_TEMPLATE,
|
|
223
|
+
})
|
|
224
|
+
writeFileSync(job.pdfPath, pdf)
|
|
225
|
+
} finally {
|
|
226
|
+
await page.close()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
input: job.input,
|
|
231
|
+
title,
|
|
232
|
+
htmlPath: job.htmlPath,
|
|
233
|
+
pdfPath: job.pdfPath,
|
|
234
|
+
htmlBytes: statSync(job.htmlPath).size,
|
|
235
|
+
pdfBytes: statSync(job.pdfPath).size,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 单文件便捷入口(与原 tools/md2pdf.mjs 的库调用等价,自带浏览器生命周期)。
|
|
241
|
+
* 插件工具内部走批量路径复用同一个浏览器,不经此函数。
|
|
242
|
+
*
|
|
243
|
+
* @param options - `{ input, outDir?, headerTitle?, headerLabel?, cssPath?, chromePath?, pageFormat? }`
|
|
244
|
+
* @returns 产物信息(含 pdfPath/htmlPath)。
|
|
245
|
+
*/
|
|
246
|
+
export async function exportMarkdownToPdf(options) {
|
|
247
|
+
const input = resolve(options.input)
|
|
248
|
+
assertMarkdownFile(input)
|
|
249
|
+
const chrome = findChrome(options.chromePath)
|
|
250
|
+
if (chrome === undefined) throw new Error(chromeMissingMessage())
|
|
251
|
+
|
|
252
|
+
const outDir = resolve(options.outDir ?? dirname(input))
|
|
253
|
+
const stem = basename(input, '.md')
|
|
254
|
+
const css = readFileSync(options.cssPath ?? DEFAULT_CSS, 'utf8')
|
|
255
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: true })
|
|
256
|
+
try {
|
|
257
|
+
return await renderOne(browser, {
|
|
258
|
+
input,
|
|
259
|
+
htmlPath: resolve(outDir, `${stem}.html`),
|
|
260
|
+
pdfPath: resolve(outDir, `${stem}.pdf`),
|
|
261
|
+
headerTitle: options.headerTitle,
|
|
262
|
+
headerLabel: options.headerLabel ?? '',
|
|
263
|
+
css,
|
|
264
|
+
pageFormat: options.pageFormat ?? 'A4',
|
|
265
|
+
})
|
|
266
|
+
} finally {
|
|
267
|
+
await browser.close()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 校验输入是一个存在的 .md 文件。 */
|
|
272
|
+
function assertMarkdownFile(input) {
|
|
273
|
+
if (!existsSync(input)) throw new Error(`输入文件不存在:${input}`)
|
|
274
|
+
if (!statSync(input).isFile()) throw new Error(`输入不是文件:${input}`)
|
|
275
|
+
if (extname(input).toLowerCase() !== '.md') throw new Error(`只支持 .md 文件,收到:${input}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 把模型给的每个输入展开成 .md 文件清单:文件直接用,目录取其中一层 .md(按名排序)。
|
|
280
|
+
* @param inputs - 模型提供的输入项。
|
|
281
|
+
* @param cwd - 相对路径的解析基准(会话工作目录)。
|
|
282
|
+
* @returns 去重后的绝对路径清单。
|
|
283
|
+
*/
|
|
284
|
+
function expandInputs(inputs, cwd) {
|
|
285
|
+
const files = []
|
|
286
|
+
for (const raw of inputs) {
|
|
287
|
+
const path = resolve(cwd, raw)
|
|
288
|
+
if (!existsSync(path)) throw new Error(`输入不存在:${raw} → ${path}`)
|
|
289
|
+
if (statSync(path).isDirectory()) {
|
|
290
|
+
const found = readdirSync(path)
|
|
291
|
+
.filter((name) => extname(name).toLowerCase() === '.md')
|
|
292
|
+
.sort()
|
|
293
|
+
.map((name) => resolve(path, name))
|
|
294
|
+
if (found.length === 0) throw new Error(`目录下没有 .md 文件:${path}`)
|
|
295
|
+
files.push(...found)
|
|
296
|
+
} else {
|
|
297
|
+
assertMarkdownFile(path)
|
|
298
|
+
files.push(path)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return [...new Set(files)]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ── 插件配置 ───────────────────────────────────────────────── */
|
|
305
|
+
|
|
306
|
+
/** 插件配置(全部可省略;省略即用内置默认值)。 */
|
|
307
|
+
export const Config = z.object({
|
|
308
|
+
chromePath: z.string().description('浏览器可执行文件路径(缺省自动探测 Chrome/Edge)'),
|
|
309
|
+
cssPath: z.string().description('导出样式 CSS 路径(缺省用插件内置 guide-style.css)'),
|
|
310
|
+
headerLabel: z.string().default('').description('页眉右侧固定标签(缺省空)'),
|
|
311
|
+
pageFormat: z.string().default('A4').description('纸张规格:A4 / Letter / A3 …'),
|
|
312
|
+
downloadBaseUrl: z.string().description('下载链接的基址(缺省读 DSH_WEB_URL,再缺省 http://127.0.0.1:3080)'),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/** 插件稳定标识(Loader 诊断用)。 */
|
|
316
|
+
export const name = 'dsh-md2pdf'
|
|
317
|
+
|
|
318
|
+
/** 注册模型工具需要 tools 服务。 */
|
|
319
|
+
export const inject = ['tools']
|
|
320
|
+
|
|
321
|
+
/* ── 模型工具与 HTTP 端点 ───────────────────────────────────── */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 注册 `md2pdf_convert` 工具与浏览器下载端点。
|
|
325
|
+
* @param ctx - 插件上下文(需含 tools;webServer 可选)。
|
|
326
|
+
* @param config - 经 Config 校验的插件配置。
|
|
327
|
+
*/
|
|
328
|
+
export function apply(ctx, config = {}) {
|
|
329
|
+
const settings = {
|
|
330
|
+
chromePath: config.chromePath,
|
|
331
|
+
cssPath: config.cssPath,
|
|
332
|
+
headerLabel: config.headerLabel ?? '',
|
|
333
|
+
pageFormat: config.pageFormat ?? 'A4',
|
|
334
|
+
downloadBaseUrl: (config.downloadBaseUrl ?? process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080').replace(/\/+$/, ''),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 本次进程内由本插件产出过的文件(绝对路径)白名单。
|
|
339
|
+
* 下载端点只服务这个集合 —— 端点无法读取任意文件。
|
|
340
|
+
*/
|
|
341
|
+
const generated = new Set()
|
|
342
|
+
|
|
343
|
+
ctx.tools.register({
|
|
344
|
+
name: 'md2pdf_convert',
|
|
345
|
+
description: '把 Markdown(.md) 转成 PDF(同时产出同名单文件 HTML)。'
|
|
346
|
+
+ 'PDF 为 A4、含页眉与页码;页眉左侧取文档第一个一级标题(没有则用文件名),'
|
|
347
|
+
+ '故页眉无需人工指定。输入可以是单个 .md 文件,也可以是目录(取其中一层全部 .md)。'
|
|
348
|
+
+ '默认写到与源 .md 同目录,可用 out_dir 覆盖。相对路径按会话工作目录解析。'
|
|
349
|
+
+ '返回 PDF/HTML 绝对路径,以及可在浏览器直接预览/下载的链接。',
|
|
350
|
+
parameters: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
properties: {
|
|
353
|
+
inputs: {
|
|
354
|
+
type: 'array',
|
|
355
|
+
items: { type: 'string' },
|
|
356
|
+
description: '一个或多个 .md 文件路径,或包含 .md 的目录路径',
|
|
357
|
+
},
|
|
358
|
+
out_dir: {
|
|
359
|
+
type: 'string',
|
|
360
|
+
description: '输出目录(缺省:与源 .md 同目录)',
|
|
361
|
+
},
|
|
362
|
+
title: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
description: '强制指定页眉标题(缺省自动取文档第一个一级标题)',
|
|
365
|
+
},
|
|
366
|
+
label: {
|
|
367
|
+
type: 'string',
|
|
368
|
+
description: '页眉右侧标签(缺省用插件配置 headerLabel,通常为空)',
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
required: ['inputs'],
|
|
372
|
+
additionalProperties: false,
|
|
373
|
+
},
|
|
374
|
+
output: {
|
|
375
|
+
// 空 schema = 无约束 JSON(dsh-tools 支持的注解型 schema),
|
|
376
|
+
// 由 render 负责把结果讲给模型听。
|
|
377
|
+
schema: {},
|
|
378
|
+
render: (_args, value) => {
|
|
379
|
+
const lines = value.results.map((item) => item.ok
|
|
380
|
+
? `✅ ${item.input}\n PDF :${item.pdfPath}(${Math.round(item.pdfBytes / 1024)} KB)\n`
|
|
381
|
+
+ ` HTML:${item.htmlPath}\n 预览/下载:${item.downloadUrl}`
|
|
382
|
+
: `❌ ${item.input}\n ${item.error}`)
|
|
383
|
+
if (value.failedCount > 0 && value.count > 0) {
|
|
384
|
+
lines.push(`\n共 ${value.results.length} 项:成功 ${value.count},失败 ${value.failedCount}。`)
|
|
385
|
+
}
|
|
386
|
+
return [{ type: 'text', text: lines.join('\n\n') }]
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
timeoutMs: 180_000,
|
|
390
|
+
async execute(args, exec) {
|
|
391
|
+
// 相对路径按会话工作目录解析:宿主进程的 cwd 未必是用户的工作区。
|
|
392
|
+
const cwd = exec?.agent?.session?.header?.cwd ?? process.cwd()
|
|
393
|
+
const inputs = Array.isArray(args?.inputs) ? args.inputs : []
|
|
394
|
+
if (inputs.length === 0) throw new Error('inputs 不能为空:请给出至少一个 .md 文件或目录')
|
|
395
|
+
|
|
396
|
+
const files = expandInputs(inputs, cwd)
|
|
397
|
+
const chrome = findChrome(settings.chromePath)
|
|
398
|
+
if (chrome === undefined) throw new Error(chromeMissingMessage())
|
|
399
|
+
|
|
400
|
+
const css = readFileSync(settings.cssPath ?? DEFAULT_CSS, 'utf8')
|
|
401
|
+
const outDir = args.out_dir === undefined ? undefined : resolve(cwd, args.out_dir)
|
|
402
|
+
const headerLabel = args.label ?? settings.headerLabel
|
|
403
|
+
|
|
404
|
+
const results = []
|
|
405
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: true })
|
|
406
|
+
try {
|
|
407
|
+
for (const input of files) {
|
|
408
|
+
try {
|
|
409
|
+
const targetDir = outDir ?? dirname(input)
|
|
410
|
+
const stem = basename(input, '.md')
|
|
411
|
+
const produced = await renderOne(browser, {
|
|
412
|
+
input,
|
|
413
|
+
htmlPath: resolve(targetDir, `${stem}.html`),
|
|
414
|
+
pdfPath: resolve(targetDir, `${stem}.pdf`),
|
|
415
|
+
headerTitle: args.title,
|
|
416
|
+
headerLabel,
|
|
417
|
+
css,
|
|
418
|
+
pageFormat: settings.pageFormat,
|
|
419
|
+
})
|
|
420
|
+
generated.add(produced.pdfPath)
|
|
421
|
+
generated.add(produced.htmlPath)
|
|
422
|
+
results.push({
|
|
423
|
+
...produced,
|
|
424
|
+
ok: true,
|
|
425
|
+
downloadUrl: downloadUrl(settings.downloadBaseUrl, produced.pdfPath, 'inline'),
|
|
426
|
+
htmlUrl: downloadUrl(settings.downloadBaseUrl, produced.htmlPath, 'inline'),
|
|
427
|
+
})
|
|
428
|
+
} catch (error) {
|
|
429
|
+
results.push({ input, ok: false, error: String(error?.message ?? error) })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
await browser.close()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const succeeded = results.filter((item) => item.ok)
|
|
437
|
+
if (succeeded.length === 0) {
|
|
438
|
+
throw new Error(results.map((item) => `${item.input}:${item.error}`).join(';'))
|
|
439
|
+
}
|
|
440
|
+
return { results, count: succeeded.length, failedCount: results.length - succeeded.length }
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// 浏览器预览/下载端点(可选:仅有 webServer 时注册)。
|
|
445
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
446
|
+
hostCtx.effect(() => hostCtx.webServer.register({
|
|
447
|
+
kind: 'exact',
|
|
448
|
+
path: DOWNLOAD_PATH,
|
|
449
|
+
handler: (request, response) => serveGenerated(request, response, generated),
|
|
450
|
+
}), 'dsh-md2pdf: download')
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 拼一个下载链接(路径整体转义,兼容中文文件名)。 */
|
|
455
|
+
function downloadUrl(base, path, disposition) {
|
|
456
|
+
return `${base}${DOWNLOAD_PATH}?path=${encodeURIComponent(path)}&disposition=${disposition}`
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 扩展名 → Content-Type。 */
|
|
460
|
+
const CONTENT_TYPES = {
|
|
461
|
+
'.pdf': 'application/pdf',
|
|
462
|
+
'.html': 'text/html; charset=utf-8',
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 下载端点处理器:只服务 `generated` 白名单内的文件,且仅限 loopback 直连。
|
|
467
|
+
* 信任边界与 dsh-email 的 SSE 端点同款:loopback 对端、无代理转发头、
|
|
468
|
+
* 无 Origin 放行、有 Origin 必须与 Host 同源。
|
|
469
|
+
* @param request - 入站请求。
|
|
470
|
+
* @param response - 响应(本函数独占其生命周期)。
|
|
471
|
+
* @param generated - 本进程产出过的文件绝对路径集合。
|
|
472
|
+
*/
|
|
473
|
+
function serveGenerated(request, response, generated) {
|
|
474
|
+
const loopback = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress ?? '')
|
|
475
|
+
const noProxy = request.headers.forwarded === undefined
|
|
476
|
+
&& request.headers['x-forwarded-for'] === undefined
|
|
477
|
+
&& request.headers['x-real-ip'] === undefined
|
|
478
|
+
let sameOrigin = true
|
|
479
|
+
if (typeof request.headers.origin === 'string' && typeof request.headers.host === 'string') {
|
|
480
|
+
try {
|
|
481
|
+
sameOrigin = new URL(request.headers.origin).host === request.headers.host
|
|
482
|
+
} catch {
|
|
483
|
+
sameOrigin = false
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!loopback || !noProxy || !sameOrigin) {
|
|
487
|
+
response.writeHead(403)
|
|
488
|
+
response.end('forbidden')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
493
|
+
const target = url.searchParams.get('path')
|
|
494
|
+
if (target === null || target === '') {
|
|
495
|
+
response.writeHead(400)
|
|
496
|
+
response.end('missing path')
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 白名单是这个端点唯一且充分的访问控制:它只能读本插件刚写出的文件。
|
|
501
|
+
const absolute = resolve(target)
|
|
502
|
+
if (!generated.has(absolute) || !existsSync(absolute)) {
|
|
503
|
+
response.writeHead(404)
|
|
504
|
+
response.end('not found (only files generated by this plugin during this session are served)')
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const disposition = url.searchParams.get('disposition') === 'attachment' ? 'attachment' : 'inline'
|
|
509
|
+
const filename = basename(absolute)
|
|
510
|
+
const contentType = CONTENT_TYPES[extname(absolute).toLowerCase()] ?? 'application/octet-stream'
|
|
511
|
+
response.writeHead(200, {
|
|
512
|
+
'content-type': contentType,
|
|
513
|
+
'content-length': String(statSync(absolute).size),
|
|
514
|
+
// RFC 5987:中文文件名必须走 filename*,否则头部非法。
|
|
515
|
+
'content-disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
516
|
+
'cache-control': 'no-store',
|
|
517
|
+
})
|
|
518
|
+
createReadStream(absolute).pipe(response)
|
|
519
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@puwenhui/dsh-md2pdf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH 插件:Markdown → 单文件 HTML + A4 PDF(同一条渲染管线,页眉按文档首个一级标题自动生成)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/plugin.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/plugin.mjs",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"dsh": {
|
|
18
|
+
"bundle": {
|
|
19
|
+
"patch": "./cordis.patch.yml"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.19.0"
|
|
24
|
+
},
|
|
25
|
+
"author": "puwenhui",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"markdown",
|
|
30
|
+
"pdf",
|
|
31
|
+
"md2pdf",
|
|
32
|
+
"plugin"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
39
|
+
"highlight.js": "^11.12.0",
|
|
40
|
+
"markdown-it": "^15.0.2",
|
|
41
|
+
"puppeteer-core": "^25.11.0"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|