@ggboy123/dsh-paper-reader 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -10
- package/dist/babeldoc-install.js +148 -18
- package/lib/client.js +115 -10
- package/package.json +3 -2
- package/reader/index.html +26 -7
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:论文伴读工作台 —— PDF 转录 / 检索 + 原生对话伴读 + 内置阅读器。
|
|
11
11
|
|
|
12
|
+
> **👉 普通用户直接看「安装 → 方式一」**:装 DeepSeek 官方桌面端,添加插件即可,不需要任何开发环境。本仓库面向的是想自己装插件、或想改代码的用户。
|
|
13
|
+
|
|
12
14
|
功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
|
|
13
15
|
|
|
14
16
|
**安装后不改动官方界面** —— 侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式(左侧变为文献库树),再点即还原官方工作区列表:
|
|
@@ -33,17 +35,23 @@
|
|
|
33
35
|
|
|
34
36
|
## 安装
|
|
35
37
|
|
|
36
|
-
###
|
|
38
|
+
### 方式一:官方桌面端装插件(推荐)
|
|
39
|
+
|
|
40
|
+
**DeepSeek 官方桌面端**([deepseek.com/download](https://www.deepseek.com/download/) 下载,即 DeepSeek Harness 桌面版):
|
|
41
|
+
|
|
42
|
+
1. 安装并启动,登录 DeepSeek 账号
|
|
43
|
+
2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.2.1` 安装
|
|
44
|
+
3. 装完默认**停用**:点进插件详情,打开「启用」开关
|
|
45
|
+
4. **重启桌面端**(热启用状态下打开论文的链路会静默失效,重启进开机组合才正常,实测)
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
完整流程演示(添加插件 → 启用 → 重启 → 打开论文直接提问):
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
2. 托盘菜单 → **Open DSH Terminal**(终端里自带 `dsh`/`pnpm`,只对那个终端生效)
|
|
42
|
-
3. 执行 `dsh plugin add @ggboy123/dsh-paper-reader@1.1.0`
|
|
43
|
-
4. 退出并重开 DSH Desktop(插件变更要重启才进 Loader 组合)
|
|
49
|
+

|
|
44
50
|
|
|
45
|
-
>
|
|
46
|
-
>
|
|
51
|
+
> 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
|
|
52
|
+
> 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
|
|
53
|
+
|
|
54
|
+
> 曾经的 PaperReader 桌面版(基于社区 DSH Desktop 的预装打包)**已不再维护**——统一收敛到官方桌面端 + 插件的形式,老用户请迁移到本方式。
|
|
47
55
|
|
|
48
56
|
### 方式二:命令行 dsh(开发者)
|
|
49
57
|
|
|
@@ -59,7 +67,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader
|
|
|
59
67
|
**已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
|
|
60
68
|
|
|
61
69
|
```bash
|
|
62
|
-
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.1
|
|
70
|
+
dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.2.1
|
|
63
71
|
# 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
|
|
64
72
|
```
|
|
65
73
|
|
|
@@ -71,7 +79,11 @@ grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/pac
|
|
|
71
79
|
|
|
72
80
|
> pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。
|
|
73
81
|
|
|
74
|
-
|
|
82
|
+
### 参与开发(本地调试)
|
|
83
|
+
|
|
84
|
+
欢迎改代码。流程:clone → 构建 → 以本地目录装进 profile,之后改完 `pnpm run build` 重启对应 profile 即可验证:
|
|
85
|
+
|
|
86
|
+

|
|
75
87
|
|
|
76
88
|
```bash
|
|
77
89
|
git clone https://github.com/GGboya/dsh-paper-reader
|
|
@@ -102,6 +114,21 @@ dsh --profile web # 侧栏变为文献库树
|
|
|
102
114
|
|
|
103
115
|
> 伴读模式是**按需开启**的:默认不注册 `sidebar.workspaces`(官方工作区/会话列表原样),侧栏底部「📚 论文伴读」开关点击才接管,再点还原;模式选择记在 localStorage。退出模式时已打开的 PDF 页签和伴读会话不受影响。
|
|
104
116
|
|
|
117
|
+
### 翻译引擎冒烟测试(Windows 依赖完整性)
|
|
118
|
+
|
|
119
|
+
Windows 用户最常见的翻车点是翻译引擎装不上(杀软拦 uv、网络截断、venv 残缺)。安装逻辑 `src/babeldoc-install.ts` 是纯 Node 模块(不依赖 Cordis/Electron),配了 GitHub Actions 在**全新干净机器**上跑冒烟,无需本地 Windows:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pnpm build
|
|
123
|
+
pnpm smoke:engine clean # 全新安装全链路
|
|
124
|
+
pnpm smoke:engine managed-uv # 强制走插件托管 uv 下载(sha256 校验路径)
|
|
125
|
+
pnpm smoke:engine pip-fallback # 强制走 pip + 国内镜像备用通道
|
|
126
|
+
pnpm smoke:engine repair # 破坏 venv(删 pymupdf)后验证自检→自动修复
|
|
127
|
+
pnpm smoke:engine translate # 再起本地 mock OpenAI 端点,真跑一遍 babeldoc 翻译管线
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
CI(`.github/workflows/engine-smoke.yml`):`windows-latest` 跑全部四个场景 + translate-e2e,`macos-latest`/`ubuntu-latest` 跑 clean 防回归;改安装/翻译代码的 push 触发,外加每周一定时(uv/PyPI 在变,代码不动也可能突然坏)。两个测试钩子环境变量:`DSH_PR_MANAGED_UV_ONLY=1`(跳过系统 uv 查找)、`DSH_PR_FORCE_PIP_CHANNEL=1`(跳过 uv 装包通道)。
|
|
131
|
+
|
|
105
132
|
## 架构
|
|
106
133
|
|
|
107
134
|
```
|
package/dist/babeldoc-install.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 链路:uv 独立二进制(GitHub Releases,带 sha256 校验)
|
|
4
4
|
// → `uv venv`(uv 自动下载托管 Python,系统没有 Python 也能跑)
|
|
5
|
-
// → `uv pip install babeldoc
|
|
5
|
+
// → `uv pip install babeldoc`(失败时退到 pip + 国内镜像自举)
|
|
6
|
+
// 使用前后各做一次 `babeldoc --version` 自检:venv 残缺(依赖被杀软/中断搞掉一半,
|
|
7
|
+
// exe 还在但一跑就 ModuleNotFoundError)会被当场抓住并自动走重装修复,而不是让用户
|
|
8
|
+
// 在翻译时对着 import 报错干瞪眼。
|
|
6
9
|
// 全部落在用户目录:uv 二进制在 <dshHome>/.dsh-paper-reader/bin/uv(win32 为 uv.exe),
|
|
7
10
|
// venv 在 文献库同级 .venv-pdf2zh(必须与 translate.ts findBabeldoc 的第一候选位一致),
|
|
8
11
|
// 不碰系统 Python、不需要 sudo。
|
|
@@ -62,6 +65,10 @@ async function findUv(home) {
|
|
|
62
65
|
const own = uvPath(home);
|
|
63
66
|
if (existsSync(own))
|
|
64
67
|
return own;
|
|
68
|
+
// 测试钩子:CI runner 常在 PATH 预装 uv,会静默绕过 downloadUv;
|
|
69
|
+
// DSH_PR_MANAGED_UV_ONLY=1 时跳过系统查找,强制走插件托管下载链路(含 sha256 校验)。
|
|
70
|
+
if (process.env.DSH_PR_MANAGED_UV_ONLY === '1')
|
|
71
|
+
return null;
|
|
65
72
|
if (await whichOk('uv'))
|
|
66
73
|
return 'uv';
|
|
67
74
|
const candidates = isWin
|
|
@@ -73,6 +80,36 @@ async function findUv(home) {
|
|
|
73
80
|
}
|
|
74
81
|
return null;
|
|
75
82
|
}
|
|
83
|
+
/** GitHub API 请求头:有 GITHUB_TOKEN/GH_TOKEN 就带上——匿名限流 60/h 按出口 IP 算,
|
|
84
|
+
* CI runner 和企业 NAT 都是共享 IP,极易撞 403;带 token 后按账号 5000/h。 */
|
|
85
|
+
function ghHeaders(accept) {
|
|
86
|
+
const h = { accept, 'user-agent': 'dsh-paper-reader' };
|
|
87
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
88
|
+
if (token)
|
|
89
|
+
h.authorization = `Bearer ${token}`;
|
|
90
|
+
return h;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* fetch 带重试:用户网络抖一下(尤其国内到 GitHub)就直接失败太脆了。
|
|
94
|
+
* 只对网络错误和 5xx 重试,4xx 是确定性错误直接抛。
|
|
95
|
+
*/
|
|
96
|
+
async function fetchWithRetry(url, init, tries = 3) {
|
|
97
|
+
let lastErr = null;
|
|
98
|
+
for (let i = 0; i < tries; i++) {
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch(url, init);
|
|
101
|
+
if (r.ok || (r.status >= 400 && r.status < 500))
|
|
102
|
+
return r;
|
|
103
|
+
lastErr = new Error(`HTTP ${r.status}`);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
lastErr = e;
|
|
107
|
+
}
|
|
108
|
+
if (i < tries - 1)
|
|
109
|
+
await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
|
|
110
|
+
}
|
|
111
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
112
|
+
}
|
|
76
113
|
/**
|
|
77
114
|
* 下载 uv 独立二进制。全程走 api.github.com(资产接口 + octet-stream 直连),
|
|
78
115
|
* 不依赖 github.com 页面域——后者在部分网络环境不可达,而 API 域通常可达。
|
|
@@ -89,8 +126,8 @@ async function downloadUv(home, onPhase) {
|
|
|
89
126
|
await mkdir(tmp, { recursive: true });
|
|
90
127
|
try {
|
|
91
128
|
onPhase('正在安装翻译引擎(1/3 下载 uv)…');
|
|
92
|
-
const rel = await
|
|
93
|
-
headers:
|
|
129
|
+
const rel = await fetchWithRetry('https://api.github.com/repos/astral-sh/uv/releases/latest', {
|
|
130
|
+
headers: ghHeaders('application/vnd.github+json'),
|
|
94
131
|
signal: AbortSignal.timeout(30_000),
|
|
95
132
|
});
|
|
96
133
|
if (!rel.ok)
|
|
@@ -101,8 +138,8 @@ async function downloadUv(home, onPhase) {
|
|
|
101
138
|
throw new Error(`uv 最新版没有 ${want} 资产`);
|
|
102
139
|
const sumAsset = assets.find((a) => a.name === want + '.sha256');
|
|
103
140
|
const download = async (a) => {
|
|
104
|
-
const r = await
|
|
105
|
-
headers:
|
|
141
|
+
const r = await fetchWithRetry(`https://api.github.com/repos/astral-sh/uv/releases/assets/${a.id}`, {
|
|
142
|
+
headers: ghHeaders('application/octet-stream'),
|
|
106
143
|
signal: AbortSignal.timeout(180_000),
|
|
107
144
|
});
|
|
108
145
|
if (!r.ok)
|
|
@@ -124,7 +161,10 @@ async function downloadUv(home, onPhase) {
|
|
|
124
161
|
// 不带 -z:bsdtar(macOS 自带 / Win10 1803+ 的 tar.exe)和 GNU tar 解包时都自动探测格式,
|
|
125
162
|
// 同一条命令通吃 tar.gz 与 zip(win32 的 uv 只发 zip)
|
|
126
163
|
await run('tar', ['-xf', pkgPath, '-C', tmp], { timeout: 60_000 });
|
|
127
|
-
|
|
164
|
+
// tar.gz 有顶层目录 uv-<triple>/;win32 的 zip 是平铺的(uv.exe 直接在根)
|
|
165
|
+
const binName = isWin ? 'uv.exe' : 'uv';
|
|
166
|
+
const nested = join(tmp, `uv-${triple}`, binName);
|
|
167
|
+
const extracted = existsSync(nested) ? nested : join(tmp, binName);
|
|
128
168
|
if (!existsSync(extracted))
|
|
129
169
|
throw new Error('uv 解包结果不符合预期');
|
|
130
170
|
const dst = uvPath(home);
|
|
@@ -137,16 +177,95 @@ async function downloadUv(home, onPhase) {
|
|
|
137
177
|
await rm(tmp, { recursive: true, force: true });
|
|
138
178
|
}
|
|
139
179
|
}
|
|
140
|
-
/**
|
|
180
|
+
/** venv 里的 python 路径(win32 在 Scripts/ 且带 .exe)。 */
|
|
181
|
+
function venvPython(venvDir) {
|
|
182
|
+
return isWin ? join(venvDir, 'Scripts', 'python.exe') : join(venvDir, 'bin', 'python');
|
|
183
|
+
}
|
|
184
|
+
/** venv 里的 babeldoc 可执行文件路径(与 translate.ts findBabeldoc 的第一候选位一致)。 */
|
|
185
|
+
function venvBabeldoc(venvDir) {
|
|
186
|
+
return isWin ? join(venvDir, 'Scripts', 'babeldoc.exe') : join(venvDir, 'bin', 'babeldoc');
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 自检:跑一次 --version。import 链上缺任何包(configargparse/pymupdf/rich…)都会在这里炸,
|
|
190
|
+
* 正好覆盖「exe 在、依赖残缺」的坏状态。通过的 bin 记进 Set,后续调用零开销。
|
|
191
|
+
*/
|
|
192
|
+
const verified = new Set();
|
|
193
|
+
function smokeCheck(bin) {
|
|
194
|
+
if (verified.has(bin))
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
return new Promise((res, rej) => {
|
|
197
|
+
execFile(bin, ['--version'], { timeout: 60_000 }, (err, stdout, stderr) => {
|
|
198
|
+
if (err) {
|
|
199
|
+
const tail = (stdout + '\n' + stderr).trim().slice(-300);
|
|
200
|
+
rej(new Error(tail || err.message));
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
verified.add(bin);
|
|
204
|
+
res();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* uv 之外的备用安装通道:venv 自举 pip(ensurepip)→ 国内镜像轮询装 babeldoc。
|
|
211
|
+
* 触发场景(均在实测中出现过):
|
|
212
|
+
* - 杀毒软件锁 uv 临时入口 exe 的 PE 资源更新(UpdateResource 被拒,uv 全线失败);
|
|
213
|
+
* - uv 默认 PyPI 源在国内网络下超时/响应截断。pip 写入口 exe 用普通文件写,不踩这两坑。
|
|
214
|
+
*/
|
|
215
|
+
const CN_MIRRORS = [
|
|
216
|
+
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
|
217
|
+
'https://mirrors.aliyun.com/pypi/simple/',
|
|
218
|
+
'https://mirrors.cloud.tencent.com/pypi/simple',
|
|
219
|
+
];
|
|
220
|
+
async function pipInstallBabeldoc(py) {
|
|
221
|
+
await run(py, ['-m', 'ensurepip', '--upgrade'], { timeout: 5 * 60_000 });
|
|
222
|
+
let lastErr = null;
|
|
223
|
+
for (const m of CN_MIRRORS) {
|
|
224
|
+
try {
|
|
225
|
+
await run(py, [
|
|
226
|
+
'-m', 'pip', 'install', '--no-cache-dir', '--timeout', '120', '--retries', '3', '-i', m, 'babeldoc',
|
|
227
|
+
], { timeout: 30 * 60_000 });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
lastErr = e; // 换下一个镜像;已装成功的包 pip 会跳过,重试是断点续传式的
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 建 venv + 装 babeldoc(或修复残缺安装)。uv 没有可用 Python 时会自己下载托管版。
|
|
238
|
+
* venv 已存在时跳过 `uv venv`——它会清空重建,毁掉 pip 修复过的包,直接补依赖即可
|
|
239
|
+
* (`uv pip install` 对缺失的依赖是幂等补装)。
|
|
240
|
+
*/
|
|
141
241
|
async function installBabeldoc(uv, venvDir, onPhase) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
242
|
+
const py = venvPython(venvDir);
|
|
243
|
+
const bin = venvBabeldoc(venvDir);
|
|
244
|
+
if (!existsSync(join(venvDir, 'pyvenv.cfg'))) {
|
|
245
|
+
onPhase('正在安装翻译引擎(2/3 安装 Python)…');
|
|
246
|
+
// --python-preference only-managed:不碰用户系统里可能残缺/带 externally-managed 标记的 Python
|
|
247
|
+
await run(uv, ['venv', venvDir, '--python', '3.12', '--python-preference', 'only-managed'], { timeout: 10 * 60_000 });
|
|
248
|
+
}
|
|
147
249
|
onPhase('正在安装翻译引擎(3/3 安装 babeldoc,首次约几分钟)…');
|
|
148
|
-
|
|
149
|
-
|
|
250
|
+
const viaPipFallback = async (cause) => {
|
|
251
|
+
onPhase('正在安装翻译引擎(3/3 备用通道:pip + 国内镜像)…');
|
|
252
|
+
if (cause) {
|
|
253
|
+
console.warn('[dsh-paper-reader] uv 安装 babeldoc 失败,改用 pip 镜像通道:', cause instanceof Error ? cause.message : cause);
|
|
254
|
+
}
|
|
255
|
+
await pipInstallBabeldoc(py);
|
|
256
|
+
};
|
|
257
|
+
// 测试钩子:DSH_PR_FORCE_PIP_CHANNEL=1 跳过 uv 通道,让 CI 确定性覆盖 pip 备用路径
|
|
258
|
+
//(杀软锁 uv 入口 exe、PyPI 截断时的逃生通道,Windows 上最关键的一条路径)
|
|
259
|
+
if (process.env.DSH_PR_FORCE_PIP_CHANNEL === '1')
|
|
260
|
+
await viaPipFallback(null);
|
|
261
|
+
else {
|
|
262
|
+
try {
|
|
263
|
+
await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
|
|
264
|
+
}
|
|
265
|
+
catch (uvErr) {
|
|
266
|
+
await viaPipFallback(uvErr);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
150
269
|
if (!existsSync(bin))
|
|
151
270
|
throw new Error('babeldoc 安装完成但未找到可执行文件');
|
|
152
271
|
return bin;
|
|
@@ -155,18 +274,29 @@ async function installBabeldoc(uv, venvDir, onPhase) {
|
|
|
155
274
|
let inflight = null;
|
|
156
275
|
/**
|
|
157
276
|
* 确保 babeldoc 可用,返回可执行文件路径。
|
|
158
|
-
* 已存在(venv/PATH/miniconda
|
|
277
|
+
* 已存在(venv/PATH/miniconda)先自检:残缺(exe 在、依赖缺)不直接用,转修复流程。
|
|
159
278
|
* venv 落 dirname(dataDir)/.venv-pdf2zh —— 与 translate.ts findBabeldoc 的第一候选位严格一致。
|
|
160
279
|
*/
|
|
161
280
|
export function ensureBabeldoc(dataDir, home, findExisting, onPhase) {
|
|
162
281
|
if (!inflight) {
|
|
163
282
|
inflight = (async () => {
|
|
164
283
|
const existing = await findExisting();
|
|
165
|
-
if (existing)
|
|
166
|
-
|
|
284
|
+
if (existing) {
|
|
285
|
+
try {
|
|
286
|
+
await smokeCheck(existing);
|
|
287
|
+
return existing;
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.warn('[dsh-paper-reader] babeldoc 自检未过(残缺安装),进入修复:', e instanceof Error ? e.message : e);
|
|
291
|
+
onPhase('检测到翻译引擎组件缺失,正在修复…');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
167
294
|
const venvDir = join(dirname(dataDir), '.venv-pdf2zh');
|
|
168
295
|
const uv = (await findUv(home)) ?? (await downloadUv(home, onPhase));
|
|
169
|
-
|
|
296
|
+
const bin = await installBabeldoc(uv, venvDir, onPhase);
|
|
297
|
+
// 终检:把真实的缺包报错在安装阶段抛出来,别等到翻译跑一半才炸
|
|
298
|
+
await smokeCheck(bin);
|
|
299
|
+
return bin;
|
|
170
300
|
})().finally(() => {
|
|
171
301
|
// 成功后清掉单例让下次走快速路径;失败也清掉让下次可以重试
|
|
172
302
|
inflight = null;
|
package/lib/client.js
CHANGED
|
@@ -29,6 +29,8 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
const zh = {
|
|
30
30
|
library: '文献库',
|
|
31
31
|
empty: '文献库为空', hint: '新建专题或上传 PDF 开始', newTopic: '新建专题',
|
|
32
|
+
cancel: '取消', ok: '确定', defaultTopic: '默认专题', uploadBtn: '上传 PDF',
|
|
33
|
+
newTopicFail: '新建专题失败',
|
|
32
34
|
upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
|
|
33
35
|
history: '历史对话',
|
|
34
36
|
sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
|
|
@@ -48,6 +50,8 @@ window.__ModuleLoader__.load({
|
|
|
48
50
|
const en = {
|
|
49
51
|
library: 'Library',
|
|
50
52
|
empty: 'Library is empty', hint: 'Create a topic or upload a PDF to start', newTopic: 'New topic',
|
|
53
|
+
cancel: 'Cancel', ok: 'OK', defaultTopic: 'Default', uploadBtn: 'Upload PDF',
|
|
54
|
+
newTopicFail: 'Failed to create topic',
|
|
51
55
|
upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
|
|
52
56
|
history: 'Chat history',
|
|
53
57
|
sync: 'Chat is still on another paper — click to switch to the current one',
|
|
@@ -108,6 +112,34 @@ window.__ModuleLoader__.load({
|
|
|
108
112
|
return r.json()
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
/* 当前在屏(主视图)会话 id:0.1.x 是会话列表快照的 .current 字段;
|
|
116
|
+
0.2.0 起快照没有该字段,官方以 retainedBy.mainView>0 标记主视图保留的会话
|
|
117
|
+
(dsh-client-ui-workspace 自己的 current() 就是这么读的)—— 两代都取到。 */
|
|
118
|
+
function currentSessionOf(snap) {
|
|
119
|
+
if (!snap) return null
|
|
120
|
+
if (snap.current !== undefined) return snap.current
|
|
121
|
+
try {
|
|
122
|
+
return Object.values(snap.byId ?? {}).find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id ?? null
|
|
123
|
+
} catch { return null }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ---------- 轻提示(DOM 直挂,不进 React 树) ----------
|
|
127
|
+
window.alert 桌面端(Electron)虽可用但阻塞且各壳表现不一,一律换掉。 */
|
|
128
|
+
function toast(msg) {
|
|
129
|
+
const el = document.createElement('div')
|
|
130
|
+
el.setAttribute('data-dpr-toast', '')
|
|
131
|
+
el.textContent = msg
|
|
132
|
+
Object.assign(el.style, {
|
|
133
|
+
position: 'fixed', left: '50%', bottom: '30px', transform: 'translateX(-50%)',
|
|
134
|
+
zIndex: 80, maxWidth: '76vw', padding: '8px 14px', borderRadius: '8px',
|
|
135
|
+
fontSize: '12px', lineHeight: 1.6, whiteSpace: 'pre-wrap', textAlign: 'center',
|
|
136
|
+
background: 'var(--dsw-alias-bg-inverse, #24292f)', color: 'var(--dsw-alias-label-inverse, #f6f8fa)',
|
|
137
|
+
boxShadow: '0 4px 16px rgba(0,0,0,.25)',
|
|
138
|
+
})
|
|
139
|
+
document.body.appendChild(el)
|
|
140
|
+
setTimeout(() => el.remove(), 4000)
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
/* ---------- 会话切换护栏:PDF/网格全程冻结,只允许对话区换内容 ----------
|
|
112
144
|
rightbar.session 是 session 作用域 slot,切会话 = seat 整体重挂:
|
|
113
145
|
旧 seat 卸载时汇报 closeRightbar,新 seat 初挂时 surface 尚未建立也汇报
|
|
@@ -304,7 +336,7 @@ window.__ModuleLoader__.load({
|
|
|
304
336
|
let docked = null
|
|
305
337
|
try {
|
|
306
338
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
307
|
-
const cur = snap
|
|
339
|
+
const cur = currentSessionOf(snap)
|
|
308
340
|
setCurSid((prev) => (prev === cur ? prev : cur))
|
|
309
341
|
const col = document.querySelector('[data-rightbar-col]')?.previousElementSibling
|
|
310
342
|
const conv = col?.querySelector('[data-phase]')
|
|
@@ -459,6 +491,10 @@ window.__ModuleLoader__.load({
|
|
|
459
491
|
root: { padding: '4px 0 12px', fontSize: '13px', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
460
492
|
head: { display: 'flex', alignItems: 'center', gap: '4px', padding: '8px 10px 6px', fontSize: '12px', fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
461
493
|
iconBtn: { cursor: 'pointer', border: 'none', background: 'none', color: 'inherit', padding: '0 4px', fontSize: '13px', opacity: .75 },
|
|
494
|
+
// 页内弹窗(Electron 无 window.prompt)与空库入口按钮
|
|
495
|
+
btnPrimary: { fontSize: '13px', padding: '5px 14px', borderRadius: '6px', border: 'none', cursor: 'pointer', background: 'var(--dsw-alias-button-primary-fill, #1f6feb)', color: 'var(--dsw-alias-button-primary-foreground, #fff)' },
|
|
496
|
+
btnGhost: { fontSize: '13px', padding: '5px 14px', borderRadius: '6px', cursor: 'pointer', background: 'none', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', color: 'var(--dsw-alias-label-secondary, #656d76)' },
|
|
497
|
+
linkBtn: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '12px', padding: '4px 10px', borderRadius: '6px', cursor: 'pointer', background: 'none', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', color: 'var(--dsw-alias-label-action-primary, #0969da)' },
|
|
462
498
|
topicHead: { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px', margin: '1px 6px 0', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, userSelect: 'none' },
|
|
463
499
|
caret: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #656d76)', transition: 'transform .12s', display: 'inline-block' },
|
|
464
500
|
items: { marginLeft: '14px' },
|
|
@@ -472,6 +508,35 @@ window.__ModuleLoader__.load({
|
|
|
472
508
|
railBtn: { width: '36px', height: '36px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'none', borderRadius: '50%', color: 'var(--dsw-alias-label-primary, #24292f)', cursor: 'pointer', fontSize: '17px', padding: '0' },
|
|
473
509
|
}
|
|
474
510
|
|
|
511
|
+
/* ---------- 页内文本输入弹窗:Electron 渲染进程不实现 window.prompt
|
|
512
|
+
(实测同版本 Electron 调用即抛 "prompt() is not supported",桌面端点「+」
|
|
513
|
+
曾因此毫无反应),一律走 createPortal 挂 document.body,避免被侧栏 overflow 裁剪。 ---------- */
|
|
514
|
+
function AskText(props) {
|
|
515
|
+
const { title, confirmText, cancelText, onConfirm, onCancel } = props
|
|
516
|
+
const [v, setV] = useState('')
|
|
517
|
+
const ref = React.useRef(null)
|
|
518
|
+
useEffect(() => { ref.current?.focus() }, [])
|
|
519
|
+
const submit = () => { const s = v.trim(); if (s) onConfirm(s) }
|
|
520
|
+
return h('div', {
|
|
521
|
+
style: { position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: 'inherit' },
|
|
522
|
+
onClick: onCancel,
|
|
523
|
+
},
|
|
524
|
+
h('div', {
|
|
525
|
+
style: { width: '320px', maxWidth: '90vw', padding: '16px', borderRadius: '10px', boxShadow: '0 8px 30px rgba(0,0,0,.3)', background: 'var(--dsw-alias-bg-layer-1, #ffffff)', color: 'var(--dsw-alias-label-primary, #24292f)' },
|
|
526
|
+
onClick: (e) => e.stopPropagation(),
|
|
527
|
+
},
|
|
528
|
+
h('div', { style: { fontSize: '13px', fontWeight: 600, margin: '0 0 10px' } }, title),
|
|
529
|
+
h('input', {
|
|
530
|
+
ref, value: v, type: 'text',
|
|
531
|
+
style: { width: '100%', boxSizing: 'border-box', fontSize: '13px', padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l1, #d0d7de)', background: 'var(--dsw-alias-bg-layer-2, #f6f8fa)', color: 'inherit', outline: 'none' },
|
|
532
|
+
onChange: (e) => setV(e.target.value),
|
|
533
|
+
onKeyDown: (e) => { if (e.key === 'Enter') submit(); else if (e.key === 'Escape') onCancel() },
|
|
534
|
+
}),
|
|
535
|
+
h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', margin: '12px 0 0' } },
|
|
536
|
+
h('button', { type: 'button', style: S.btnGhost, onClick: onCancel }, cancelText),
|
|
537
|
+
h('button', { type: 'button', style: S.btnPrimary, onClick: submit }, confirmText))))
|
|
538
|
+
}
|
|
539
|
+
|
|
475
540
|
function LibraryTree(props) {
|
|
476
541
|
const { layout, uiWorkspace, sidebarRight, wide, expandSidebar } = props
|
|
477
542
|
const [lib, setLib] = useState(null)
|
|
@@ -498,11 +563,15 @@ window.__ModuleLoader__.load({
|
|
|
498
563
|
|
|
499
564
|
// 打开 PDF 页签:等 rightbar seat 绑定到目标会话后只调一次 openTab(成功即停,
|
|
500
565
|
// 不再轮询——否则用户关页签会被后续 tick 重开,「关很多次才能关掉」)。
|
|
566
|
+
// 「在屏会话」的读法两代不同:0.1.x 是 sidebarRight.binding.sessionId,
|
|
567
|
+
// 0.2.0 起没有 binding,换成 mounted store(快照=在屏 sessionId)——
|
|
568
|
+
// 只认旧的会把新版的页签轮询 30 次全部耗尽,PDF 永远开不出来(issue #1 实测)。
|
|
569
|
+
const onScreenSession = (sr) => sr.binding?.sessionId ?? sr.mounted?.getSnapshot?.()
|
|
501
570
|
const openPdfTab = useCallback((sessionId) => {
|
|
502
571
|
let tries = 30
|
|
503
572
|
const tick = () => {
|
|
504
573
|
try {
|
|
505
|
-
if (sidebarRight
|
|
574
|
+
if (onScreenSession(sidebarRight) !== sessionId) throw new Error('seat-not-bound')
|
|
506
575
|
sidebarRight.openTab('dpr-reader', { revealIfOpened: true })
|
|
507
576
|
} catch {
|
|
508
577
|
// 快轮询:seat 重绑一完成就开页签,把"面板空白"窗口压到最短
|
|
@@ -519,7 +588,7 @@ window.__ModuleLoader__.load({
|
|
|
519
588
|
const tick = () => {
|
|
520
589
|
try {
|
|
521
590
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
522
|
-
if (snap
|
|
591
|
+
if (currentSessionOf(snap) === sessionId) return // 已是当前会话
|
|
523
592
|
const listed = !!(snap && (snap.byId?.[sessionId] || snap.ids?.includes(sessionId)))
|
|
524
593
|
if (listed) uiWorkspace.openSession(sessionId)
|
|
525
594
|
if (--tries > 0) setTimeout(tick, 300)
|
|
@@ -559,7 +628,7 @@ window.__ModuleLoader__.load({
|
|
|
559
628
|
// 离开的若是空白草稿(点了新建又没问任何问题)→ 后台归档,不留在会话列表里
|
|
560
629
|
try {
|
|
561
630
|
const snap = uiWorkspace.sessions?.list?.getSnapshot?.()
|
|
562
|
-
const leaving = snap
|
|
631
|
+
const leaving = currentSessionOf(snap)
|
|
563
632
|
if (leaving && typeof leaving === 'string' && leaving.startsWith('dpr-') && leaving !== target.sessionId
|
|
564
633
|
&& snap?.byId?.[leaving]?.blank && !snap.byId[leaving].running) {
|
|
565
634
|
api('/sessions/archive', {
|
|
@@ -609,15 +678,19 @@ window.__ModuleLoader__.load({
|
|
|
609
678
|
loadSessions(topic, name) // 后台对账:让 host 的空白过滤把被顶替的旧空白会话收掉
|
|
610
679
|
}, [activate, loadSessions])
|
|
611
680
|
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
681
|
+
const [askTopic, setAskTopic] = useState(false)
|
|
682
|
+
const createTopic = useCallback(async (raw) => {
|
|
683
|
+
const name = raw?.trim()
|
|
684
|
+
if (!name) return
|
|
615
685
|
await api('/library/topic', {
|
|
616
686
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
617
|
-
body: JSON.stringify({ name
|
|
687
|
+
body: JSON.stringify({ name }),
|
|
618
688
|
})
|
|
619
689
|
refresh()
|
|
620
690
|
}, [refresh])
|
|
691
|
+
// 新建专题:window.prompt 在 Electron 渲染进程不可用(抛 "prompt() is not
|
|
692
|
+
// supported",点击无任何反应),改为 AskText 页内弹窗(见 LibraryTree return)。
|
|
693
|
+
const newTopic = useCallback(() => setAskTopic(true), [])
|
|
621
694
|
|
|
622
695
|
const upload = useCallback((topic) => {
|
|
623
696
|
uploadTopic.current = topic
|
|
@@ -638,13 +711,30 @@ window.__ModuleLoader__.load({
|
|
|
638
711
|
const body = await r.json().catch(() => ({}))
|
|
639
712
|
throw new Error(body.error || body.reason || ('HTTP ' + r.status))
|
|
640
713
|
}
|
|
641
|
-
}).catch((err) =>
|
|
714
|
+
}).catch((err) => toast('上传失败: ' + err.message))
|
|
642
715
|
refresh()
|
|
643
716
|
}, [refresh])
|
|
644
717
|
|
|
645
718
|
const topics = lib?.topics ?? []
|
|
646
719
|
const papers = lib?.papers ?? []
|
|
647
720
|
|
|
721
|
+
// 头部「⬆」与空库按钮共用:定位目标专题,没有专题时先自动建「默认专题」
|
|
722
|
+
// (宿主端 mkdir recursive,重名无害)再弹文件选择——空库不再是无入口的死路。
|
|
723
|
+
const uploadSmart = useCallback(async () => {
|
|
724
|
+
let topic = cur?.topic || topics[0]
|
|
725
|
+
if (!topic) {
|
|
726
|
+
topic = t('defaultTopic')
|
|
727
|
+
try {
|
|
728
|
+
await api('/library/topic', {
|
|
729
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
730
|
+
body: JSON.stringify({ name: topic }),
|
|
731
|
+
})
|
|
732
|
+
} catch { /* 已存在等情形忽略,继续走上传 */ }
|
|
733
|
+
refresh()
|
|
734
|
+
}
|
|
735
|
+
upload(topic)
|
|
736
|
+
}, [cur, topics, upload, refresh])
|
|
737
|
+
|
|
648
738
|
// 对话头部按钮 → 树的既有动作(enter/newChat 闭包在这里)
|
|
649
739
|
useEffect(() => {
|
|
650
740
|
const onEnter = (e) => enter(e.detail.topic, e.detail.name, e.detail.n)
|
|
@@ -669,10 +759,15 @@ window.__ModuleLoader__.load({
|
|
|
669
759
|
: h(React.Fragment, null,
|
|
670
760
|
h('div', { style: S.head },
|
|
671
761
|
h('span', { style: { flex: 1 } }, t('library')),
|
|
762
|
+
h('button', { style: S.iconBtn, title: t('upload'), onClick: () => uploadSmart() }, '⬆'),
|
|
672
763
|
h('button', { style: S.iconBtn, title: t('newTopic'), onClick: newTopic }, '+'),
|
|
673
764
|
h('button', { style: S.iconBtn, title: 'refresh', onClick: refresh }, '⟳'),
|
|
674
765
|
),
|
|
675
|
-
lib && topics.length === 0 && h('div', { style: S.empty },
|
|
766
|
+
lib && topics.length === 0 && h('div', { style: S.empty },
|
|
767
|
+
`${t('empty')}:${t('hint')}`,
|
|
768
|
+
h('div', { style: { display: 'flex', gap: '8px', margin: '8px 0 2px' } },
|
|
769
|
+
h('button', { type: 'button', style: S.linkBtn, onClick: () => uploadSmart() }, '⬆ ', t('uploadBtn')),
|
|
770
|
+
h('button', { type: 'button', style: S.linkBtn, onClick: newTopic }, '+ ', t('newTopic')))),
|
|
676
771
|
topics.map((topic) => {
|
|
677
772
|
const isClosed = !!closed[topic]
|
|
678
773
|
return h('div', { key: topic },
|
|
@@ -711,6 +806,16 @@ window.__ModuleLoader__.load({
|
|
|
711
806
|
// 阅读器 iframe 与对话头部按钮组已迁往 shell.overlay 常驻注册(见 applyPaperMode 下方)——
|
|
712
807
|
// 它们不属于侧栏,更不能跟着「退出伴读模式」被卸载(已打开的 PDF 页签要继续活着)
|
|
713
808
|
tree,
|
|
809
|
+
askTopic && createPortal(
|
|
810
|
+
h(AskText, {
|
|
811
|
+
title: t('newTopic') + ':', confirmText: t('ok'), cancelText: t('cancel'),
|
|
812
|
+
onConfirm: (name) => {
|
|
813
|
+
setAskTopic(false)
|
|
814
|
+
createTopic(name).catch((err) => toast(t('newTopicFail') + ': ' + err.message))
|
|
815
|
+
},
|
|
816
|
+
onCancel: () => setAskTopic(false),
|
|
817
|
+
}),
|
|
818
|
+
document.body),
|
|
714
819
|
)
|
|
715
820
|
}
|
|
716
821
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggboy123/dsh-paper-reader",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsc -p tsconfig.json",
|
|
47
47
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"smoke:engine": "node scripts/engine-smoke.mjs",
|
|
48
49
|
"prepublishOnly": "tsc -p tsconfig.json"
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
},
|
|
71
72
|
"peerDependencies": {
|
|
72
73
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
73
|
-
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 <0.
|
|
74
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 <0.3.0-0"
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
77
|
"@deepseek-ai/cordis": "^4.0.2",
|
package/reader/index.html
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>论文伴读 · dsh-paper-reader</title>
|
|
7
|
-
<!-- pdf.js 6.x 本地 vendor(不再走 CDN):3.11 的字体解析对 babeldoc 嵌入的 CID
|
|
8
|
-
<script type="module"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
pdfjsLib.GlobalWorkerOptions.workerSrc = '/paper-reader/vendor/pdf.worker.min.mjs';
|
|
12
|
-
</script>
|
|
7
|
+
<!-- pdf.js 6.x 本地 vendor(不再走 CDN):3.11 的字体解析对 babeldoc 嵌入的 CID 字体会回退成黑体。
|
|
8
|
+
不用 <script type="module">:module 是 defer 语义(整页解析完 + 458KB 拉取编译完才执行),
|
|
9
|
+
而底部经典脚本在解析期就开跑 bootstrap(),库里有论文时先到 loadPdf,此刻 window.pdfjsLib
|
|
10
|
+
还没就绪 → "pdfjsLib is not defined"。改为经典脚本里动态 import()(见 ensurePdfjs),顺序有保证。 -->
|
|
13
11
|
<style>
|
|
14
12
|
:root {
|
|
15
13
|
--bg: #f6f7f9; --panel: #ffffff; --border: #e2e5ea; --text: #24292f;
|
|
@@ -398,7 +396,27 @@ $('cfg-save').onclick = async () => {
|
|
|
398
396
|
};
|
|
399
397
|
|
|
400
398
|
/* ================= PDF 渲染(pdf.js:缩放 + devicePixelRatio 高清,移植自 pdfqa) ================= */
|
|
401
|
-
/*
|
|
399
|
+
/* pdf.js 6.x 本地 vendor:动态 import(缓存的 Promise),loadPdf 先 await 再用——
|
|
400
|
+
底部经典脚本解析期就开跑 bootstrap(),必须由这里保证「pdf.js 就绪后才渲染」;
|
|
401
|
+
失败(vendor 404 / MIME 拒绝)抛带 URL 的可读错误,而不是莫名其妙的 ReferenceError。 */
|
|
402
|
+
let pdfjsReadyP = null;
|
|
403
|
+
function ensurePdfjs() {
|
|
404
|
+
if (!pdfjsReadyP) {
|
|
405
|
+
pdfjsReadyP = import('/paper-reader/vendor/pdf.min.mjs').then(
|
|
406
|
+
(m) => {
|
|
407
|
+
window.pdfjsLib = m;
|
|
408
|
+
m.GlobalWorkerOptions.workerSrc = '/paper-reader/vendor/pdf.worker.min.mjs';
|
|
409
|
+
return m;
|
|
410
|
+
},
|
|
411
|
+
(e) => {
|
|
412
|
+
pdfjsReadyP = null; // 失败后清缓存:瞬时故障不至于让本页签永久坏掉,下次 loadPdf 重试
|
|
413
|
+
throw new Error('pdf.js 加载失败(/paper-reader/vendor/pdf.min.mjs):' + (e && e.message ? e.message : e));
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return pdfjsReadyP;
|
|
418
|
+
}
|
|
419
|
+
ensurePdfjs().catch(() => {}); // 预热:与 bootstrap 的 API 请求并行拉取,不把首屏串行化
|
|
402
420
|
|
|
403
421
|
let pdfDoc = null;
|
|
404
422
|
let zoomScale = 1.5;
|
|
@@ -416,6 +434,7 @@ let renderToken = 0;
|
|
|
416
434
|
})();
|
|
417
435
|
|
|
418
436
|
async function loadPdf(url) {
|
|
437
|
+
await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
|
|
419
438
|
pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
|
|
420
439
|
$('empty').style.display = 'none';
|
|
421
440
|
if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
|