dsh-pdf-jump 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-document.png +0 -0
- package/assets/screenshot-page.png +0 -0
- package/cordis.patch.yml +8 -0
- package/index.js +171 -0
- package/lib/pdf.mjs +236 -0
- package/lib/resolve.mjs +76 -0
- package/lib/server.mjs +571 -0
- package/native.js +75 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-pdf-jump contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# dsh-pdf-jump
|
|
2
|
+
|
|
3
|
+
[中文](README.md) | **English**
|
|
4
|
+
|
|
5
|
+
Read local PDFs **inside the DSH sidebar**. One install ships two independent tools:
|
|
6
|
+
|
|
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.
|
|
9
|
+
|
|
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
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
Jump by page number: type it in the toolbar; arrow keys flip pages.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
Whole-document view: thumbnails first, full size as each page scrolls into view, with a page box at the top.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add dsh-pdf-jump
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Before the package is on npm, install straight from GitHub:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then restart DSH. Replace `web` with your profile name if you use another one.
|
|
33
|
+
|
|
34
|
+
After the restart a `/pdf` command is registered as well: `/pdf manual 300` returns the link without going through the model.
|
|
35
|
+
|
|
36
|
+
Requirements: DSH with the host `tools` and `systemPrompt` services (both are part of the normal base composition), Node 22.19+ or 24+.
|
|
37
|
+
|
|
38
|
+
Compatible with the DSH 0.2.1 line (alpha / rc / stable); 0.3 and later are not guaranteed.
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
| You want | Tool |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| Open a PDF, jump to page N, browse the whole document | `pdf-view` (default; `page` = PDF page number) |
|
|
45
|
+
| Native preview (no images, selectable text) | `pdf-open` |
|
|
46
|
+
| Jump to a page inside the whole-document view | Page box at the top, or a `#p40` URL |
|
|
47
|
+
| Skip model judgement and open directly | `/pdf <file or fragment> [page]` |
|
|
48
|
+
| Extract text / metadata | install the `dsh-pdf` plugin and use its `pdf_info` / `pdf_extract_text` |
|
|
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.
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
- `pdf-open` only resolves the path and returns it for `sidebar_open`; all rendering happens in the DSH client.
|
|
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.
|
|
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
|
+
- 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
|
+
- 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.
|
|
60
|
+
- Nothing is uploaded.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Optional, in the profile patch row (affects `pdf-view` only):
|
|
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
|
+
|
|
79
|
+
| Field | Default | Meaning |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `port` | `8731` | First port to try; the server scans upward for a free or already-running instance |
|
|
82
|
+
| `scale` | `2` | Render scale; 2 is roughly 144 dpi |
|
|
83
|
+
| `idleMinutes` | `0` | Shut the server down after this many idle minutes; `0` keeps it running for the DSH session |
|
|
84
|
+
| `prefetch` | `true` | Warm pages around the current one in the background |
|
|
85
|
+
| `prefetchWindow` | `6` | How many pages on each side of the current page to warm |
|
|
86
|
+
| `roots` | `[]` | Extra search roots for file-name fragments |
|
|
87
|
+
|
|
88
|
+
`PDF_JUMP_ROOTS` (environment variable) adds search roots as well, separated by the platform path delimiter (`;` on Windows, `:` elsewhere).
|
|
89
|
+
|
|
90
|
+
## Limitations
|
|
91
|
+
|
|
92
|
+
- `pdf-open` relies on DSH's built-in PDF preview; use the default `pdf-view` where that preview is absent.
|
|
93
|
+
- `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
|
+
- Rendering needs the `@napi-rs/canvas` prebuilt binary, which covers Windows, macOS and Linux on x64/arm64.
|
|
95
|
+
|
|
96
|
+
## Troubleshooting
|
|
97
|
+
|
|
98
|
+
- **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
|
+
- **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
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-pdf-jump
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**中文** | [English](README.en.md)
|
|
4
|
+
|
|
5
|
+
在 DSH 侧栏里看本地 PDF。一个包装两个独立工具,装一次两个都可用:
|
|
6
|
+
|
|
7
|
+
- **`pdf-view`(默认入口)**:本地把页面渲染成图——**按页码跳**、**整本浏览**;整本页先出缩略图、滚到哪页换高清,页顶还能输入页码直接跳。不依赖原生预览,任何 profile 都能用。
|
|
8
|
+
- **`pdf-open`**:用 DSH **原生的 PDF 预览**打开(客户端直接渲染,不生成图片、不起本地服务、有文字层);只在明确要「原生预览、不生成图片、最快」时用。
|
|
9
|
+
|
|
10
|
+
页码按 **PDF 页序**(1 = 文件第一页)。不从页脚推断印刷页码:手册常把全书页码和「章-页」编号混着用,猜出来的差值会把正文页整体错位。
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
按页码跳页:工具条里输入页码直接跳到那一页,方向键翻页。
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
整本浏览:缩略图先铺满,滚到哪页换高清;顶部也有页码框,输入即跳。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add dsh-pdf-jump
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
还没发布到 npm 时,直接从 GitHub 装:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
dsh plugin --profile web add github:taoziliyouchong/dsh-pdf-jump
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
然后重启 DSH。用别的 profile 就把 `web` 换成你的 profile 名。
|
|
33
|
+
|
|
34
|
+
装好后除了两个工具,还会注册一个 `/pdf` 命令:`/pdf 繁易手册 300` 直接出链接,不经过模型判断。
|
|
35
|
+
|
|
36
|
+
要求:DSH 带宿主 `tools` 与 `systemPrompt` 服务(普通基础组合都含),Node 22.19+ 或 24+。
|
|
37
|
+
|
|
38
|
+
兼容:DSH 0.2.1 系列(alpha / rc / 正式版均可);0.3 起不保证。
|
|
39
|
+
|
|
40
|
+
## 用法
|
|
41
|
+
|
|
42
|
+
| 你说 | 用哪个工具 |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| 打开某本 PDF、跳到第 N 页、整本翻 | `pdf-view`(默认;page = PDF 页序) |
|
|
45
|
+
| 要原生预览(不生成图片、能选中文字) | `pdf-open` |
|
|
46
|
+
| 在整本里跳到某页 | 整本页顶部输入页码,或地址带 `#p40` |
|
|
47
|
+
| 不让模型判断、直接开 | `/pdf <文件或片段> [页码]` |
|
|
48
|
+
| 提取文字 / 读元数据 | 另装 `dsh-pdf` 插件,用它的 `pdf_info` / `pdf_extract_text` |
|
|
49
|
+
|
|
50
|
+
两个工具都接受绝对/相对路径或文件名片段(如 `NB系列 安装手册`);相对路径按会话工作目录解析。结果都会显示成一条可点的文档行,`pdf-open` 指向文件本身(由侧栏原生预览打开),`pdf-view` 指向本地查看页。
|
|
51
|
+
|
|
52
|
+
## 机制
|
|
53
|
+
|
|
54
|
+
- `pdf-open` 只解析路径并返回文件路径,交给 `sidebar_open`;渲染完全由 DSH 客户端完成。
|
|
55
|
+
- `pdf-view` 的本地 HTTP 服务只绑 `127.0.0.1`,端口从 8731 往上试,复用别的 DSH 进程已起的服务;插件一加载就启动,整个 DSH 会话期间常驻(`idleMinutes: 0`),旧对话里的链接不用重新生成。
|
|
56
|
+
- `pdf-view` 用 `pdfjs-dist` + `@napi-rs/canvas` 把页面渲成 PNG 缓存在系统临时目录;工具一调用先暖请求页和前后各 2 页,打开后以当前页为中心向两边各暖 6 页,已缓存的页直接读盘、不进渲染队列。
|
|
57
|
+
- 整本浏览先用 0.8 倍 JPEG 缩略图铺满(每页十几毫秒),滚到哪页再把那页换成全尺寸 PNG;每一页都有锚点(`#p40`),整本页顶部输入页码即滚到该页。
|
|
58
|
+
- `/pdf` 命令固定走 `pdf-view`(单页跳转模式),不带页码时从第 1 页起;要原生预览就直接调 `pdf-open`,或说「原生预览打开」。
|
|
59
|
+
- 查看地址是短路径(`/v/<id>/<页>`、`/d/<id>`、`/p/<id>/<页>.png`、`/t/<id>/<页>.jpg`),不带查询串;id 通过系统临时目录里的小注册表映射到文件。配合「插件加载即启动、端口固定」,DSH 重启后旧对话里的链接也能重新打开。
|
|
60
|
+
- 不上传任何内容。
|
|
61
|
+
|
|
62
|
+
## 配置
|
|
63
|
+
|
|
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
|
+
```
|
|
78
|
+
|
|
79
|
+
| 字段 | 默认 | 含义 |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `port` | `8731` | 起始端口;被占用或已有同款服务时自动向上找 |
|
|
82
|
+
| `scale` | `2` | 渲染倍率,2 约等于 144 dpi |
|
|
83
|
+
| `idleMinutes` | `0` | 空闲多少分钟后关掉服务;`0` 表示整个 DSH 会话常驻 |
|
|
84
|
+
| `prefetch` | `true` | 是否在后台暖当前页附近的页面 |
|
|
85
|
+
| `prefetchWindow` | `6` | 当前页两侧各暖多少页 |
|
|
86
|
+
| `roots` | `[]` | 文件名片段搜索的额外根目录 |
|
|
87
|
+
|
|
88
|
+
环境变量 `PDF_JUMP_ROOTS` 也可以加搜索根,按平台的路径分隔符分隔(Windows 是 `;`,其他是 `:`)。
|
|
89
|
+
|
|
90
|
+
## 限制
|
|
91
|
+
|
|
92
|
+
- `pdf-open` 依赖 DSH 自带的原生 PDF 预览;没有该预览的环境用默认的 `pdf-view`。
|
|
93
|
+
- `pdf-view` 的页面是图片:不能选文字,也不能全文搜索;要文字另装 `dsh-pdf` 插件,用它的 `pdf_extract_text`。
|
|
94
|
+
- 渲染依赖 `@napi-rs/canvas` 的预编译二进制,覆盖 Windows / macOS / Linux 的 x64 与 arm64。
|
|
95
|
+
|
|
96
|
+
## 打不开时
|
|
97
|
+
|
|
98
|
+
- **侧栏空白或提示「已阻止」**:应用内侧栏浏览器默认拦截本机回环地址。把 `127.0.0.1:8731`(及 8732–8750 中实际用到的)加进侧栏设置的「允许访问的本机地址」,或把「设置 → 通用 → 网页链接默认打开方式」改成「默认浏览器」。
|
|
99
|
+
- **旧对话里的链接提示「拒绝连接」**:要么端口被别的程序占了导致换了端口,要么 PDF 文件被移动/删除。重新调用一次工具取新地址;`pdf-open` 返回的文件路径不受影响。
|
|
100
|
+
|
|
101
|
+
## 许可
|
|
102
|
+
|
|
103
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 类型:工具;解决:侧栏 iframe 不渲染 PDF,手册要按页翻、按页跳;
|
|
3
|
+
* 作用:本地把页面渲成图,给出带翻页 / 跳页 / 整本浏览的网页地址(页码按 PDF 页序)。
|
|
4
|
+
*
|
|
5
|
+
* 不 import 任何 DSH 包:工具定义按 ctx.tools.register 的形状直接写,换 DSH 版本不受影响。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { basename, resolve } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { resolvePdf } from './lib/resolve.mjs'
|
|
11
|
+
import { createViewer, registerFile } from './lib/server.mjs'
|
|
12
|
+
|
|
13
|
+
/** Cordis 插件名。 */
|
|
14
|
+
export const name = 'dsh-pdf-jump'
|
|
15
|
+
/** 需要的服务:工具注册表 + 系统提示(让模型知道什么时候该用它)+ 命令注册表(/pdf)。 */
|
|
16
|
+
export const inject = ['tools', 'systemPrompt', 'commands']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 每轮都在模型上下文里的使用说明。工具描述只在工具列表里出现一次,
|
|
20
|
+
* 这段公告把「什么时候用 / 什么时候不用」说清楚,保证模型选得对。
|
|
21
|
+
*/
|
|
22
|
+
const GUIDANCE = [
|
|
23
|
+
'本机装了 dsh-pdf-jump(在侧栏里看 PDF,两个独立入口):',
|
|
24
|
+
'- 默认用 pdf-view(单页跳转模式):带页码打开,页面带「上一页/下一页/跳页码/整本浏览」。用户只说「打开/看一眼某本 PDF」「打开第 N 页」也一律用它。',
|
|
25
|
+
'- pdf-open 只在明确要求「原生预览、不生成图片、最快」时才用,不作为默认。',
|
|
26
|
+
'- 用户要提取 PDF 文字、读元数据或比较字体时,用 dsh-pdf 插件(需另装)的 pdf_info / pdf_extract_text,不要用这两个工具。',
|
|
27
|
+
'- 不要绕过这两个工具自己用 sidebar_open 打开 PDF 文件:那样没有跳页、没有整本浏览;pdf-open 返回的路径再交给 sidebar_open 才是正确顺序。',
|
|
28
|
+
'- 两个工具都返回本机路径或本地 url:有 sidebar_open 就先调它开在侧栏;不要把裸路径/裸 url 贴给用户,用「📄 文件名」做链接文字。',
|
|
29
|
+
'- 只给了文件名片段、不知道路径时直接传进去:先按会话工作目录找,再按片段搜。',
|
|
30
|
+
'- 用户说「点了链接没反应 / 打不开」时,说明是侧栏浏览器默认拦截本机地址:建议把「网页链接默认打开方式」改成默认浏览器,或把 127.0.0.1:<端口> 加进侧栏设置里的本机地址白名单。',
|
|
31
|
+
'- 旧对话里的 url 在服务空闲退出后会失效,不要复用;每次重新调用取新地址。',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
34
|
+
const parameters = {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
file: { type: 'string', description: 'PDF 绝对/相对路径,或文件名片段(如 "VFD-EL-W 手册")' },
|
|
38
|
+
page: { type: 'number', description: 'PDF 页序(从 1 开始),不是页脚印的印刷页码' },
|
|
39
|
+
cwd: { type: 'string', description: '搜索根目录;默认用当前会话的工作目录' },
|
|
40
|
+
},
|
|
41
|
+
required: ['file', 'page'],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const output = {
|
|
45
|
+
schema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
properties: {
|
|
49
|
+
ok: { type: 'boolean' },
|
|
50
|
+
url: { type: 'string' },
|
|
51
|
+
file: { type: 'string' },
|
|
52
|
+
page: { type: 'number' },
|
|
53
|
+
},
|
|
54
|
+
required: ['ok', 'url', 'file', 'page'],
|
|
55
|
+
},
|
|
56
|
+
render: (_args, value) => [{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: `[📄 ${basename(value.file)} · 第 ${value.page} 页](<${value.url}>)`,
|
|
59
|
+
}],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 当前会话的工作目录;取不到时退回进程目录。 */
|
|
63
|
+
function sessionCwd(ctx, exec) {
|
|
64
|
+
try {
|
|
65
|
+
const workingDirectory = ctx.get?.('workingDirectory')
|
|
66
|
+
const session = exec?.agent?.session
|
|
67
|
+
if (workingDirectory && session) return workingDirectory.get(session)
|
|
68
|
+
} catch { /* 老版本没有这个服务,用进程目录 */ }
|
|
69
|
+
return process.cwd()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function apply(ctx, config = {}) {
|
|
73
|
+
const viewer = createViewer({
|
|
74
|
+
port: config.port,
|
|
75
|
+
scale: config.scale,
|
|
76
|
+
idleMinutes: config.idleMinutes,
|
|
77
|
+
prefetch: config.prefetch,
|
|
78
|
+
prefetchWindow: config.prefetchWindow,
|
|
79
|
+
})
|
|
80
|
+
const extraRoots = Array.isArray(config.roots) ? config.roots.map(String) : []
|
|
81
|
+
|
|
82
|
+
let starting = null
|
|
83
|
+
const ensureViewer = () => {
|
|
84
|
+
// start() 自己会复用已在跑的服务;服务因空闲退出后再次调用会重新拉起。
|
|
85
|
+
if (!starting) starting = viewer.start().finally(() => { starting = null })
|
|
86
|
+
return starting
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctx.effect?.(() => () => { viewer.stop().catch(() => {}) })
|
|
90
|
+
|
|
91
|
+
// 插件加载就把服务起起来。DSH 重启后还是同一个默认端口、同一份路径注册表,
|
|
92
|
+
// 旧对话里的 /v、/d 链接可以直接重新打开;端口被占用时工具调用会再试。
|
|
93
|
+
viewer.start().catch(() => {})
|
|
94
|
+
|
|
95
|
+
const hasTool = (name) => {
|
|
96
|
+
try {
|
|
97
|
+
return typeof ctx.tools.get === 'function' && ctx.tools.get(name) !== undefined
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!hasTool('pdf-view')) {
|
|
104
|
+
try {
|
|
105
|
+
ctx.tools.register({
|
|
106
|
+
name: 'pdf-view',
|
|
107
|
+
description:
|
|
108
|
+
'在侧栏里看本地 PDF(默认入口):单页跳转模式,页面带「上一页/下一页/跳页码/整本浏览」;'
|
|
109
|
+
+ '打开某本 PDF、翻到第 N 页、整本浏览都用它。pdf-open 只在明确要原生预览时用;要提取文字或元数据用 dsh-pdf 插件(需另装)的 pdf_info / pdf_extract_text。'
|
|
110
|
+
+ '本地服务把该页渲染成图,返回带「上一页/下一页/跳页码/整本浏览」的侧栏网页地址。'
|
|
111
|
+
+ 'file 给 PDF 路径或文件名片段(相对路径按会话工作目录解析),page 给 PDF 页序(从 1 开始,不从页脚推断印刷页码)。'
|
|
112
|
+
+ '返回 url:有 sidebar_open 就交给它打开;不要把裸 url 贴给用户。首次调用自动拉起本地服务(只绑 127.0.0.1)。',
|
|
113
|
+
parameters,
|
|
114
|
+
output,
|
|
115
|
+
isConcurrencySafe: () => true,
|
|
116
|
+
async execute(args, exec) {
|
|
117
|
+
const cwd = args.cwd ? resolve(String(args.cwd)) : sessionCwd(ctx, exec)
|
|
118
|
+
const pdf = resolvePdf(args.file, cwd, extraRoots)
|
|
119
|
+
const port = await ensureViewer()
|
|
120
|
+
const page = Math.max(1, Math.round(Number(args.page) || 1))
|
|
121
|
+
const id = registerFile(pdf)
|
|
122
|
+
// 工具一返回就在后台把这一页和附近几页渲进缓存,用户点开时直接取图。
|
|
123
|
+
if (config.prefetch !== false) viewer.prefetch(pdf, page)
|
|
124
|
+
const url = `http://127.0.0.1:${port}/v/${id}/${page}`
|
|
125
|
+
return { ok: true, url, file: pdf, page }
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.warn(`[dsh-pdf-jump] 未注册 pdf-view:${err.message}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 常驻说明:描述两个工具的分工;没有 systemPrompt 服务时静默跳过。
|
|
134
|
+
const systemPrompt = ctx.get?.('systemPrompt')
|
|
135
|
+
if (systemPrompt && typeof systemPrompt.section === 'function') {
|
|
136
|
+
ctx.effect?.(
|
|
137
|
+
() => systemPrompt.section({ name: 'plugin:dsh-pdf-jump', order: 161, text: GUIDANCE }),
|
|
138
|
+
'dsh-pdf-jump: guidance',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 命令层:完全不经过模型判断,用户输入 /pdf 就一定走这两个工具。
|
|
143
|
+
try {
|
|
144
|
+
const commands = ctx.commands ?? ctx.get?.('commands')
|
|
145
|
+
if (commands && typeof commands.register === 'function') {
|
|
146
|
+
commands.register({
|
|
147
|
+
name: 'pdf',
|
|
148
|
+
description: '在侧栏打开 PDF(参数:<路径或文件名片段> [页码]);默认走 pdf-view 单页跳转模式,页码省略时从第 1 页起',
|
|
149
|
+
input: { hint: '<PDF 路径或文件名片段> [页码]' },
|
|
150
|
+
handler: async ({ rawInput }) => {
|
|
151
|
+
try {
|
|
152
|
+
const raw = String(rawInput ?? '').trim()
|
|
153
|
+
if (!raw) return { kind: 'error', text: '用法:/pdf <PDF 路径或文件名片段> [页码]' }
|
|
154
|
+
const matched = raw.match(/^(.*?)\s+(\d+)$/)
|
|
155
|
+
const target = matched ? matched[1].trim() : raw
|
|
156
|
+
const page = matched ? Math.max(1, Math.round(Number(matched[2]))) : 1
|
|
157
|
+
const pdf = resolvePdf(target, process.cwd(), extraRoots)
|
|
158
|
+
const port = await ensureViewer()
|
|
159
|
+
const id = registerFile(pdf)
|
|
160
|
+
if (config.prefetch !== false) viewer.prefetch(pdf, page)
|
|
161
|
+
return { kind: 'success', text: `[📄 ${basename(pdf)} · 第 ${page} 页](<http://127.0.0.1:${port}/v/${id}/${page}>)` }
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn(`[dsh-pdf-jump] 未注册 /pdf 命令:${err.message}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
package/lib/pdf.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF 页面渲染:pdfjs-dist 取页 + @napi-rs/canvas 出 PNG。
|
|
3
|
+
*
|
|
4
|
+
* 不依赖 Python;首次渲染一个文件时载入文档并缓存,文档改动(mtime/size)后自动重载。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
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
|
+
|
|
21
|
+
const CACHE_DIR = join(tmpdir(), 'dsh-pdf-jump-cache')
|
|
22
|
+
const MAX_DOCS = 2
|
|
23
|
+
|
|
24
|
+
const docs = new Map()
|
|
25
|
+
|
|
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))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
destroy() {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 打开(或复用)一个 PDF 文档。文档句柄连同它的 loading task 一起缓存,
|
|
42
|
+
* 释放时走 task.destroy()(pdfjs 6 的 PDFDocumentProxy 不再直接提供 destroy)。
|
|
43
|
+
* @param {string} file PDF 绝对路径
|
|
44
|
+
*/
|
|
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(() => {})
|
|
73
|
+
}
|
|
74
|
+
return doc
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 页数。页码一律按 PDF 页序,不从页脚推断印刷页码——很多手册混用全书页码和
|
|
79
|
+
* 「章-页」编号,推断出来的差值会把正文页整体错位。
|
|
80
|
+
* @param {string} file PDF 绝对路径
|
|
81
|
+
* @returns {Promise<{pages: number}>}
|
|
82
|
+
*/
|
|
83
|
+
export async function documentInfo(file) {
|
|
84
|
+
const doc = await getDocument(file)
|
|
85
|
+
return { pages: doc.numPages }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 清理 7 天前的渲染缓存(只在第一次渲染时执行一次)。
|
|
90
|
+
*/
|
|
91
|
+
let pruned = false
|
|
92
|
+
function pruneCache() {
|
|
93
|
+
if (pruned) return
|
|
94
|
+
pruned = true
|
|
95
|
+
try {
|
|
96
|
+
const cutoff = Date.now() - 7 * 24 * 3_600_000
|
|
97
|
+
for (const name of readdirSync(CACHE_DIR)) {
|
|
98
|
+
const target = join(CACHE_DIR, name)
|
|
99
|
+
try {
|
|
100
|
+
if (statSync(target).mtimeMs < cutoff) rmSync(target, { force: true })
|
|
101
|
+
} catch { /* 单个文件清理失败不影响渲染 */ }
|
|
102
|
+
}
|
|
103
|
+
} catch { /* 缓存目录还不存在 */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
/** 整本浏览用的缩略图倍率:0.8 约等于 476px 宽,列表里放大到 760px 内可辨版式。 */
|
|
115
|
+
export const THUMB_SCALE = 0.8
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 某页是否已经有渲染缓存(预渲染循环用它跳过已完成的页)。
|
|
119
|
+
* @param {string} file PDF 绝对路径
|
|
120
|
+
* @param {number} pdfPage PDF 页序
|
|
121
|
+
* @param {number} scale 渲染倍率
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function isPageCached(file, pdfPage, scale = 2) {
|
|
125
|
+
try {
|
|
126
|
+
return existsSync(cachePathFor(file, pdfPage, scale))
|
|
127
|
+
} catch {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 直接读已渲染的页图;没有缓存返回 null(不触发渲染)。
|
|
134
|
+
* @param {string} file PDF 绝对路径
|
|
135
|
+
* @param {number} pdfPage PDF 页序
|
|
136
|
+
* @param {number} scale 渲染倍率
|
|
137
|
+
* @returns {Buffer|null}
|
|
138
|
+
*/
|
|
139
|
+
export function readCachedPage(file, pdfPage, scale = 2) {
|
|
140
|
+
try {
|
|
141
|
+
const cachePath = cachePathFor(file, pdfPage, scale)
|
|
142
|
+
return existsSync(cachePath) ? readFileSync(cachePath) : null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 把一页渲染成 PNG(带磁盘缓存)。
|
|
150
|
+
* @param {string} file PDF 绝对路径
|
|
151
|
+
* @param {number} pdfPage PDF 页序(从 1 起)
|
|
152
|
+
* @param {number} scale 渲染倍率,2 约等于 144 dpi
|
|
153
|
+
* @returns {Promise<Buffer>} PNG 字节
|
|
154
|
+
*/
|
|
155
|
+
export async function renderPage(file, pdfPage, scale = 2) {
|
|
156
|
+
const cachePath = cachePathFor(file, pdfPage, scale)
|
|
157
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
158
|
+
pruneCache()
|
|
159
|
+
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
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 缩略图是否已有缓存(整本浏览用,避免重复渲染)。 */
|
|
177
|
+
export function isThumbCached(file, pdfPage, scale = THUMB_SCALE) {
|
|
178
|
+
try {
|
|
179
|
+
return existsSync(cachePathFor(file, pdfPage, scale, 'jpg'))
|
|
180
|
+
} catch {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 直接读已渲染的缩略图;没有缓存返回 null。 */
|
|
186
|
+
export function readCachedThumb(file, pdfPage, scale = THUMB_SCALE) {
|
|
187
|
+
try {
|
|
188
|
+
const cachePath = cachePathFor(file, pdfPage, scale, 'jpg')
|
|
189
|
+
return existsSync(cachePath) ? readFileSync(cachePath) : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 把一页渲染成整本浏览用的缩略图(JPEG,白底)。
|
|
197
|
+
* 缩略图只求版式可辨,编码比全尺寸 PNG 快一个数量级。
|
|
198
|
+
* @param {string} file PDF 绝对路径
|
|
199
|
+
* @param {number} pdfPage PDF 页序(从 1 起)
|
|
200
|
+
* @param {number} scale 倍率,默认 0.8(约 476px 宽)
|
|
201
|
+
* @param {number} quality JPEG 质量,默认 0.8
|
|
202
|
+
* @returns {Promise<Buffer>} JPEG 字节
|
|
203
|
+
*/
|
|
204
|
+
export async function renderThumb(file, pdfPage, scale = THUMB_SCALE, quality = 0.8) {
|
|
205
|
+
const cachePath = cachePathFor(file, pdfPage, scale, 'jpg')
|
|
206
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
207
|
+
pruneCache()
|
|
208
|
+
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
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 释放所有已载入的文档(插件卸载时调用)。 */
|
|
229
|
+
export async function closeDocuments() {
|
|
230
|
+
const all = [...docs.values()]
|
|
231
|
+
docs.clear()
|
|
232
|
+
await Promise.all(all.map((entry) => entry.task.destroy().catch(() => {})))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 渲染缓存目录,供文档与诊断使用。 */
|
|
236
|
+
export const cacheDir = CACHE_DIR
|