dsh-pdf-jump 0.1.4 → 0.1.6

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 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
- Compatible with the DSH 0.2.1 line (alpha / rc / stable); 0.3 and later are not guaranteed.
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 (for example `NB系列 安装手册`); relative paths resolve against the session working directory. Results are one clickable document line: `pdf-open` points at the file itself (opened by the native preview), `pdf-view` points at the local viewer page.
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` only resolves the path and returns it for `sidebar_open`; all rendering happens in the DSH client.
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` relies on DSH's built-in PDF preview; use the default `pdf-view` where that preview is absent.
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
 
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
- 兼容:DSH 0.2.1 系列(alpha / rc / 正式版均可);0.3 起不保证。
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` 指向文件本身(由侧栏原生预览打开),`pdf-view` 指向本地查看页。
50
+ 两个工具都接受绝对/相对路径或文件名片段(如 `NB系列 安装手册`);相对路径按会话工作目录解析。结果都会显示成一条可点的文档行,`pdf-open` 自己调 `sidebar_open` 用原生预览打开(没有它时回落到查看器链接),`pdf-view` 指向本地查看页。
51
51
 
52
52
  ## 机制
53
53
 
54
- - `pdf-open` 只解析路径并返回文件路径,交给 `sidebar_open`;渲染完全由 DSH 客户端完成。
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` 依赖 DSH 自带的原生 PDF 预览;没有该预览的环境用默认的 `pdf-view`。
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
 
package/index.js CHANGED
@@ -24,11 +24,14 @@ const FEEDBACK_HINT = '遇到问题:加 QQ 号 2171960697,或开 Issue https
24
24
  */
25
25
  const GUIDANCE = [
26
26
  '本机装了 dsh-pdf-jump(在侧栏里看 PDF,两个独立入口):',
27
+ '- 硬规则:凡是跟 PDF 有关的答复——打开、翻页、读手册某章、按页查内容、提取文字、比对字体——第一步调 pdf-view 打开相关页,并把页链接给用户。只贴文字、只渲染图片、只给摘录,都算没做完。',
27
28
  '- 默认用 pdf-view(单页跳转模式):带页码打开,页面带「上一页/下一页/跳页码/整本浏览」。用户只说「打开/看一眼某本 PDF」「打开第 N 页」也一律用它。',
29
+ '- 还不知道页码时:先用 dsh-pdf 的 pdf_info / pdf_extract_text(或 doc-probe-search)定位页码,再立刻用 pdf-view 跳到该页。',
30
+ '- 用户说「放到对应的页」「给我看第 N 页」时:必须给 pdf-view 的页链接,不许用图片或摘录替代。',
28
31
  '- pdf-open 只在明确要求「原生预览、不生成图片、最快」时才用,不作为默认。',
29
- '- 用户要提取 PDF 文字、读元数据或比较字体时,用 dsh-pdf 插件(需另装)的 pdf_info / pdf_extract_text,不要用这两个工具。',
30
- '- 不要绕过这两个工具自己用 sidebar_open 打开 PDF 文件:那样没有跳页、没有整本浏览;pdf-open 返回的路径再交给 sidebar_open 才是正确顺序。',
31
- '- 两个工具都返回本机路径或本地 url:有 sidebar_open 就先调它开在侧栏;不要把裸路径/裸 url 贴给用户,用「📄 文件名」做链接文字。',
32
+ '- dsh-pdf 的 pdf_info / pdf_extract_text 只用于取页码、取文字、读元数据、比字体;取完仍要给 pdf-view 页链接。',
33
+ '- 不要绕过这两个工具自己用 sidebar_open 打开 PDF 文件:那样没有跳页、没有整本浏览。pdf-open 会自己调 sidebar_open 用原生预览打开——本会话没有 sidebar_open(第三方侧栏插件没装、或它的「模型打开侧栏」开关没开)时,它会回落到 pdf-view 的渲染查看器并给一个可点的链接。',
34
+ '- 两个工具都返回本机路径或本地 url:不要把裸路径/裸 url 贴给用户,用「📄 文件名」做链接文字。pdf-view 的 url 要 sidebar_open 打开才落到侧栏;没有 sidebar_open 时直接在回复里给可点的链接。',
32
35
  '- 只给了文件名片段、不知道路径时直接传进去:先按会话工作目录找,再按片段搜。',
33
36
  '- 用户说「点了链接没反应 / 打不开」时,说明是侧栏浏览器默认拦截本机地址:建议把「网页链接默认打开方式」改成默认浏览器,或把 127.0.0.1:<端口> 加进侧栏设置里的本机地址白名单。',
34
37
  '- 旧对话里的 url 在服务空闲退出后会失效,不要复用;每次重新调用取新地址。',
@@ -63,12 +66,15 @@ const output = {
63
66
  }
64
67
 
65
68
  /** 当前会话的工作目录;取不到时退回进程目录。 */
66
- function sessionCwd(ctx, exec) {
69
+ async function sessionCwd(ctx, exec) {
67
70
  try {
68
71
  const workingDirectory = ctx.get?.('workingDirectory')
69
- const session = exec?.agent?.session
70
- if (workingDirectory && session) return workingDirectory.get(session)
71
- } catch { /* 老版本没有这个服务,用进程目录 */ }
72
+ if (workingDirectory && exec?.agent) {
73
+ // ensure() 按 Agent 取会话目录(get() 要的是 Session 对象,工具上下文只有 Agent)。
74
+ return await workingDirectory.ensure(exec.agent, exec?.signal)
75
+ }
76
+ if (workingDirectory?.defaultDirectory) return workingDirectory.defaultDirectory
77
+ } catch { /* 服务不可用或目录已被删除,用进程目录 */ }
72
78
  return process.cwd()
73
79
  }
74
80
 
@@ -117,10 +123,13 @@ export function apply(ctx, config = {}) {
117
123
  output,
118
124
  isConcurrencySafe: () => true,
119
125
  async execute(args, exec) {
120
- const cwd = args.cwd ? resolve(String(args.cwd)) : sessionCwd(ctx, exec)
126
+ const cwd = args.cwd ? resolve(String(args.cwd)) : await sessionCwd(ctx, exec)
121
127
  const pdf = resolvePdf(args.file, cwd, extraRoots)
122
128
  const port = await ensureViewer()
123
- const page = Math.max(1, Math.round(Number(args.page) || 1))
129
+ const requested = Math.max(1, Math.round(Number(args.page) || 1))
130
+ // 超出总页数时钳到最后一页——和查看器里显示的一致,返回文案不报一个看不到的页码。
131
+ const total = await viewer.pageCount(pdf).catch(() => 0)
132
+ const page = total > 0 ? Math.min(total, requested) : requested
124
133
  const id = registerFile(pdf)
125
134
  // 工具一返回就在后台把这一页和附近几页渲进缓存,用户点开时直接取图。
126
135
  if (config.prefetch !== false) viewer.prefetch(pdf, page)
@@ -156,9 +165,11 @@ export function apply(ctx, config = {}) {
156
165
  if (!raw) return { kind: 'error', text: '用法:/pdf <PDF 路径或文件名片段> [页码]' }
157
166
  const matched = raw.match(/^(.*?)\s+(\d+)$/)
158
167
  const target = matched ? matched[1].trim() : raw
159
- const page = matched ? Math.max(1, Math.round(Number(matched[2]))) : 1
168
+ const requested = matched ? Math.max(1, Math.round(Number(matched[2]))) : 1
160
169
  const pdf = resolvePdf(target, process.cwd(), extraRoots)
161
170
  const port = await ensureViewer()
171
+ const total = await viewer.pageCount(pdf).catch(() => 0)
172
+ const page = total > 0 ? Math.min(total, requested) : requested
162
173
  const id = registerFile(pdf)
163
174
  if (config.prefetch !== false) viewer.prefetch(pdf, page)
164
175
  return { kind: 'success', text: `[📄 ${basename(pdf)} · 第 ${page} 页](<http://127.0.0.1:${port}/v/${id}/${page}>)` }
package/lib/pdf.mjs CHANGED
@@ -1,77 +1,89 @@
1
1
  /**
2
- * PDF 页面渲染:pdfjs-dist 取页 + @napi-rs/canvas 出 PNG。
2
+ * PDF 页面渲染:**在子进程里**用 pdfjs-dist 取页、@napi-rs/canvas 出图,宿主进程只负责调度与缓存。
3
3
  *
4
- * 不依赖 Python;首次渲染一个文件时载入文档并缓存,文档改动(mtime/size)后自动重载。
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 { createRequire } from 'node:module'
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 MAX_DOCS = 2
21
+ const MAX_CHILDREN = 2
23
22
 
24
- const docs = new Map()
23
+ /** 子进程并发闸门:缩略图会连着请求,不设上限会一次拉起几十个进程。 */
24
+ let running = 0
25
+ const waiting = []
25
26
 
26
- class CanvasFactory {
27
- create(width, height) {
28
- const canvas = createCanvas(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height)))
29
- return { canvas, context: canvas.getContext('2d') }
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
- destroy() {}
35
+ function release() {
36
+ const next = waiting.shift()
37
+ if (next) next()
38
+ else running -= 1
38
39
  }
39
40
 
40
41
  /**
41
- * 打开(或复用)一个 PDF 文档。文档句柄连同它的 loading task 一起缓存,
42
- * 释放时走 task.destroy()(pdfjs 6 的 PDFDocumentProxy 不再直接提供 destroy)。
43
- * @param {string} file PDF 绝对路径
42
+ * 跑一次渲染子进程。
43
+ * @param {string[]} args 子进程参数
44
+ * @returns {Promise<{stdout: string, stderr: string}>}
44
45
  */
45
- async function getDocument(file) {
46
- const stat = statSync(file)
47
- const hit = docs.get(file)
48
- if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
49
- hit.usedAt = Date.now()
50
- return hit.doc
51
- }
52
- if (hit) {
53
- docs.delete(file)
54
- await hit.task.destroy().catch(() => {})
55
- }
56
- const task = pdfjs.getDocument({
57
- data: new Uint8Array(readFileSync(file)),
58
- isEvalSupported: false,
59
- verbosity: 0,
60
- useSystemFonts: true,
61
- cMapUrl: CMAP_URL,
62
- cMapPacked: true,
63
- standardFontDataUrl: STANDARD_FONTS_URL,
64
- canvasFactory: new CanvasFactory(),
65
- })
66
- const doc = await task.promise
67
- docs.set(file, { doc, task, mtimeMs: stat.mtimeMs, size: stat.size, usedAt: Date.now() })
68
- if (docs.size > MAX_DOCS) {
69
- const [oldestKey] = [...docs.entries()].sort((a, b) => a[1].usedAt - b[1].usedAt)[0]
70
- const oldest = docs.get(oldestKey)
71
- docs.delete(oldestKey)
72
- await oldest.task.destroy().catch(() => {})
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
- return doc
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 doc = await getDocument(file)
85
- return { pages: doc.numPages }
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
- const doc = await getDocument(file)
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
- const doc = await getDocument(file)
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
- const all = [...docs.values()]
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 { delimiter, basename, join, resolve } from 'node:path'
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
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue
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
- const hits = collectPdfs(abs)
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/plain; charset=utf-8', String(err && err.message ? err.message : err))
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/plain; charset=utf-8', String(err && err.message ? err.message : err))
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/plain; charset=utf-8', String(err && err.message ? err.message : err))
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 时不应走图片渲染;作用:返回本地 PDF 路径,
3
- * 交给 sidebar_open 用 DSH 原生 PDF 预览打开(客户端渲染,最快)。
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: `[📄 ${basename(value.file)}](<${value.file}>)`,
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
- const session = exec?.agent?.session
44
- if (workingDirectory && session) return workingDirectory.get(session)
45
- } catch { /* 老版本没有这个服务,用进程目录 */ }
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 路径或文件名片段(相对路径按会话工作目录解析)。返回文件路径,交给 sidebar_open 打开。',
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pdf-jump",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "在 DSH 侧栏里看本地 PDF:跳到任意一页,整本连着看。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "engines": {
23
23
  "node": "^22.19.0 || >=24.0.0",
24
- "dsh": ">=0.2.1-0 <0.3.0-0"
24
+ "dsh": ">=0.2.1-0"
25
25
  },
26
26
  "dependencies": {
27
27
  "@napi-rs/canvas": "1.0.13",