dsh-pdf-jump 0.1.3 → 0.1.5
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.en.md +13 -18
- package/README.md +18 -20
- package/index.js +21 -10
- package/lib/pdf.mjs +89 -105
- package/lib/render-child.mjs +108 -0
- package/lib/resolve.mjs +59 -4
- package/lib/server.mjs +66 -7
- package/native.js +58 -10
- package/package.json +4 -3
package/README.en.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Read local PDFs **inside the DSH sidebar**. One install ships two independent tools:
|
|
6
6
|
|
|
7
7
|
- **`pdf-view` (default)** — renders pages locally for **jumping to a page number** and **whole-document browsing**. The whole-document view fills the list with thumbnails first, swaps each page to full size when it scrolls into view, and has a page box at the top. Works anywhere, even without the native preview.
|
|
8
|
-
- **`pdf-open`** — opens the PDF with **DSH's native PDF preview** (client-side rendering, no image generation, no local server, selectable text). Used when you explicitly ask for the native preview, the fastest path, or no images.
|
|
8
|
+
- **`pdf-open`** — opens the PDF with **DSH's native PDF preview** (client-side rendering, no image generation, no local server, selectable text). Used when you explicitly ask for the native preview, the fastest path, or no images. It calls `sidebar_open` itself; when the current session has no `sidebar_open` (the third-party sidebar plugin that provides it is not installed, or its "model opens the sidebar" switch is off), it falls back to the `pdf-view` renderer and returns a clickable link (`opened=false`).
|
|
9
9
|
|
|
10
10
|
Page numbers follow **PDF page order** (1 = first page of the file). The plugin deliberately does not infer the number printed in a footer: manuals often mix whole-book numbering with `chapter-page` numbering.
|
|
11
11
|
|
|
@@ -35,7 +35,7 @@ After the restart a `/pdf` command is registered as well: `/pdf manual 300` retu
|
|
|
35
35
|
|
|
36
36
|
Requirements: DSH with the host `tools` and `systemPrompt` services (both are part of the normal base composition), Node 22.19+ or 24+.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Requires DSH >= 0.2.1-alpha.3; verified on 0.2.1-alpha.3 and 0.2.2-rc.1, later versions untested.
|
|
39
39
|
|
|
40
40
|
## Use
|
|
41
41
|
|
|
@@ -47,13 +47,13 @@ Compatible with the DSH 0.2.1 line (alpha / rc / stable); 0.3 and later are not
|
|
|
47
47
|
| Skip model judgement and open directly | `/pdf <file or fragment> [page]` |
|
|
48
48
|
| Extract text / metadata | install the `dsh-pdf` plugin and use its `pdf_info` / `pdf_extract_text` |
|
|
49
49
|
|
|
50
|
-
Both tools accept an absolute/relative path or a file-name fragment
|
|
50
|
+
Both tools accept an absolute/relative path or a file-name fragment; relative paths resolve against the session working directory. Results are one clickable document line: `pdf-open` calls `sidebar_open` for the native preview (or falls back to the viewer link), `pdf-view` points at the local viewer page.
|
|
51
51
|
|
|
52
52
|
## How it works
|
|
53
53
|
|
|
54
|
-
- `pdf-open`
|
|
54
|
+
- `pdf-open` resolves the path and calls `sidebar_open` (when the session has it) for the native preview; without `sidebar_open` it falls back to a `pdf-view` viewer link.
|
|
55
55
|
- `pdf-view` runs a local HTTP server bound to `127.0.0.1`; it tries ports 8731–8750, reuses a server already started by another DSH process, and starts as soon as the plugin loads. It stays up for the whole DSH session (`idleMinutes: 0`), so links from earlier conversations keep working.
|
|
56
|
-
- `pdf-view` renders pages with `pdfjs-dist` and `@napi-rs/canvas` into PNGs cached in the OS temp directory. A call warms the requested page ±2; opening a page warms ±`prefetchWindow` around it, outward one step at a time. Cached pages are served directly without entering the render queue.
|
|
56
|
+
- `pdf-view` renders pages with `pdfjs-dist` and `@napi-rs/canvas` into PNGs cached in the OS temp directory. A call warms the requested page ±2; opening a page warms ±`prefetchWindow` around it, outward one step at a time. Cached pages are served directly without entering the render queue. Page images and thumbnails carry a 1-hour browser cache, so flipping back and forth does not re-download them.
|
|
57
57
|
- Whole-document view fills the list with 0.8× JPEG thumbnails first (a dozen milliseconds per page) and swaps each page to the full-size PNG as it scrolls into view. Every page carries an anchor (`#p40`); the page box at the top scrolls to the requested page.
|
|
58
58
|
- The `/pdf` command always goes through `pdf-view` (single-page mode), starting at page 1 when no page is given; call `pdf-open` directly (or ask for the native preview) when you want the native viewer.
|
|
59
59
|
- Viewer URLs are short paths (`/v/<id>/<page>`, `/d/<id>`, `/p/<id>/<page>.png`, `/t/<id>/<page>.jpg`) with no query string; the id maps to the file through a small registry in the temp directory. Combined with the eager start on the same port, a link from an earlier conversation opens again after DSH restarts.
|
|
@@ -63,18 +63,6 @@ Both tools accept an absolute/relative path or a file-name fragment (for example
|
|
|
63
63
|
|
|
64
64
|
Optional, in the profile patch row (affects `pdf-view` only):
|
|
65
65
|
|
|
66
|
-
```yaml
|
|
67
|
-
- id: dsh-pdf-jump
|
|
68
|
-
name: dsh-pdf-jump
|
|
69
|
-
config:
|
|
70
|
-
port: 8731
|
|
71
|
-
scale: 2
|
|
72
|
-
idleMinutes: 0
|
|
73
|
-
prefetch: true
|
|
74
|
-
prefetchWindow: 6
|
|
75
|
-
roots:
|
|
76
|
-
- D:/manuals
|
|
77
|
-
```
|
|
78
66
|
|
|
79
67
|
| Field | Default | Meaning |
|
|
80
68
|
| --- | --- | --- |
|
|
@@ -89,7 +77,7 @@ Optional, in the profile patch row (affects `pdf-view` only):
|
|
|
89
77
|
|
|
90
78
|
## Limitations
|
|
91
79
|
|
|
92
|
-
- `pdf-open`
|
|
80
|
+
- `pdf-open`'s native preview needs a sidebar plugin that registers the `sidebar_open` tool; that feature is off by default and must be enabled in its settings ("model opens the sidebar"). Without it, `pdf-open` falls back to the `pdf-view` renderer; no third-party plugin is needed just to read PDFs.
|
|
93
81
|
- `pdf-view` pages are images: no text selection and no full-text search. Install the `dsh-pdf` plugin and use its `pdf_extract_text` when you need the text.
|
|
94
82
|
- Rendering needs the `@napi-rs/canvas` prebuilt binary, which covers Windows, macOS and Linux on x64/arm64.
|
|
95
83
|
|
|
@@ -98,6 +86,13 @@ Optional, in the profile patch row (affects `pdf-view` only):
|
|
|
98
86
|
- **The sidebar shows a blank page or "blocked"** (for `pdf-view` URLs): the in-app Sidebar Browser blocks loopback addresses unless they are allowlisted. Add `127.0.0.1:8731` (and whichever port in 8732–8750 is in use), or set *Settings → General → Open chat links in* to the default browser.
|
|
99
87
|
- **A link from an older conversation says "connection refused"**: either the viewer port changed (another program holds 8731) or the PDF file moved. Call the tool again for a fresh link; `pdf-open` paths are unaffected.
|
|
100
88
|
|
|
89
|
+
## Feedback
|
|
90
|
+
|
|
91
|
+
- Open an issue: https://github.com/taoziliyouchong/dsh-pdf-jump/issues — searchable, and others can help.
|
|
92
|
+
- Or reach me on QQ: **2171960697** (mention which plugin you installed when adding the contact).
|
|
93
|
+
|
|
94
|
+
Please include: DSH version, plugin version, the exact error text, and a screenshot.
|
|
95
|
+
|
|
101
96
|
## License
|
|
102
97
|
|
|
103
98
|
MIT
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
在 DSH 侧栏里看本地 PDF。一个包装两个独立工具,装一次两个都可用:
|
|
6
6
|
|
|
7
7
|
- **`pdf-view`(默认入口)**:本地把页面渲染成图——**按页码跳**、**整本浏览**;整本页先出缩略图、滚到哪页换高清,页顶还能输入页码直接跳。不依赖原生预览,任何 profile 都能用。
|
|
8
|
-
- **`pdf-open`**:用 DSH **原生的 PDF
|
|
8
|
+
- **`pdf-open`**:用 DSH **原生的 PDF 预览**打开(客户端直接渲染,不生成图片、不起本地服务、有文字层);只在明确要「原生预览、不生成图片、最快」时用。它会自己调 `sidebar_open`;本会话没有 `sidebar_open`(提供它的第三方侧栏插件未装,或它的「模型打开侧栏」开关没开)时,自动回落到 `pdf-view` 的渲染查看器,返回可点的链接(`opened=false`)。
|
|
9
9
|
|
|
10
10
|
页码按 **PDF 页序**(1 = 文件第一页)。不从页脚推断印刷页码:手册常把全书页码和「章-页」编号混着用,猜出来的差值会把正文页整体错位。
|
|
11
11
|
|
|
@@ -35,7 +35,7 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
|
35
35
|
|
|
36
36
|
要求:DSH 带宿主 `tools` 与 `systemPrompt` 服务(普通基础组合都含),Node 22.19+ 或 24+。
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
要求 DSH >= 0.2.1-alpha.3;实测 0.2.1-alpha.3、0.2.2-rc.1,更高版本未验证。
|
|
39
39
|
|
|
40
40
|
## 用法
|
|
41
41
|
|
|
@@ -47,13 +47,14 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
|
47
47
|
| 不让模型判断、直接开 | `/pdf <文件或片段> [页码]` |
|
|
48
48
|
| 提取文字 / 读元数据 | 另装 `dsh-pdf` 插件,用它的 `pdf_info` / `pdf_extract_text` |
|
|
49
49
|
|
|
50
|
-
两个工具都接受绝对/相对路径或文件名片段(如 `NB系列 安装手册`);相对路径按会话工作目录解析。结果都会显示成一条可点的文档行,`pdf-open`
|
|
50
|
+
两个工具都接受绝对/相对路径或文件名片段(如 `NB系列 安装手册`);相对路径按会话工作目录解析。结果都会显示成一条可点的文档行,`pdf-open` 自己调 `sidebar_open` 用原生预览打开(没有它时回落到查看器链接),`pdf-view` 指向本地查看页。
|
|
51
51
|
|
|
52
52
|
## 机制
|
|
53
53
|
|
|
54
|
-
- `pdf-open`
|
|
54
|
+
- `pdf-open` 解析路径后直接调 `sidebar_open`(若本会话有)用原生预览打开;没有 `sidebar_open` 时自动回落到 `pdf-view` 的查看器链接。
|
|
55
55
|
- `pdf-view` 的本地 HTTP 服务只绑 `127.0.0.1`,端口从 8731 往上试,复用别的 DSH 进程已起的服务;插件一加载就启动,整个 DSH 会话期间常驻(`idleMinutes: 0`),旧对话里的链接不用重新生成。
|
|
56
|
-
- `pdf-view` 用 `pdfjs-dist` + `@napi-rs/canvas` 把页面渲成 PNG 缓存在系统临时目录;工具一调用先暖请求页和前后各 2 页,打开后以当前页为中心向两边各暖 6
|
|
56
|
+
- `pdf-view` 用 `pdfjs-dist` + `@napi-rs/canvas` 把页面渲成 PNG 缓存在系统临时目录;工具一调用先暖请求页和前后各 2 页,打开后以当前页为中心向两边各暖 6 页,已缓存的页直接读盘、不进渲染队列。页图与缩略图带 1 小时浏览器缓存,来回翻页不重复下载。
|
|
57
|
+
- 渲染在**子进程**里做(`lib/render-child.mjs`,一页一进程):同一个宿主里若还有别的插件带不同版本的 pdf.js,后写的那份会把 `globalThis.pdfjsWorker` 换成自己的,同进程混合会报「API version … does not match Worker version …」。子进程有自己的 module graph 与全局,互不影响;代价是每页首次渲染多 0.3–0.8 秒。
|
|
57
58
|
- 整本浏览先用 0.8 倍 JPEG 缩略图铺满(每页十几毫秒),滚到哪页再把那页换成全尺寸 PNG;每一页都有锚点(`#p40`),整本页顶部输入页码即滚到该页。
|
|
58
59
|
- `/pdf` 命令固定走 `pdf-view`(单页跳转模式),不带页码时从第 1 页起;要原生预览就直接调 `pdf-open`,或说「原生预览打开」。
|
|
59
60
|
- 查看地址是短路径(`/v/<id>/<页>`、`/d/<id>`、`/p/<id>/<页>.png`、`/t/<id>/<页>.jpg`),不带查询串;id 通过系统临时目录里的小注册表映射到文件。配合「插件加载即启动、端口固定」,DSH 重启后旧对话里的链接也能重新打开。
|
|
@@ -61,20 +62,8 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
|
61
62
|
|
|
62
63
|
## 配置
|
|
63
64
|
|
|
64
|
-
可选,写在 profile 的 patch 行里(只影响 `pdf-view
|
|
65
|
-
|
|
66
|
-
```yaml
|
|
67
|
-
- id: dsh-pdf-jump
|
|
68
|
-
name: dsh-pdf-jump
|
|
69
|
-
config:
|
|
70
|
-
port: 8731
|
|
71
|
-
scale: 2
|
|
72
|
-
idleMinutes: 0
|
|
73
|
-
prefetch: true
|
|
74
|
-
prefetchWindow: 6
|
|
75
|
-
roots:
|
|
76
|
-
- D:/manuals
|
|
77
|
-
```
|
|
65
|
+
可选,写在 profile 的 patch 行里(只影响 `pdf-view`);字段与默认值:
|
|
66
|
+
|
|
78
67
|
|
|
79
68
|
| 字段 | 默认 | 含义 |
|
|
80
69
|
| --- | --- | --- |
|
|
@@ -89,7 +78,7 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
|
89
78
|
|
|
90
79
|
## 限制
|
|
91
80
|
|
|
92
|
-
- `pdf-open`
|
|
81
|
+
- `pdf-open` 的原生预览依赖一个提供 `sidebar_open` 工具的侧栏插件(如 dsh-better-sidebar),且该功能默认关闭、需要在它的设置里打开「模型打开侧栏」。没有它时 `pdf-open` 自动回落到 `pdf-view` 的渲染查看器;只想看 PDF 的话不装任何第三方也能用。
|
|
93
82
|
- `pdf-view` 的页面是图片:不能选文字,也不能全文搜索;要文字另装 `dsh-pdf` 插件,用它的 `pdf_extract_text`。
|
|
94
83
|
- 渲染依赖 `@napi-rs/canvas` 的预编译二进制,覆盖 Windows / macOS / Linux 的 x64 与 arm64。
|
|
95
84
|
|
|
@@ -98,6 +87,15 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
|
98
87
|
- **侧栏空白或提示「已阻止」**:应用内侧栏浏览器默认拦截本机回环地址。把 `127.0.0.1:8731`(及 8732–8750 中实际用到的)加进侧栏设置的「允许访问的本机地址」,或把「设置 → 通用 → 网页链接默认打开方式」改成「默认浏览器」。
|
|
99
88
|
- **旧对话里的链接提示「拒绝连接」**:要么端口被别的程序占了导致换了端口,要么 PDF 文件被移动/删除。重新调用一次工具取新地址;`pdf-open` 返回的文件路径不受影响。
|
|
100
89
|
|
|
90
|
+
## 反馈
|
|
91
|
+
|
|
92
|
+
插件有问题,按这个顺序:
|
|
93
|
+
|
|
94
|
+
1. 开 Issue:https://github.com/taoziliyouchong/dsh-pdf-jump/issues —— 能留痕、能搜历史,别人也可能已经遇过;
|
|
95
|
+
2. 急着问或不想开 Issue,QQ 号:**2171960697**(加好友请注明装的是哪个插件)。
|
|
96
|
+
|
|
97
|
+
反馈时带上:DSH 版本、插件版本、报错原文、截图。少一样多半要再问一轮。
|
|
98
|
+
|
|
101
99
|
## 许可
|
|
102
100
|
|
|
103
101
|
MIT
|
package/index.js
CHANGED
|
@@ -15,6 +15,9 @@ export const name = 'dsh-pdf-jump'
|
|
|
15
15
|
/** 需要的服务:工具注册表 + 系统提示(让模型知道什么时候该用它)+ 命令注册表(/pdf)。 */
|
|
16
16
|
export const inject = ['tools', 'systemPrompt', 'commands']
|
|
17
17
|
|
|
18
|
+
/** 报错尾行:插件出问题时,使用者第一眼看的就是错误文本。 */
|
|
19
|
+
const FEEDBACK_HINT = '遇到问题:加 QQ 号 2171960697,或开 Issue https://github.com/taoziliyouchong/dsh-pdf-jump/issues'
|
|
20
|
+
|
|
18
21
|
/**
|
|
19
22
|
* 每轮都在模型上下文里的使用说明。工具描述只在工具列表里出现一次,
|
|
20
23
|
* 这段公告把「什么时候用 / 什么时候不用」说清楚,保证模型选得对。
|
|
@@ -24,8 +27,8 @@ const GUIDANCE = [
|
|
|
24
27
|
'- 默认用 pdf-view(单页跳转模式):带页码打开,页面带「上一页/下一页/跳页码/整本浏览」。用户只说「打开/看一眼某本 PDF」「打开第 N 页」也一律用它。',
|
|
25
28
|
'- pdf-open 只在明确要求「原生预览、不生成图片、最快」时才用,不作为默认。',
|
|
26
29
|
'- 用户要提取 PDF 文字、读元数据或比较字体时,用 dsh-pdf 插件(需另装)的 pdf_info / pdf_extract_text,不要用这两个工具。',
|
|
27
|
-
'- 不要绕过这两个工具自己用 sidebar_open 打开 PDF
|
|
28
|
-
'- 两个工具都返回本机路径或本地 url
|
|
30
|
+
'- 不要绕过这两个工具自己用 sidebar_open 打开 PDF 文件:那样没有跳页、没有整本浏览。pdf-open 会自己调 sidebar_open 用原生预览打开——本会话没有 sidebar_open(第三方侧栏插件没装、或它的「模型打开侧栏」开关没开)时,它会回落到 pdf-view 的渲染查看器并给一个可点的链接。',
|
|
31
|
+
'- 两个工具都返回本机路径或本地 url:不要把裸路径/裸 url 贴给用户,用「📄 文件名」做链接文字。pdf-view 的 url 要 sidebar_open 打开才落到侧栏;没有 sidebar_open 时直接在回复里给可点的链接。',
|
|
29
32
|
'- 只给了文件名片段、不知道路径时直接传进去:先按会话工作目录找,再按片段搜。',
|
|
30
33
|
'- 用户说「点了链接没反应 / 打不开」时,说明是侧栏浏览器默认拦截本机地址:建议把「网页链接默认打开方式」改成默认浏览器,或把 127.0.0.1:<端口> 加进侧栏设置里的本机地址白名单。',
|
|
31
34
|
'- 旧对话里的 url 在服务空闲退出后会失效,不要复用;每次重新调用取新地址。',
|
|
@@ -60,12 +63,15 @@ const output = {
|
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/** 当前会话的工作目录;取不到时退回进程目录。 */
|
|
63
|
-
function sessionCwd(ctx, exec) {
|
|
66
|
+
async function sessionCwd(ctx, exec) {
|
|
64
67
|
try {
|
|
65
68
|
const workingDirectory = ctx.get?.('workingDirectory')
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
if (workingDirectory && exec?.agent) {
|
|
70
|
+
// ensure() 按 Agent 取会话目录(get() 要的是 Session 对象,工具上下文只有 Agent)。
|
|
71
|
+
return await workingDirectory.ensure(exec.agent, exec?.signal)
|
|
72
|
+
}
|
|
73
|
+
if (workingDirectory?.defaultDirectory) return workingDirectory.defaultDirectory
|
|
74
|
+
} catch { /* 服务不可用或目录已被删除,用进程目录 */ }
|
|
69
75
|
return process.cwd()
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -114,10 +120,13 @@ export function apply(ctx, config = {}) {
|
|
|
114
120
|
output,
|
|
115
121
|
isConcurrencySafe: () => true,
|
|
116
122
|
async execute(args, exec) {
|
|
117
|
-
const cwd = args.cwd ? resolve(String(args.cwd)) : sessionCwd(ctx, exec)
|
|
123
|
+
const cwd = args.cwd ? resolve(String(args.cwd)) : await sessionCwd(ctx, exec)
|
|
118
124
|
const pdf = resolvePdf(args.file, cwd, extraRoots)
|
|
119
125
|
const port = await ensureViewer()
|
|
120
|
-
const
|
|
126
|
+
const requested = Math.max(1, Math.round(Number(args.page) || 1))
|
|
127
|
+
// 超出总页数时钳到最后一页——和查看器里显示的一致,返回文案不报一个看不到的页码。
|
|
128
|
+
const total = await viewer.pageCount(pdf).catch(() => 0)
|
|
129
|
+
const page = total > 0 ? Math.min(total, requested) : requested
|
|
121
130
|
const id = registerFile(pdf)
|
|
122
131
|
// 工具一返回就在后台把这一页和附近几页渲进缓存,用户点开时直接取图。
|
|
123
132
|
if (config.prefetch !== false) viewer.prefetch(pdf, page)
|
|
@@ -153,14 +162,16 @@ export function apply(ctx, config = {}) {
|
|
|
153
162
|
if (!raw) return { kind: 'error', text: '用法:/pdf <PDF 路径或文件名片段> [页码]' }
|
|
154
163
|
const matched = raw.match(/^(.*?)\s+(\d+)$/)
|
|
155
164
|
const target = matched ? matched[1].trim() : raw
|
|
156
|
-
const
|
|
165
|
+
const requested = matched ? Math.max(1, Math.round(Number(matched[2]))) : 1
|
|
157
166
|
const pdf = resolvePdf(target, process.cwd(), extraRoots)
|
|
158
167
|
const port = await ensureViewer()
|
|
168
|
+
const total = await viewer.pageCount(pdf).catch(() => 0)
|
|
169
|
+
const page = total > 0 ? Math.min(total, requested) : requested
|
|
159
170
|
const id = registerFile(pdf)
|
|
160
171
|
if (config.prefetch !== false) viewer.prefetch(pdf, page)
|
|
161
172
|
return { kind: 'success', text: `[📄 ${basename(pdf)} · 第 ${page} 页](<http://127.0.0.1:${port}/v/${id}/${page}>)` }
|
|
162
173
|
} catch (error) {
|
|
163
|
-
return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
|
|
174
|
+
return { kind: 'error', text: (error instanceof Error ? error.message : String(error)) + '\n\n' + FEEDBACK_HINT }
|
|
164
175
|
}
|
|
165
176
|
},
|
|
166
177
|
})
|
package/lib/pdf.mjs
CHANGED
|
@@ -1,77 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PDF
|
|
2
|
+
* PDF 页面渲染:**在子进程里**用 pdfjs-dist 取页、@napi-rs/canvas 出图,宿主进程只负责调度与缓存。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 为什么隔进程:pdfjs 通过 globalThis.pdfjsWorker.WorkerMessageHandler 找 worker。
|
|
5
|
+
* 宿主里只要还有另一个插件带不同版本的 pdfjs(例如 4.10.38),后写的会把全局改成自己的,
|
|
6
|
+
* 本插件 6.x 的 API 就会拿到 4.x 的 worker,渲染直接失败
|
|
7
|
+
* (「API version 6.4.299 does not match Worker version 4.10.38」)。
|
|
8
|
+
* 子进程有自己的 module graph 与全局,版本永远不会被别的插件换掉。
|
|
9
|
+
*
|
|
10
|
+
* 代价:每页首次渲染多一次进程启动(约 0.3–0.8 秒);出图后落磁盘缓存,翻过的页不再付。
|
|
5
11
|
*/
|
|
6
|
-
|
|
7
|
-
import { createHash } from 'node:crypto'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
8
13
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import {
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { join } from 'node:path'
|
|
10
17
|
import { tmpdir } from 'node:os'
|
|
11
|
-
import { dirname, join } from 'node:path'
|
|
12
|
-
|
|
13
|
-
import { createCanvas } from '@napi-rs/canvas'
|
|
14
|
-
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
15
|
-
|
|
16
|
-
const require = createRequire(import.meta.url)
|
|
17
|
-
const PDFJS_ROOT = dirname(require.resolve('pdfjs-dist/package.json')).replaceAll('\\', '/')
|
|
18
|
-
const CMAP_URL = `${PDFJS_ROOT}/cmaps/`
|
|
19
|
-
const STANDARD_FONTS_URL = `${PDFJS_ROOT}/standard_fonts/`
|
|
20
18
|
|
|
19
|
+
const CHILD = fileURLToPath(new URL('./render-child.mjs', import.meta.url))
|
|
21
20
|
const CACHE_DIR = join(tmpdir(), 'dsh-pdf-jump-cache')
|
|
22
|
-
const
|
|
21
|
+
const MAX_CHILDREN = 2
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
/** 子进程并发闸门:缩略图会连着请求,不设上限会一次拉起几十个进程。 */
|
|
24
|
+
let running = 0
|
|
25
|
+
const waiting = []
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
reset({ canvas }, width, height) {
|
|
33
|
-
canvas.width = Math.max(1, Math.floor(width))
|
|
34
|
-
canvas.height = Math.max(1, Math.floor(height))
|
|
27
|
+
function acquire() {
|
|
28
|
+
if (running < MAX_CHILDREN) {
|
|
29
|
+
running += 1
|
|
30
|
+
return Promise.resolve()
|
|
35
31
|
}
|
|
32
|
+
return new Promise((resolve) => waiting.push(resolve))
|
|
33
|
+
}
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
function release() {
|
|
36
|
+
const next = waiting.shift()
|
|
37
|
+
if (next) next()
|
|
38
|
+
else running -= 1
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* @
|
|
42
|
+
* 跑一次渲染子进程。
|
|
43
|
+
* @param {string[]} args 子进程参数
|
|
44
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
44
45
|
*/
|
|
45
|
-
async function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
async function runChild(args) {
|
|
47
|
+
await acquire()
|
|
48
|
+
try {
|
|
49
|
+
return await new Promise((resolve, reject) => {
|
|
50
|
+
// 宿主是 Electron:process.execPath 是 exe,要靠 ELECTRON_RUN_AS_NODE 才按 Node 跑脚本。
|
|
51
|
+
const env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
|
52
|
+
const child = spawn(process.execPath, [CHILD, ...args], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
53
|
+
let stdout = ''
|
|
54
|
+
let stderr = ''
|
|
55
|
+
child.stdout.setEncoding('utf8')
|
|
56
|
+
child.stderr.setEncoding('utf8')
|
|
57
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
58
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
child.kill()
|
|
61
|
+
reject(new Error('渲染超时(子进程没有在 60 秒内返回)'))
|
|
62
|
+
}, 60_000)
|
|
63
|
+
child.once('error', (error) => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
reject(error)
|
|
66
|
+
})
|
|
67
|
+
child.once('close', (code) => {
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
if (code === 0) resolve({ stdout, stderr })
|
|
70
|
+
else reject(new Error((stderr || stdout || `渲染子进程退出码 ${code}`).trim().slice(0, 400)))
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
} finally {
|
|
74
|
+
release()
|
|
73
75
|
}
|
|
74
|
-
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 页数缓存:{ pages, mtimeMs, size },文件变了就重新问。 */
|
|
79
|
+
const infoCache = new Map()
|
|
80
|
+
|
|
81
|
+
function cachePathFor(file, pdfPage, scale, ext = 'png') {
|
|
82
|
+
const stat = statSync(file)
|
|
83
|
+
const key = createHash('md5')
|
|
84
|
+
.update(`${file}|${stat.mtimeMs}|${stat.size}|${pdfPage}|${scale}`)
|
|
85
|
+
.digest('hex')
|
|
86
|
+
return join(CACHE_DIR, `${key}.${ext}`)
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
@@ -81,8 +93,14 @@ async function getDocument(file) {
|
|
|
81
93
|
* @returns {Promise<{pages: number}>}
|
|
82
94
|
*/
|
|
83
95
|
export async function documentInfo(file) {
|
|
84
|
-
const
|
|
85
|
-
|
|
96
|
+
const stat = statSync(file)
|
|
97
|
+
const hit = infoCache.get(file)
|
|
98
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return { pages: hit.pages }
|
|
99
|
+
const { stdout } = await runChild(['--info', '--file', file])
|
|
100
|
+
const pages = Number(JSON.parse(stdout.trim()).pages)
|
|
101
|
+
if (!Number.isFinite(pages) || pages < 1) throw new Error('读取页数失败')
|
|
102
|
+
infoCache.set(file, { pages, mtimeMs: stat.mtimeMs, size: stat.size })
|
|
103
|
+
return { pages }
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
/**
|
|
@@ -103,14 +121,6 @@ function pruneCache() {
|
|
|
103
121
|
} catch { /* 缓存目录还不存在 */ }
|
|
104
122
|
}
|
|
105
123
|
|
|
106
|
-
function cachePathFor(file, pdfPage, scale, ext = 'png') {
|
|
107
|
-
const stat = statSync(file)
|
|
108
|
-
const key = createHash('md5')
|
|
109
|
-
.update(`${file}|${stat.mtimeMs}|${stat.size}|${pdfPage}|${scale}`)
|
|
110
|
-
.digest('hex')
|
|
111
|
-
return join(CACHE_DIR, `${key}.${ext}`)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
124
|
/** 整本浏览用的缩略图倍率:0.8 约等于 476px 宽,列表里放大到 760px 内可辨版式。 */
|
|
115
125
|
export const THUMB_SCALE = 0.8
|
|
116
126
|
|
|
@@ -153,24 +163,12 @@ export function readCachedPage(file, pdfPage, scale = 2) {
|
|
|
153
163
|
* @returns {Promise<Buffer>} PNG 字节
|
|
154
164
|
*/
|
|
155
165
|
export async function renderPage(file, pdfPage, scale = 2) {
|
|
156
|
-
const cachePath = cachePathFor(file, pdfPage, scale)
|
|
157
166
|
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
158
167
|
pruneCache()
|
|
168
|
+
const cachePath = cachePathFor(file, pdfPage, scale)
|
|
159
169
|
if (existsSync(cachePath)) return readFileSync(cachePath)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const page = await doc.getPage(Math.max(1, Math.min(doc.numPages, pdfPage)))
|
|
163
|
-
const viewport = page.getViewport({ scale })
|
|
164
|
-
const factory = new CanvasFactory()
|
|
165
|
-
const { canvas, context } = factory.create(viewport.width, viewport.height)
|
|
166
|
-
try {
|
|
167
|
-
await page.render({ canvasContext: context, viewport, canvasFactory: factory }).promise
|
|
168
|
-
const png = canvas.toBuffer('image/png')
|
|
169
|
-
writeFileSync(cachePath, png)
|
|
170
|
-
return png
|
|
171
|
-
} finally {
|
|
172
|
-
factory.destroy({ canvas, context })
|
|
173
|
-
}
|
|
170
|
+
await runChild(['--file', file, '--page', String(pdfPage), '--scale', String(scale), '--format', 'png', '--out', cachePath])
|
|
171
|
+
return readFileSync(cachePath)
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
/** 缩略图是否已有缓存(整本浏览用,避免重复渲染)。 */
|
|
@@ -202,34 +200,20 @@ export function readCachedThumb(file, pdfPage, scale = THUMB_SCALE) {
|
|
|
202
200
|
* @returns {Promise<Buffer>} JPEG 字节
|
|
203
201
|
*/
|
|
204
202
|
export async function renderThumb(file, pdfPage, scale = THUMB_SCALE, quality = 0.8) {
|
|
205
|
-
const cachePath = cachePathFor(file, pdfPage, scale, 'jpg')
|
|
206
203
|
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
207
204
|
pruneCache()
|
|
205
|
+
const cachePath = cachePathFor(file, pdfPage, scale, 'jpg')
|
|
208
206
|
if (existsSync(cachePath)) return readFileSync(cachePath)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const page = await doc.getPage(Math.max(1, Math.min(doc.numPages, pdfPage)))
|
|
212
|
-
const viewport = page.getViewport({ scale })
|
|
213
|
-
const factory = new CanvasFactory()
|
|
214
|
-
const { canvas, context } = factory.create(viewport.width, viewport.height)
|
|
215
|
-
try {
|
|
216
|
-
// JPEG 没有透明通道,先铺白底,和单页查看器的白纸效果一致。
|
|
217
|
-
context.fillStyle = '#ffffff'
|
|
218
|
-
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
219
|
-
await page.render({ canvasContext: context, viewport, canvasFactory: factory }).promise
|
|
220
|
-
const jpg = canvas.toBuffer('image/jpeg', { quality })
|
|
221
|
-
writeFileSync(cachePath, jpg)
|
|
222
|
-
return jpg
|
|
223
|
-
} finally {
|
|
224
|
-
factory.destroy({ canvas, context })
|
|
225
|
-
}
|
|
207
|
+
await runChild(['--file', file, '--page', String(pdfPage), '--scale', String(scale), '--format', 'jpg', '--quality', String(quality), '--out', cachePath])
|
|
208
|
+
return readFileSync(cachePath)
|
|
226
209
|
}
|
|
227
210
|
|
|
228
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* 释放资源。渲染在子进程里做,宿主侧没有常驻文档;这里保留接口给调用方,
|
|
213
|
+
* 顺便清掉页数缓存。
|
|
214
|
+
*/
|
|
229
215
|
export async function closeDocuments() {
|
|
230
|
-
|
|
231
|
-
docs.clear()
|
|
232
|
-
await Promise.all(all.map((entry) => entry.task.destroy().catch(() => {})))
|
|
216
|
+
infoCache.clear()
|
|
233
217
|
}
|
|
234
218
|
|
|
235
219
|
/** 渲染缓存目录,供文档与诊断使用。 */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 单页渲染子进程:一页一进程,渲染完就退出。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不在宿主进程里直接渲染:pdfjs 用它挂在 globalThis 上的
|
|
6
|
+
* `pdfjsWorker.WorkerMessageHandler` 找 worker;宿主里如果还有别的插件带另一个
|
|
7
|
+
* pdfjs 版本(比如 4.10.38),后写的那份会把全局改成自己的,于是本插件 6.x 的
|
|
8
|
+
* API 拿到 4.x 的 worker,报「API version 6.4.299 does not match Worker version 4.10.38」。
|
|
9
|
+
* 子进程有自己的 module graph 和全局,谁也不影响谁。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node render-child.mjs --info --file <pdf>
|
|
13
|
+
* node render-child.mjs --file <pdf> --page <n> --scale <s> --out <png/jpg> [--format png|jpg] [--quality 0.8]
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { createRequire } from 'node:module'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { createCanvas } from '@napi-rs/canvas'
|
|
20
|
+
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url)
|
|
23
|
+
const PDFJS_ROOT = dirname(require.resolve('pdfjs-dist/package.json')).replaceAll('\\', '/')
|
|
24
|
+
|
|
25
|
+
const argv = process.argv.slice(2)
|
|
26
|
+
const flag = (name) => argv.includes(name)
|
|
27
|
+
const opt = (name, fallback) => {
|
|
28
|
+
const at = argv.indexOf(name)
|
|
29
|
+
return at >= 0 && at + 1 < argv.length ? argv[at + 1] : fallback
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const file = opt('--file')
|
|
33
|
+
if (!file) {
|
|
34
|
+
console.error('render-child: 缺少 --file')
|
|
35
|
+
process.exit(2)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class CanvasFactory {
|
|
39
|
+
create(width, height) {
|
|
40
|
+
const canvas = createCanvas(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height)))
|
|
41
|
+
return { canvas, context: canvas.getContext('2d') }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
reset({ canvas }, width, height) {
|
|
45
|
+
canvas.width = Math.max(1, Math.floor(width))
|
|
46
|
+
canvas.height = Math.max(1, Math.floor(height))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
destroy() {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function open() {
|
|
53
|
+
return pdfjs.getDocument({
|
|
54
|
+
data: new Uint8Array(readFileSync(file)),
|
|
55
|
+
isEvalSupported: false,
|
|
56
|
+
verbosity: 0,
|
|
57
|
+
useSystemFonts: true,
|
|
58
|
+
cMapUrl: `${PDFJS_ROOT}/cmaps/`,
|
|
59
|
+
cMapPacked: true,
|
|
60
|
+
standardFontDataUrl: `${PDFJS_ROOT}/standard_fonts/`,
|
|
61
|
+
canvasFactory: new CanvasFactory(),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const task = open()
|
|
67
|
+
const doc = await task.promise
|
|
68
|
+
if (flag('--info')) {
|
|
69
|
+
process.stdout.write(JSON.stringify({ pages: doc.numPages }) + '\n')
|
|
70
|
+
await task.destroy().catch(() => {})
|
|
71
|
+
process.exit(0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pageNo = Math.max(1, Math.min(doc.numPages, Number(opt('--page', '1')) || 1))
|
|
75
|
+
const scale = Number(opt('--scale', '2')) || 2
|
|
76
|
+
const format = opt('--format', 'png') === 'jpg' ? 'jpg' : 'png'
|
|
77
|
+
const quality = Number(opt('--quality', '0.8')) || 0.8
|
|
78
|
+
const out = opt('--out')
|
|
79
|
+
if (!out) {
|
|
80
|
+
console.error('render-child: 缺少 --out')
|
|
81
|
+
process.exit(2)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const page = await doc.getPage(pageNo)
|
|
85
|
+
const viewport = page.getViewport({ scale })
|
|
86
|
+
const factory = new CanvasFactory()
|
|
87
|
+
const { canvas, context } = factory.create(viewport.width, viewport.height)
|
|
88
|
+
try {
|
|
89
|
+
// JPEG 没有透明通道,先铺白底,和单页查看器的白纸效果一致。
|
|
90
|
+
if (format === 'jpg') {
|
|
91
|
+
context.fillStyle = '#ffffff'
|
|
92
|
+
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
93
|
+
}
|
|
94
|
+
await page.render({ canvasContext: context, viewport, canvasFactory: factory }).promise
|
|
95
|
+
const bytes = format === 'jpg'
|
|
96
|
+
? canvas.toBuffer('image/jpeg', { quality })
|
|
97
|
+
: canvas.toBuffer('image/png')
|
|
98
|
+
writeFileSync(out, bytes)
|
|
99
|
+
process.stdout.write(JSON.stringify({ pages: doc.numPages, bytes: bytes.length }) + '\n')
|
|
100
|
+
} finally {
|
|
101
|
+
factory.destroy({ canvas, context })
|
|
102
|
+
}
|
|
103
|
+
await task.destroy().catch(() => {})
|
|
104
|
+
process.exit(0)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(String(error && error.message || error))
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
package/lib/resolve.mjs
CHANGED
|
@@ -2,17 +2,64 @@
|
|
|
2
2
|
* 把工具参数 file 落成一个真实存在的 PDF 路径。
|
|
3
3
|
*
|
|
4
4
|
* 优先按路径解析(相对路径按调用方给的工作目录);找不到时把 file 当文件名片段,
|
|
5
|
-
*
|
|
5
|
+
* 在搜索根里广度优先找(浅层优先,带数量与时间上限)。搜索根默认是会话工作目录,
|
|
6
|
+
* 再加上用户主目录下常见的桌面 / 文档 / 下载——只给片段时桌面上的 PDF 也能找到。
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
8
10
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
9
|
-
import {
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { delimiter, basename, isAbsolute, join, resolve } from 'node:path'
|
|
10
13
|
|
|
11
14
|
const SKIP_DIRS = new Set([
|
|
12
15
|
'node_modules', '.git', '.pnpm', '_backup', '$RECYCLE.BIN',
|
|
13
16
|
'System Volume Information', '.cache', '__pycache__',
|
|
14
17
|
])
|
|
15
18
|
|
|
19
|
+
const SHELL_FOLDERS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders'
|
|
20
|
+
|
|
21
|
+
/** 读 Windows 已知文件夹的真实路径(桌面可能被重定向到别的盘)。 */
|
|
22
|
+
function readShellFolder(name) {
|
|
23
|
+
try {
|
|
24
|
+
// reg.exe 按系统 OEM 代码页输出(中文 Windows 是 GBK),不能按 UTF-8 解码。
|
|
25
|
+
const raw = execFileSync('reg', ['query', SHELL_FOLDERS_KEY, '/v', name], {
|
|
26
|
+
timeout: 1500, windowsHide: true,
|
|
27
|
+
})
|
|
28
|
+
let text
|
|
29
|
+
try {
|
|
30
|
+
text = new TextDecoder('gbk').decode(raw)
|
|
31
|
+
} catch {
|
|
32
|
+
text = raw.toString('utf8')
|
|
33
|
+
}
|
|
34
|
+
const match = text.match(/REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/m)
|
|
35
|
+
if (!match) return null
|
|
36
|
+
return match[1].replace(/%([^%]+)%/g, (_, key) => process.env[key] ?? '')
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let cachedHomeRoots
|
|
43
|
+
|
|
44
|
+
/** 用户主目录下常见的几处(桌面 / 文档 / 下载,按系统实际路径,存在才收)。 */
|
|
45
|
+
function homeRoots() {
|
|
46
|
+
if (cachedHomeRoots) return cachedHomeRoots
|
|
47
|
+
const home = homedir()
|
|
48
|
+
const onWindows = process.platform === 'win32'
|
|
49
|
+
const roots = [
|
|
50
|
+
(onWindows && readShellFolder('Desktop')) || join(home, 'Desktop'),
|
|
51
|
+
(onWindows && readShellFolder('Personal')) || join(home, 'Documents'),
|
|
52
|
+
(onWindows && readShellFolder('{374DE290-123F-4565-9164-39C4925E467B}')) || join(home, 'Downloads'),
|
|
53
|
+
]
|
|
54
|
+
cachedHomeRoots = roots.filter(Boolean)
|
|
55
|
+
return cachedHomeRoots
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Windows 盘符路径 / UNC 路径都算绝对路径(path.isAbsolute 认前者,不认 UNC 的写法差异)。 */
|
|
59
|
+
function isAbsolutePath(raw) {
|
|
60
|
+
return isAbsolute(raw) || /^[a-zA-Z]:[\\/]/.test(raw) || /^\\\\/.test(raw)
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
/**
|
|
17
64
|
* @param {string} root 搜索根
|
|
18
65
|
* @param {number} limit 最多收多少个 PDF
|
|
@@ -34,7 +81,9 @@ export function collectPdfs(root, limit = 20000, budgetMs = 8000) {
|
|
|
34
81
|
for (const entry of entries) {
|
|
35
82
|
const full = join(queue[i], entry.name)
|
|
36
83
|
if (entry.isDirectory()) {
|
|
37
|
-
|
|
84
|
+
// 只跳过确定没有 PDF 的大目录;隐藏目录(如 .build)不再一律跳过——
|
|
85
|
+
// 构建产物里也可能放 PDF。
|
|
86
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
38
87
|
queue.push(full)
|
|
39
88
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.pdf')) {
|
|
40
89
|
out.push(full)
|
|
@@ -56,18 +105,24 @@ export function resolvePdf(input, cwd, extraRoots = []) {
|
|
|
56
105
|
if (!raw) throw new Error('file 不能为空')
|
|
57
106
|
const direct = resolve(cwd, raw)
|
|
58
107
|
if (existsSync(direct) && statSync(direct).isFile()) return direct
|
|
108
|
+
// 绝对路径不存在就是不存在,不再按片段去扫目录,错误信息也不带搜索根噪音。
|
|
109
|
+
if (isAbsolutePath(raw)) throw new Error(`文件不存在:${raw}`)
|
|
59
110
|
|
|
60
111
|
const needle = basename(raw).toLowerCase()
|
|
61
112
|
const roots = [...extraRoots.filter(Boolean)]
|
|
62
113
|
const envRoots = process.env.PDF_JUMP_ROOTS
|
|
63
114
|
if (envRoots) roots.push(...envRoots.split(delimiter).filter(Boolean))
|
|
64
115
|
roots.push(cwd)
|
|
116
|
+
roots.push(...homeRoots())
|
|
65
117
|
const seen = new Set()
|
|
118
|
+
const base = resolve(cwd)
|
|
66
119
|
for (const root of roots) {
|
|
67
120
|
const abs = resolve(root)
|
|
68
121
|
if (seen.has(abs) || !existsSync(abs)) continue
|
|
69
122
|
seen.add(abs)
|
|
70
|
-
|
|
123
|
+
// 工作目录优先,额度给足;桌面 / 文档 / 下载是兜底,限时短一些。
|
|
124
|
+
const budgetMs = abs === base ? 8000 : 4000
|
|
125
|
+
const hits = collectPdfs(abs, 20000, budgetMs)
|
|
71
126
|
.filter((file) => basename(file).toLowerCase().includes(needle))
|
|
72
127
|
.sort((a, b) => a.length - b.length)
|
|
73
128
|
if (hits.length > 0) return hits[0]
|
package/lib/server.mjs
CHANGED
|
@@ -210,6 +210,63 @@ document.addEventListener('keydown', (e) => {
|
|
|
210
210
|
</script></body></html>`
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 渲染/读取失败时给人看的说明页。白屏和裸报错都等于没说清原因,
|
|
215
|
+
* 所以这里按错误内容给「原因 + 怎么办」,原始错误折叠在下面备查。
|
|
216
|
+
*/
|
|
217
|
+
function buildErrorHtml({ file, printed, message }) {
|
|
218
|
+
const raw = String(message || '未知错误')
|
|
219
|
+
const name = String(file || '').replace(/^.*[\\/]/, '')
|
|
220
|
+
const cause = /does not match the Worker version/i.test(raw)
|
|
221
|
+
? {
|
|
222
|
+
title: '渲染库版本冲突',
|
|
223
|
+
why: '这台 DSH 里还有另一个插件带着不同版本的 pdf.js。两个版本在同一个进程里共用「渲染器」登记位,后加载的那份会把它改写成自己的版本,本插件再渲染时两边版本对不上。',
|
|
224
|
+
todo: '重启 DSH(新版已把渲染挪到独立进程,重启后不会再撞)。重启后仍报这句,就把下面的原始错误一起发出来。',
|
|
225
|
+
}
|
|
226
|
+
: /ENOENT|no such file|cannot find|找不到/i.test(raw)
|
|
227
|
+
? {
|
|
228
|
+
title: '文件不在了或读不了',
|
|
229
|
+
why: '按这个路径找不到 PDF,或者当前用户没有读权限。',
|
|
230
|
+
todo: '确认文件还在原位置;被移动过就重新用 pdf-view 打开一次。',
|
|
231
|
+
}
|
|
232
|
+
: /超时|timeout/i.test(raw)
|
|
233
|
+
? {
|
|
234
|
+
title: '渲染超时',
|
|
235
|
+
why: '这一页在 60 秒内没渲染完,通常是大页面或字体缺失拖慢的。',
|
|
236
|
+
todo: '点「重试」再翻一次;连续失败把下面的原始错误发出来。',
|
|
237
|
+
}
|
|
238
|
+
: {
|
|
239
|
+
title: '这一页没渲染出来',
|
|
240
|
+
why: '渲染进程返回了错误。',
|
|
241
|
+
todo: '点「重试」;还在失败就把下面的原始错误发出来。',
|
|
242
|
+
}
|
|
243
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
244
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
245
|
+
<title>${escapeHtml(name)} · 渲染失败</title>
|
|
246
|
+
<style>
|
|
247
|
+
:root{color-scheme:light dark}
|
|
248
|
+
*{box-sizing:border-box}
|
|
249
|
+
body{margin:0;min-height:100%;background:#2b2b2b;color:#ddd;font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;padding:28px 22px}
|
|
250
|
+
.card{max-width:720px;margin:0 auto;background:#242424;border:1px solid #3a3a3a;border-radius:10px;padding:18px 20px}
|
|
251
|
+
h1{font-size:16px;margin:0 0 10px;color:#ffb4b4}
|
|
252
|
+
.meta{opacity:.65;font-size:12px;margin-bottom:14px}
|
|
253
|
+
h2{font-size:13px;margin:16px 0 4px;color:#8cc4ff;font-weight:600}
|
|
254
|
+
p{margin:0;white-space:pre-wrap}
|
|
255
|
+
details{margin-top:16px;border-top:1px dashed #3a3a3a;padding-top:10px}
|
|
256
|
+
summary{cursor:pointer;opacity:.75;font-size:12px}
|
|
257
|
+
code{display:block;margin-top:8px;padding:8px 10px;border-radius:6px;background:#111;color:#ffc9c9;font:12px/1.5 ui-monospace,Consolas,monospace;white-space:pre-wrap;word-break:break-all}
|
|
258
|
+
button{margin-top:16px;height:32px;padding:0 14px;border:1px solid #4a4a4a;border-radius:6px;background:#333;color:#eee;font:inherit;cursor:pointer}
|
|
259
|
+
button:hover{background:#414141}
|
|
260
|
+
</style></head><body><div class="card">
|
|
261
|
+
<h1>${escapeHtml(cause.title)}</h1>
|
|
262
|
+
<div class="meta">${escapeHtml(name)}${printed ? ` · 第 ${printed} 页` : ''}</div>
|
|
263
|
+
<h2>原因</h2><p>${escapeHtml(cause.why)}</p>
|
|
264
|
+
<h2>怎么办</h2><p>${escapeHtml(cause.todo)}</p>
|
|
265
|
+
<details><summary>原始错误</summary><code>${escapeHtml(raw)}</code></details>
|
|
266
|
+
<button onclick="location.reload()">重试</button>
|
|
267
|
+
</div></body></html>`
|
|
268
|
+
}
|
|
269
|
+
|
|
213
270
|
/**
|
|
214
271
|
* 整本浏览页:先用缩略图铺满(每页十几毫秒),滚到哪页再把那页换成全尺寸。
|
|
215
272
|
* 点某页进单页查看器;全部在侧栏里完成,不打开外部浏览器。
|
|
@@ -455,13 +512,13 @@ export function createViewer(options = {}) {
|
|
|
455
512
|
const page = Math.max(1, Math.min(total, n))
|
|
456
513
|
const cached = readCachedThumb(file, page)
|
|
457
514
|
if (cached) {
|
|
458
|
-
send(res, 200, 'image/jpeg', cached)
|
|
515
|
+
send(res, 200, 'image/jpeg', cached, { 'Cache-Control': 'public, max-age=3600' })
|
|
459
516
|
return
|
|
460
517
|
}
|
|
461
518
|
const jpg = await schedule(() => renderThumb(file, page), 15)
|
|
462
|
-
send(res, 200, 'image/jpeg', jpg)
|
|
519
|
+
send(res, 200, 'image/jpeg', jpg, { 'Cache-Control': 'public, max-age=3600' })
|
|
463
520
|
} catch (err) {
|
|
464
|
-
send(res, 500, 'text/
|
|
521
|
+
send(res, 500, 'text/html; charset=utf-8', buildErrorHtml({ file, printed, message: err && err.message ? err.message : err }))
|
|
465
522
|
}
|
|
466
523
|
return
|
|
467
524
|
}
|
|
@@ -476,7 +533,7 @@ export function createViewer(options = {}) {
|
|
|
476
533
|
const total = await pagesOf(file)
|
|
477
534
|
send(res, 200, 'text/html; charset=utf-8', buildDocumentHtml({ id, file, total }))
|
|
478
535
|
} catch (err) {
|
|
479
|
-
send(res, 500, 'text/
|
|
536
|
+
send(res, 500, 'text/html; charset=utf-8', buildErrorHtml({ file, printed, message: err && err.message ? err.message : err }))
|
|
480
537
|
}
|
|
481
538
|
return
|
|
482
539
|
}
|
|
@@ -488,7 +545,7 @@ export function createViewer(options = {}) {
|
|
|
488
545
|
warmAround(file, page, prefetchWindow)
|
|
489
546
|
send(res, 200, 'text/html; charset=utf-8', buildHtml({ id, file, printed: page, total, pdfPage: page }))
|
|
490
547
|
} catch (err) {
|
|
491
|
-
send(res, 500, 'text/
|
|
548
|
+
send(res, 500, 'text/html; charset=utf-8', buildErrorHtml({ file, printed, message: err && err.message ? err.message : err }))
|
|
492
549
|
}
|
|
493
550
|
return
|
|
494
551
|
}
|
|
@@ -497,11 +554,11 @@ export function createViewer(options = {}) {
|
|
|
497
554
|
warmAround(file, printed, prefetchWindow)
|
|
498
555
|
const cached = readCachedPage(file, printed, scale)
|
|
499
556
|
if (cached) {
|
|
500
|
-
send(res, 200, 'image/png', cached)
|
|
557
|
+
send(res, 200, 'image/png', cached, { 'Cache-Control': 'public, max-age=3600' })
|
|
501
558
|
return
|
|
502
559
|
}
|
|
503
560
|
schedule(() => renderPage(file, printed, scale), 10)
|
|
504
|
-
.then((pngData) => { if (!res.headersSent) send(res, 200, 'image/png', pngData) })
|
|
561
|
+
.then((pngData) => { if (!res.headersSent) send(res, 200, 'image/png', pngData, { 'Cache-Control': 'public, max-age=3600' }) })
|
|
505
562
|
.catch((err) => { if (!res.headersSent) send(res, 500, 'text/plain; charset=utf-8', String(err)) })
|
|
506
563
|
}
|
|
507
564
|
|
|
@@ -564,6 +621,8 @@ export function createViewer(options = {}) {
|
|
|
564
621
|
return {
|
|
565
622
|
start,
|
|
566
623
|
stop,
|
|
624
|
+
/** 页数(与查看器同一份缓存);工具层用它把超出范围的页码钳到最后一页。 */
|
|
625
|
+
pageCount: (file) => pagesOf(file),
|
|
567
626
|
prefetch: (file, page) => warmAround(file, page, Math.min(2, prefetchWindow)),
|
|
568
627
|
get port() { return port },
|
|
569
628
|
get owned() { return owned },
|
package/native.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 类型:工具;解决:只想「打开看一眼」PDF
|
|
3
|
-
*
|
|
2
|
+
* 类型:工具;解决:只想「打开看一眼」PDF 时不应走图片渲染;作用:直接调 sidebar_open
|
|
3
|
+
* 用 DSH 原生 PDF 预览打开(客户端渲染,最快);本会话没有 sidebar_open 时回落到
|
|
4
|
+
* pdf-view 的渲染查看器,给出可点的链接。
|
|
5
|
+
*
|
|
6
|
+
* sidebar_open 是第三方侧栏插件(dsh-better-sidebar)提供的工具,而且默认关闭;
|
|
7
|
+
* 别的会话里未必存在,所以这里不假定它一定在。
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import { basename, resolve } from 'node:path'
|
|
7
11
|
|
|
8
12
|
import { resolvePdf } from './lib/resolve.mjs'
|
|
13
|
+
import { createViewer, registerFile } from './lib/server.mjs'
|
|
9
14
|
|
|
10
15
|
/** Cordis 插件名。 */
|
|
11
16
|
export const name = 'dsh-pdf-jump-native'
|
|
@@ -28,26 +33,65 @@ const output = {
|
|
|
28
33
|
properties: {
|
|
29
34
|
ok: { type: 'boolean' },
|
|
30
35
|
file: { type: 'string' },
|
|
36
|
+
opened: { type: 'boolean' },
|
|
37
|
+
url: { type: 'string' },
|
|
31
38
|
},
|
|
32
|
-
required: ['ok', 'file'],
|
|
39
|
+
required: ['ok', 'file', 'opened'],
|
|
33
40
|
},
|
|
34
41
|
render: (_args, value) => [{
|
|
35
42
|
type: 'text',
|
|
36
|
-
text:
|
|
43
|
+
text: value.opened
|
|
44
|
+
? `[📄 ${basename(value.file)}](<${value.file}>) · 已用原生预览请求在侧栏打开`
|
|
45
|
+
: `本会话没有可用的 sidebar_open(第三方侧栏插件未装,或它的「模型打开侧栏」开关没开),已回落到渲染查看器:[📄 ${basename(value.file)}](<${value.url}>)`,
|
|
37
46
|
}],
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
function sessionCwd(ctx, exec) {
|
|
41
50
|
try {
|
|
42
51
|
const workingDirectory = ctx.get?.('workingDirectory')
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (workingDirectory && exec?.agent?.id) {
|
|
53
|
+
// ensure() 按 Agent 取会话目录;它是异步的,调用方 await。
|
|
54
|
+
return Promise.resolve(workingDirectory.ensure(exec.agent, exec?.signal)).catch(() => process.cwd())
|
|
55
|
+
}
|
|
56
|
+
if (workingDirectory?.defaultDirectory) return workingDirectory.defaultDirectory
|
|
57
|
+
} catch { /* 服务不可用或目录已被删除,用进程目录 */ }
|
|
46
58
|
return process.cwd()
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 调 sidebar_open 把 PDF 在侧栏用原生预览打开。
|
|
63
|
+
* 工具不存在(第三方插件未装 / 开关没开)或调用失败都返回 false,交给调用方回落。
|
|
64
|
+
*/
|
|
65
|
+
async function openInSidebar(ctx, exec, pdf) {
|
|
66
|
+
try {
|
|
67
|
+
if (typeof ctx.tools.get !== 'function' || typeof ctx.tools.execute !== 'function') return false
|
|
68
|
+
if (!ctx.tools.get('sidebar_open', exec?.agent)) return false
|
|
69
|
+
const result = await ctx.tools.execute({
|
|
70
|
+
callId: `pdf-open-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
71
|
+
name: 'sidebar_open',
|
|
72
|
+
arguments: { target: pdf, title: basename(pdf) },
|
|
73
|
+
signal: exec?.signal ?? new AbortController().signal,
|
|
74
|
+
...(exec?.agent ? { agent: exec.agent } : {}),
|
|
75
|
+
...(exec?.token ? { parent: exec.token } : {}),
|
|
76
|
+
})
|
|
77
|
+
return result?.isError !== true
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
export function apply(ctx, config = {}) {
|
|
50
84
|
const extraRoots = Array.isArray(config.roots) ? config.roots.map(String) : []
|
|
85
|
+
// 只在需要回落时启动;index.js 那半边通常已经把同端口的服务拉起来了,start() 会复用它。
|
|
86
|
+
const viewer = createViewer({
|
|
87
|
+
port: config.port,
|
|
88
|
+
scale: config.scale,
|
|
89
|
+
idleMinutes: config.idleMinutes,
|
|
90
|
+
prefetch: config.prefetch,
|
|
91
|
+
prefetchWindow: config.prefetchWindow,
|
|
92
|
+
})
|
|
93
|
+
ctx.effect?.(() => () => { viewer.stop().catch(() => {}) })
|
|
94
|
+
|
|
51
95
|
try {
|
|
52
96
|
if (typeof ctx.tools.get === 'function' && ctx.tools.get('pdf-open')) return
|
|
53
97
|
} catch { /* 老版本没有 get(),交给 register 的重名错误兜底 */ }
|
|
@@ -59,14 +103,18 @@ export function apply(ctx, config = {}) {
|
|
|
59
103
|
'在侧栏用 DSH 原生的 PDF 预览打开本地 PDF——客户端直接渲染,不生成图片、不起本地服务、有文字层;'
|
|
60
104
|
+ '只在明确要原生预览时用;默认入口是 pdf-view(按页码跳页、整本浏览)。'
|
|
61
105
|
+ '打开 PDF 文件先过这个工具(或 pdf-view),不要自己拼 sidebar_open 路径——这里还要解析文件名片段和会话工作目录。'
|
|
62
|
-
+ 'file 给 PDF
|
|
106
|
+
+ 'file 给 PDF 路径或文件名片段(相对路径按会话工作目录解析)。'
|
|
107
|
+
+ '这个工具会自己调 sidebar_open 打开原生预览;本会话没有 sidebar_open 时,回落到 pdf-view 的渲染查看器并返回可点的 url(opened=false)。',
|
|
63
108
|
parameters,
|
|
64
109
|
output,
|
|
65
110
|
isConcurrencySafe: () => true,
|
|
66
111
|
async execute(args, exec) {
|
|
67
|
-
const cwd = args.cwd ? resolve(String(args.cwd)) : sessionCwd(ctx, exec)
|
|
112
|
+
const cwd = args.cwd ? resolve(String(args.cwd)) : await sessionCwd(ctx, exec)
|
|
68
113
|
const pdf = resolvePdf(args.file, cwd, extraRoots)
|
|
69
|
-
return { ok: true, file: pdf }
|
|
114
|
+
if (await openInSidebar(ctx, exec, pdf)) return { ok: true, file: pdf, opened: true }
|
|
115
|
+
const port = await viewer.start()
|
|
116
|
+
const id = registerFile(pdf)
|
|
117
|
+
return { ok: true, file: pdf, opened: false, url: `http://127.0.0.1:${port}/v/${id}/1` }
|
|
70
118
|
},
|
|
71
119
|
})
|
|
72
120
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pdf-jump",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "在 DSH
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "在 DSH 侧栏里看本地 PDF:跳到任意一页,整本连着看。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": "^22.19.0 || >=24.0.0",
|
|
24
|
-
"dsh": ">=0.2.1-0
|
|
24
|
+
"dsh": ">=0.2.1-0"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@napi-rs/canvas": "1.0.13",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"scripts": {
|
|
45
45
|
"smoke": "node scripts/smoke.mjs"
|
|
46
46
|
},
|
|
47
|
+
"author": "taoziliyouchong",
|
|
47
48
|
"license": "MIT",
|
|
48
49
|
"repository": {
|
|
49
50
|
"type": "git",
|