@ggboy123/dsh-paper-reader 1.4.1 → 1.5.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 +34 -100
- package/dist/babeldoc-install.js +6 -2
- package/dist/formulas.js +87 -14
- package/dist/host.js +107 -3
- package/dist/mineru.js +5 -2
- package/dist/search.js +11 -3
- package/dist/translate-config.js +4 -1
- package/dist/translate-selection.js +772 -0
- package/lib/client.js +70 -0
- package/package.json +2 -1
- package/reader/index.html +1023 -42
- package/reader/vendor/KaTeX_AMS-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Script-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size1-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size2-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size3-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size4-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/reader/vendor/katex.LICENSE +21 -0
- package/reader/vendor/katex.NOTICE.md +26 -0
- package/reader/vendor/katex.min.css +1 -0
- package/reader/vendor/katex.min.js +1 -0
package/reader/index.html
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
不用 <script type="module">:module 是 defer 语义(整页解析完 + 458KB 拉取编译完才执行),
|
|
9
9
|
而底部经典脚本在解析期就开跑 bootstrap(),库里有论文时先到 loadPdf,此刻 window.pdfjsLib
|
|
10
10
|
还没就绪 → "pdfjsLib is not defined"。改为经典脚本里动态 import()(见 ensurePdfjs),顺序有保证。 -->
|
|
11
|
+
<!-- KaTeX 0.16.22 本地 vendor(数学渲染;论文伴读是**离线场景**,绝不引 CDN)。
|
|
12
|
+
经典脚本:页面解析期同步设置 window.katex;缺失时渲染路径会**降级**为原始 LaTeX(见 trRenderBody)。
|
|
13
|
+
体积与来源见 reader/vendor/katex.NOTICE.md。 -->
|
|
14
|
+
<link rel="stylesheet" href="/paper-reader/vendor/katex.min.css">
|
|
15
|
+
<script src="/paper-reader/vendor/katex.min.js"></script>
|
|
11
16
|
<style>
|
|
12
17
|
:root {
|
|
13
18
|
--bg: #f6f7f9; --panel: #ffffff; --border: #e2e5ea; --text: #24292f;
|
|
@@ -48,12 +53,22 @@
|
|
|
48
53
|
@media (max-width: 560px) { #ver { display: none; } }
|
|
49
54
|
|
|
50
55
|
/* ---------- PDF 区 ---------- */
|
|
56
|
+
/* #split-wrap 是分屏(模式 B)的两列行容器:非分屏时只有原文列,行为与改造前一致。
|
|
57
|
+
#pdf-pane 从 #main 的直接子节点变成它的子节点——#main 的 flex 语义不变。 */
|
|
51
58
|
#main { display: flex; flex: 1; min-height: 0; position: relative; } /* relative:公式面板绝对定位的锚 */
|
|
52
|
-
#
|
|
59
|
+
#split-wrap { display: flex; flex-direction: row; gap: 8px; flex: 1; min-width: 0; }
|
|
60
|
+
#pdf-pane { flex: 1 1 0; min-width: 0; overflow: auto; padding: 16px; }
|
|
61
|
+
/* 中文列(仅分屏出现):与原文列同权重、各自滚动 */
|
|
62
|
+
#pdf-pane-zh { flex: 1 1 0; min-width: 0; overflow: auto; padding: 16px; display: none; }
|
|
63
|
+
#split-wrap.on > #pdf-pane-zh { display: block; }
|
|
64
|
+
.pane-label { display: none; position: sticky; top: -16px; z-index: 3; background: var(--bg); color: var(--text-2);
|
|
65
|
+
font-size: 12px; font-weight: 600; padding: 2px 0 6px; }
|
|
66
|
+
#split-wrap.on .pane-label { display: block; }
|
|
53
67
|
/* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
|
|
54
68
|
#pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
69
|
+
#pdf-pages-zh { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
|
|
55
70
|
/* 翻页模式:单页垂直居中(safe center 同理,放大后顶部仍可滚到) */
|
|
56
|
-
#pdf-pages.paged { min-height: 100%; justify-content: safe center; gap: 0; }
|
|
71
|
+
#pdf-pages.paged, #pdf-pages-zh.paged { min-height: 100%; justify-content: safe center; gap: 0; }
|
|
57
72
|
|
|
58
73
|
/* ---------- 翻页控件 ---------- */
|
|
59
74
|
#pager { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
@@ -127,6 +142,25 @@
|
|
|
127
142
|
#ask-send { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; }
|
|
128
143
|
#ask-send:disabled { opacity: .5; cursor: default; }
|
|
129
144
|
#ask-cancel { background: none; border: none; color: var(--text-2); cursor: pointer; }
|
|
145
|
+
/* 「译」动作按钮(加法:只在原文视图的纯文字选区出现,见 showAskPop) */
|
|
146
|
+
#ask-translate { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--accent); cursor: pointer; }
|
|
147
|
+
|
|
148
|
+
/* ---------- 划词译文气泡 ----------
|
|
149
|
+
用户明确要求:**只显示译文**,不做「插入会话」「复制」按钮 ⇒ 气泡内部零 button 元素。
|
|
150
|
+
提示(诚实降级文案)走独立的 #tr-note,绝不混进 #tr-body。 */
|
|
151
|
+
#tr-pop { position: fixed; z-index: 50; display: none; background: var(--panel); border: 1px solid var(--border);
|
|
152
|
+
border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px;
|
|
153
|
+
width: min(360px, calc(100vw - 16px)); max-height: 45vh; overflow: auto; }
|
|
154
|
+
#tr-status { font-size: 12px; color: var(--text-2); }
|
|
155
|
+
#tr-status:empty { display: none; }
|
|
156
|
+
#tr-body { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
|
|
157
|
+
#tr-note { font-size: 12px; color: var(--text-2); margin-top: 6px; line-height: 1.5; }
|
|
158
|
+
#tr-note:empty { display: none; }
|
|
159
|
+
/* 公式段:KaTeX 渲染进我们新建的元素(.tr-math / .tr-math-block);渲染失败时该段落回原始 LaTeX
|
|
160
|
+
文本并加 .tr-math-fallback(可读可复制,绝不留白)。 */
|
|
161
|
+
#tr-body .tr-math-block { margin: 6px 0; overflow-x: auto; }
|
|
162
|
+
#tr-body .tr-math-block .katex-display { margin: 0; }
|
|
163
|
+
#tr-body .tr-math-fallback { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--text-2); word-break: break-all; }
|
|
130
164
|
|
|
131
165
|
/* ---------- 翻译端点设置浮层 ---------- */
|
|
132
166
|
/* 阅读器在窄 iframe 里,宽度要能收缩 */
|
|
@@ -151,6 +185,12 @@
|
|
|
151
185
|
<span id="status">论文伴读</span>
|
|
152
186
|
<button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
|
|
153
187
|
<button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none" data-icon="translate"></button>
|
|
188
|
+
<button id="dual-toggle" class="tbtn" title="用 babeldoc 的中英对照 PDF(模式 A)" style="display:none" data-icon="columns">对照</button>
|
|
189
|
+
<button id="split-toggle" class="tbtn" title="原文与中文版并排同步滚动(模式 B)" style="display:none" data-icon="split">分屏</button>
|
|
190
|
+
<!-- 「同步滚动」开关(模式 B 的全局偏好):分屏控件旁,只在分屏下可见(模式 A/原文/译文都没有两个滚动条)。
|
|
191
|
+
状态换图标(sync / syncOff,与 mode-toggle 的 paged/scroll 同法)+ title 里 zh/en 说明状态;
|
|
192
|
+
与设置面板里的同一项共用 dpr.syncScroll 键、互相同步。 -->
|
|
193
|
+
<button id="sync-toggle" class="tbtn" title="同步滚动 / Sync scroll:开(两列一起滚,点击关闭)" style="display:none" data-icon="sync"></button>
|
|
154
194
|
<button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none" data-icon="regen"></button>
|
|
155
195
|
<button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none" data-icon="download"></button>
|
|
156
196
|
<span class="spacer"></span>
|
|
@@ -171,9 +211,16 @@
|
|
|
171
211
|
</div>
|
|
172
212
|
|
|
173
213
|
<div id="main">
|
|
174
|
-
<div id="
|
|
175
|
-
<div id="pdf-
|
|
176
|
-
|
|
214
|
+
<div id="split-wrap">
|
|
215
|
+
<div id="pdf-pane">
|
|
216
|
+
<div id="pane-label" class="pane-label">原文</div>
|
|
217
|
+
<div id="pdf-pages"></div>
|
|
218
|
+
<div id="empty" style="display:none"></div>
|
|
219
|
+
</div>
|
|
220
|
+
<div id="pdf-pane-zh">
|
|
221
|
+
<div id="pane-label-zh" class="pane-label">中文</div>
|
|
222
|
+
<div id="pdf-pages-zh"></div>
|
|
223
|
+
</div>
|
|
177
224
|
</div>
|
|
178
225
|
<div id="eq-panel">
|
|
179
226
|
<h3>公式</h3>
|
|
@@ -187,11 +234,19 @@
|
|
|
187
234
|
<textarea id="ask-input" rows="2" placeholder="围绕选中内容提问…"></textarea>
|
|
188
235
|
<div id="ask-actions">
|
|
189
236
|
<button id="ask-copy" style="display:none">复制 LaTeX</button>
|
|
237
|
+
<button id="ask-translate" style="display:none" title="翻译选中内容(含公式时用 MinerU 的 LaTeX 还原)">译</button>
|
|
190
238
|
<button id="ask-cancel">取消</button>
|
|
191
239
|
<button id="ask-send">提问</button>
|
|
192
240
|
</div>
|
|
193
241
|
</div>
|
|
194
242
|
|
|
243
|
+
<!-- 划词译文气泡:只有译文 + 状态行 + 一行诚实提示,**零按钮**(用户明确不要插入会话/复制) -->
|
|
244
|
+
<div id="tr-pop">
|
|
245
|
+
<div id="tr-status"></div>
|
|
246
|
+
<div id="tr-body"></div>
|
|
247
|
+
<div id="tr-note"></div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
195
250
|
<div id="cfg-pop">
|
|
196
251
|
<h3>翻译端点</h3>
|
|
197
252
|
<div id="cfg-hint">选预设供应商,或选「自定义」填任意 OpenAI 兼容端点(通义 / 本地 vLLM 等)。保存前会先测试连接。</div>
|
|
@@ -232,6 +287,10 @@ const ICONS = {
|
|
|
232
287
|
minus: '<path d="M5 12h14"/>',
|
|
233
288
|
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
|
234
289
|
sigma: '<path d="M18 7V5H6l6 7-6 7h12v-2"/>',
|
|
290
|
+
columns: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/>',
|
|
291
|
+
split: '<rect width="8" height="18" x="3" y="3" rx="2"/><rect width="8" height="18" x="13" y="3" rx="2"/>',
|
|
292
|
+
sync: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
|
|
293
|
+
syncOff: '<path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71"/><path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71"/><line x1="8" x2="8" y1="2" y2="5"/><line x1="2" x2="5" y1="8" y2="8"/><line x1="16" x2="16" y1="19" y2="22"/><line x1="19" x2="22" y1="16" y2="16"/>',
|
|
235
294
|
spin: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
|
236
295
|
};
|
|
237
296
|
const iconHtml = (name) => `<svg class="ic${name === 'spin' ? ' spin' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
@@ -244,6 +303,9 @@ const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) =>
|
|
|
244
303
|
const err = new Error(body.error || body.reason || ('HTTP ' + r.status));
|
|
245
304
|
err.code = body.code; // 机器可读码(如 no-endpoint)——让调用方靠它分支,别去匹配中文文案
|
|
246
305
|
err.status = r.status;
|
|
306
|
+
// 划词翻译的 502 带上游状态码:客户端据此出 §3.7② 的分档文案(拒绝/限流/端点错误),
|
|
307
|
+
// 而不是去 match 服务端的自由文案。
|
|
308
|
+
err.upstreamStatus = body.upstreamStatus;
|
|
247
309
|
throw err;
|
|
248
310
|
}
|
|
249
311
|
return r.json();
|
|
@@ -264,7 +326,11 @@ $('pdf-path').onclick = async () => {
|
|
|
264
326
|
let current = null; // { topic, name }
|
|
265
327
|
let zhState = { zh: false, dual: false, busy: false };
|
|
266
328
|
let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它
|
|
267
|
-
let zhVariant = null; // null=原文 'dual'=中英对照 'zh'
|
|
329
|
+
let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文(**分屏 split 也是 null**,见 C17)
|
|
330
|
+
// 视图模式:四个控件(中/对照/分屏)挂在同一个状态上,互斥(§4.3)。
|
|
331
|
+
// 'split'(模式 B)刻意让 zhVariant 保持 null —— 这样 eqHotEnabled()/公式面板文案/fitMode
|
|
332
|
+
// 等既有分支继续按「原文」工作,零改动。
|
|
333
|
+
let viewMode = 'orig'; // 'orig' | 'zh' | 'dual' | 'split'
|
|
268
334
|
// 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
|
|
269
335
|
// 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
|
|
270
336
|
const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
|
|
@@ -283,6 +349,10 @@ function pdfUrl(variant) {
|
|
|
283
349
|
async function openPaper(topic, name, opts = {}) {
|
|
284
350
|
if (!name) return;
|
|
285
351
|
current = { topic, name };
|
|
352
|
+
// R-01/N-01:换文献的在飞译文必须**立刻**失效——必须放在本函数第一处 await 之前。
|
|
353
|
+
// 原来它在三次 await(`/api/paper`、`refreshZhState`、`loadFormulas`)之后,落在那几个窗口里
|
|
354
|
+
// 到达的旧响应仍会点亮气泡(t8 复审 N-01:P-A3 实测 seq 2→3 时气泡已显示上一篇译文)。
|
|
355
|
+
invalidateTranslate();
|
|
286
356
|
localStorage.setItem('dpr.last', JSON.stringify(current));
|
|
287
357
|
curPage = opts.page || 1; // 换论文从第一页(或跳转目标页)开始;变体切换/重译不走这里,页码保持
|
|
288
358
|
$('status').textContent = '加载 PDF…';
|
|
@@ -298,17 +368,28 @@ async function openPaper(topic, name, opts = {}) {
|
|
|
298
368
|
pathBtn.title = (meta.pdfPath || '') + '(点击复制)';
|
|
299
369
|
pathBtn.style.display = meta.pdfPath ? '' : 'none';
|
|
300
370
|
await refreshZhState();
|
|
301
|
-
//
|
|
371
|
+
// 恢复该论文上次的视图模式(持久化复用同一个键,取值域扩到 'split';见 §4.3)
|
|
302
372
|
const saved = localStorage.getItem(variantKey(topic, name));
|
|
303
|
-
|
|
373
|
+
viewMode = viewModeFromSaved(saved, zhState);
|
|
374
|
+
if (saved && viewMode === 'orig') localStorage.setItem(variantKey(topic, name), ''); // 非空但不可用 → 回写空(防脏值)
|
|
375
|
+
zhVariant = viewMode === 'zh' ? 'zh' : viewMode === 'dual' ? 'dual' : null; // split → null
|
|
304
376
|
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
305
|
-
|
|
377
|
+
// 译文版页宽与原文不同(对照双栏≈2×),分屏每列只有一半宽 ⇒ 都先进适宽
|
|
378
|
+
if (zhVariant || viewMode === 'split') { fitMode = true; fitBtn.classList.add('active'); }
|
|
306
379
|
// 公式索引(MinerU content_list):**必须在 loadPdf 之前到货**——热区是在 paintPage 里
|
|
307
380
|
// 按 eqIndex 铺的,先渲染会漏画/残留上一篇的矩形;又必须排在 zhVariant 定稿之后——
|
|
308
381
|
// eqHotEnabled()/renderEqPanel() 都按 zhVariant 决定「译文视图关热区」。失败不影响打开文献。
|
|
309
382
|
await loadFormulas();
|
|
310
383
|
updateDlBtn();
|
|
311
|
-
|
|
384
|
+
updateViewButtons();
|
|
385
|
+
if (viewMode === 'split') {
|
|
386
|
+
await openSplit(); // 内部自己拉原文 + -zh.pdf 两个文档
|
|
387
|
+
} else {
|
|
388
|
+
// R-02:原地换文献时**无条件**退掉分屏(幂等)——否则新论文有 -zh.pdf、而持久化视图又不是
|
|
389
|
+
// split 时,refreshZhState 的回落守卫不触发,中文列会留着**上一篇**的 PDF(pdfDocZh 也不释放)。
|
|
390
|
+
teardownSplit();
|
|
391
|
+
await loadPdf(pdfUrl(zhVariant));
|
|
392
|
+
}
|
|
312
393
|
if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
|
|
313
394
|
}
|
|
314
395
|
|
|
@@ -357,25 +438,107 @@ async function refreshZhState() {
|
|
|
357
438
|
const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
|
|
358
439
|
regen.style.display = canRegen ? '' : 'none';
|
|
359
440
|
if (!canRegen) disarmRegen();
|
|
441
|
+
// 分屏激活中 -zh.pdf 没了(重翻/外部删除)→ 自动回落原文 + toast + 把持久化写回 ''(§4.2/B8)
|
|
442
|
+
if (viewMode === 'split' && !zhState.zh) {
|
|
443
|
+
toast('中文版不存在,已退回原文', 4000);
|
|
444
|
+
await setViewMode('orig');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
updateViewButtons();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 顶栏三个视图控件的状态机(§4.3):文案/active/disabled/title 全在这一处决定。 */
|
|
451
|
+
function updateViewButtons() {
|
|
452
|
+
const zt = $('zh-toggle'), dt = $('dual-toggle'), st = $('split-toggle');
|
|
453
|
+
const hasZh = zhState.zh, hasDual = zhState.dual, hasAny = hasZh || hasDual;
|
|
454
|
+
const splitOn = viewMode === 'split';
|
|
455
|
+
// 「中」的语义**不变**:原文 ↔ (有 zh ? 'zh' : 'dual');分屏时加闸(避免在 zhVariant===null
|
|
456
|
+
// 的误解下误开生成流程),其余情况与改造前逐字一致。
|
|
457
|
+
zt.disabled = splitOn || zhState.busy;
|
|
458
|
+
zt.title = splitOn ? '分屏对照中:先关闭分屏'
|
|
459
|
+
: zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
|
|
460
|
+
: hasAny ? '切换中文译文'
|
|
461
|
+
: '还没有中文版,点击生成(babeldoc,需要几分钟)';
|
|
462
|
+
zt.classList.toggle('active', zhVariant !== null);
|
|
463
|
+
|
|
464
|
+
dt.style.display = ''; // D1(队长裁定):按钮恒可见,无产物时 disabled + title 说明原因,
|
|
465
|
+
// 把死控件变成解释(与 §6 B1 字面一致,也与 zhonly/dualonly 两态一致)
|
|
466
|
+
dt.classList.toggle('active', viewMode === 'dual');
|
|
467
|
+
dt.disabled = zhState.busy || !hasDual;
|
|
468
|
+
dt.title = zhState.busy ? (zhState.phase || '翻译进行中…')
|
|
469
|
+
: !hasDual ? '还没有中英对照版,先用「中」生成(babeldoc,需要几分钟)'
|
|
470
|
+
: viewMode === 'dual' ? '当前:中英对照(模式 A),点击回到原文'
|
|
471
|
+
: '用 babeldoc 的中英对照 PDF(模式 A)';
|
|
472
|
+
|
|
473
|
+
st.style.display = ''; // D1:同上
|
|
474
|
+
st.classList.toggle('active', splitOn);
|
|
475
|
+
st.disabled = zhState.busy || !hasZh;
|
|
476
|
+
st.title = zhState.busy ? (zhState.phase || '翻译进行中…')
|
|
477
|
+
: !hasZh ? (hasDual ? '需先生成中文版(模式 B 用 -zh.pdf 与原文并排)' : '还没有中文版,先用「中」生成(babeldoc,需要几分钟)')
|
|
478
|
+
: splitOn ? '当前:分屏对照(模式 B),点击回到原文'
|
|
479
|
+
: '原文与中文版并排同步滚动(模式 B)';
|
|
480
|
+
|
|
481
|
+
// 「同步滚动」开关:与三个视图控件同一个状态机(一处决定所有按钮的可见性/文案/态);
|
|
482
|
+
// 仅分屏下可见 —— 模式 A/原文/译文只有一个滚动条,开关无意义
|
|
483
|
+
updateSyncToggle();
|
|
360
484
|
}
|
|
361
485
|
|
|
362
486
|
$('zh-toggle').onclick = async () => {
|
|
363
|
-
if (!current || zhState.busy) return;
|
|
487
|
+
if (!current || zhState.busy || viewMode === 'split') return;
|
|
364
488
|
if (zhState.dual || zhState.zh) {
|
|
365
|
-
// 两态切换:原文 ↔
|
|
366
|
-
|
|
367
|
-
localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
|
|
368
|
-
$('zh-toggle').classList.toggle('active', zhVariant !== null);
|
|
369
|
-
updateDlBtn();
|
|
370
|
-
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
|
|
371
|
-
fitMode = true; fitBtn.classList.add('active');
|
|
372
|
-
renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉)
|
|
373
|
-
await loadPdf(pdfUrl(zhVariant));
|
|
489
|
+
// 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照(既有语义一字不动)
|
|
490
|
+
await setViewMode(zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : 'orig');
|
|
374
491
|
return;
|
|
375
492
|
}
|
|
376
493
|
await startGenerate(false);
|
|
377
494
|
};
|
|
378
495
|
|
|
496
|
+
/* ================= 视图模式(模式 A 对照 / 模式 B 分屏)=================
|
|
497
|
+
三个控件共用 viewMode;进入任一非 orig 模式都先退出另一个(互斥)。 */
|
|
498
|
+
|
|
499
|
+
/** 持久化复用既有键:lib/client.js 的改名搬家只搬 `dpr.variant.` 前缀,新增键不会被迁移(C19)。 */
|
|
500
|
+
function persistViewMode() {
|
|
501
|
+
localStorage.setItem(variantKey(current.topic, current.name), viewMode === 'orig' ? '' : viewMode);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** 读回持久化的视图模式(§4.3 的取值表):译文/分屏不存在时回落原文,非空不可用的值由调用方回写空。 */
|
|
505
|
+
function viewModeFromSaved(saved, st) {
|
|
506
|
+
if (saved === 'zh' && st.zh) return 'zh';
|
|
507
|
+
if (saved === 'dual' && st.dual) return 'dual';
|
|
508
|
+
if (saved === 'split' && st.zh) return 'split'; // 分屏需要 -zh.pdf
|
|
509
|
+
return 'orig';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 视图模式的状态机(守卫 → 赋值 → 持久化 → 三个按钮/下载按钮/公式面板)。
|
|
513
|
+
* 返回 false = 守卫拦下(状态未变)。渲染留给 setViewMode,便于单测状态机(B11/B12/R4)。 */
|
|
514
|
+
function applyViewMode(mode) {
|
|
515
|
+
if (!current) return false;
|
|
516
|
+
if (mode === 'dual' && !zhState.dual) return false; // 没有 -dual.pdf,不进(按钮已 disabled)
|
|
517
|
+
if (mode === 'split' && !zhState.zh) return false; // 没有 -zh.pdf,不进
|
|
518
|
+
if (mode === 'split' && window.innerWidth < SPLIT_MIN_WIDTH) {
|
|
519
|
+
toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000);
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
viewMode = mode;
|
|
523
|
+
zhVariant = mode === 'zh' ? 'zh' : mode === 'dual' ? 'dual' : null; // split → null(C17)
|
|
524
|
+
persistViewMode();
|
|
525
|
+
// 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×、分屏每列半宽),不重算会溢出裁切
|
|
526
|
+
fitMode = true; fitBtn.classList.add('active');
|
|
527
|
+
updateViewButtons();
|
|
528
|
+
updateDlBtn();
|
|
529
|
+
renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉)
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function setViewMode(mode) {
|
|
534
|
+
if (!applyViewMode(mode)) return;
|
|
535
|
+
if (mode === 'split') { await openSplit(); return; }
|
|
536
|
+
teardownSplit();
|
|
537
|
+
await loadPdf(pdfUrl(zhVariant));
|
|
538
|
+
}
|
|
539
|
+
$('dual-toggle').onclick = () => setViewMode(viewMode === 'dual' ? 'orig' : 'dual');
|
|
540
|
+
$('split-toggle').onclick = () => setViewMode(viewMode === 'split' ? 'orig' : 'split');
|
|
541
|
+
|
|
379
542
|
// 生成/重翻共用:force=true 时服务端先删旧译文再启动;成功后轮询到完成
|
|
380
543
|
async function startGenerate(force) {
|
|
381
544
|
try {
|
|
@@ -400,8 +563,9 @@ async function startGenerate(force) {
|
|
|
400
563
|
clearInterval(timer);
|
|
401
564
|
if (zhState.dual || zhState.zh) {
|
|
402
565
|
toast('中文版已生成');
|
|
403
|
-
//
|
|
404
|
-
if (
|
|
566
|
+
// 重翻完成时用户可能正停在译文视图/分屏:原地刷新,而不是来回切换
|
|
567
|
+
if (viewMode === 'split') await renderSplit();
|
|
568
|
+
else if (zhVariant) await loadPdf(pdfUrl(zhVariant));
|
|
405
569
|
else $('zh-toggle').click();
|
|
406
570
|
}
|
|
407
571
|
else toast('生成失败:' + (zhState.error || '未知原因'), 8000);
|
|
@@ -463,7 +627,8 @@ const providerOf = (baseUrl) => PROVIDERS.find((p) => p.id !== 'custom' && normU
|
|
|
463
627
|
|
|
464
628
|
// 保存成功后是否自动接着生成:点「中译」/「重翻」发现没配 → 弹本表单 → 填完直接开跑
|
|
465
629
|
let cfgRetryGenerate = false;
|
|
466
|
-
let cfgRetryForce = false; //
|
|
630
|
+
let cfgRetryForce = false; // 待重试的是不是「重翻」(regen 图标进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
|
|
631
|
+
let cfgRetryTranslate = false; // 待重试的是不是「划词翻译」(点「译」发现没配端点 → 填完自动重试这次翻译)
|
|
467
632
|
|
|
468
633
|
async function openCfgPop(retryGenerate) {
|
|
469
634
|
cfgRetryGenerate = Boolean(retryGenerate);
|
|
@@ -495,14 +660,14 @@ async function openCfgPop(retryGenerate) {
|
|
|
495
660
|
pop.style.top = Math.max(8, (window.innerHeight - pop.offsetHeight) / 3) + 'px';
|
|
496
661
|
$('cfg-key').focus();
|
|
497
662
|
}
|
|
498
|
-
function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; cfgRetryForce = false; }
|
|
663
|
+
function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; cfgRetryForce = false; cfgRetryTranslate = false; }
|
|
499
664
|
|
|
500
665
|
$('cfg-open').onclick = () => openCfgPop(false);
|
|
501
666
|
$('cfg-cancel').onclick = closeCfgPop;
|
|
502
667
|
|
|
503
668
|
$('cfg-save').onclick = async () => {
|
|
504
|
-
// retry/force 先捞出来:closeCfgPop
|
|
505
|
-
const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate, force = cfgRetryForce;
|
|
669
|
+
// retry/force/translate 先捞出来:closeCfgPop 会把这三个待确认态清零
|
|
670
|
+
const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate, force = cfgRetryForce, retryTr = cfgRetryTranslate;
|
|
506
671
|
btn.disabled = true; btn.textContent = '测试中…';
|
|
507
672
|
try {
|
|
508
673
|
// 服务端先打一发极小的 chat/completions 验证 url/key/model,通过才落盘
|
|
@@ -523,7 +688,8 @@ $('cfg-save').onclick = async () => {
|
|
|
523
688
|
}
|
|
524
689
|
closeCfgPop();
|
|
525
690
|
await refreshZhState();
|
|
526
|
-
if (retry) startGenerate(force); //
|
|
691
|
+
if (retry) startGenerate(force); // 沿用触发来源的语义:zh-toggle=生成,regen=重翻
|
|
692
|
+
else if (retryTr) translateSelection(); // 划词翻译发现没配端点 → 填完自动重试这一次翻译(§3.7①)
|
|
527
693
|
else toast('翻译端点已保存');
|
|
528
694
|
};
|
|
529
695
|
|
|
@@ -565,22 +731,27 @@ let renderToken = 0;
|
|
|
565
731
|
});
|
|
566
732
|
})();
|
|
567
733
|
|
|
568
|
-
async function loadPdf(url) {
|
|
734
|
+
async function loadPdf(url, opts = {}) {
|
|
569
735
|
await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
|
|
570
736
|
pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
|
|
571
737
|
$('empty').style.display = 'none';
|
|
572
738
|
// 翻页控件与模式切换按钮:文档就绪后才出现
|
|
573
739
|
$('pager').style.display = '';
|
|
574
740
|
$('mode-toggle').style.display = '';
|
|
741
|
+
// 分屏(模式 B)自己安排两列的渲染与适宽:这里只把原文文档装上,不触发单列渲染
|
|
742
|
+
if (opts.deferRender) return;
|
|
575
743
|
if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → rerender
|
|
576
744
|
await rerender();
|
|
577
745
|
}
|
|
578
746
|
|
|
579
|
-
/** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。
|
|
580
|
-
|
|
747
|
+
/** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。
|
|
748
|
+
* `doc` = 'orig' | 'zh':**无条件**写在每个页节点上(分屏时两个文档的页号相同,热区几何
|
|
749
|
+
* 与选区归属都靠它判别;只有分屏才写的话普通模式的选择器会取到 null)。 */
|
|
750
|
+
function makePageNode(i, viewport, doc = 'orig') {
|
|
581
751
|
const div = document.createElement('div');
|
|
582
752
|
div.className = 'page';
|
|
583
753
|
div.dataset.page = i;
|
|
754
|
+
div.dataset.doc = doc;
|
|
584
755
|
div.style.width = viewport.width + 'px';
|
|
585
756
|
|
|
586
757
|
const canvas = document.createElement('canvas');
|
|
@@ -625,8 +796,10 @@ async function paintPage(page, div, viewport, token) {
|
|
|
625
796
|
paintEqLayer(div, viewport, +div.dataset.page);
|
|
626
797
|
}
|
|
627
798
|
|
|
628
|
-
/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。
|
|
799
|
+
/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。
|
|
800
|
+
* 分屏(模式 B)是第三套布局,转到 renderSplit();renderPaged/renderAllPages 的既有语义**不变**。 */
|
|
629
801
|
function rerender() {
|
|
802
|
+
if (viewMode === 'split') return renderSplit();
|
|
630
803
|
return pageMode === 'paged' ? renderPaged() : renderAllPages();
|
|
631
804
|
}
|
|
632
805
|
|
|
@@ -709,9 +882,18 @@ let fitMode = true;
|
|
|
709
882
|
const fitBtn = $('zoom-fit');
|
|
710
883
|
fitBtn.classList.add('active');
|
|
711
884
|
function exitFitMode() { fitMode = false; fitBtn.classList.remove('active'); }
|
|
885
|
+
/** 适宽的可用宽度(分屏时按**较窄的那一列**算,保证两列页高一致)。每列 padding 16px×2。 */
|
|
886
|
+
function fitPaneWidth() {
|
|
887
|
+
if (viewMode === 'split') {
|
|
888
|
+
const avail = [$('pdf-pane').clientWidth - 32, $('pdf-pane-zh').clientWidth - 32].filter((w) => w > 0)
|
|
889
|
+
if (!avail.length) return 0
|
|
890
|
+
return Math.min(...avail)
|
|
891
|
+
}
|
|
892
|
+
return $('pdf-pane').clientWidth - 32
|
|
893
|
+
}
|
|
712
894
|
function fitToWidth() {
|
|
713
895
|
if (!pdfDoc) return // resize 可能先于首次 loadPdf 触发(布局换位/条带注入会改容器尺寸)
|
|
714
|
-
const paneWidth =
|
|
896
|
+
const paneWidth = fitPaneWidth()
|
|
715
897
|
if (paneWidth <= 0) return // 隐藏期间尺寸为 0,算出的缩放无意义
|
|
716
898
|
pdfDoc.getPage(1).then((p) => setZoom(paneWidth / p.getViewport({ scale: 1 }).width))
|
|
717
899
|
}
|
|
@@ -719,13 +901,25 @@ $('zoom-in').onclick = () => { exitFitMode(); setZoom(zoomScale + 0.25); };
|
|
|
719
901
|
$('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); };
|
|
720
902
|
fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); };
|
|
721
903
|
window.addEventListener('resize', () => {
|
|
904
|
+
if (viewMode === 'split' && window.innerWidth < SPLIT_MIN_WIDTH) {
|
|
905
|
+
// 分屏被缩到硬闸门以下:回落原文 + 同一提示(§4.2)
|
|
906
|
+
toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000);
|
|
907
|
+
setViewMode('orig');
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
722
910
|
if (fitMode) fitToWidth(); // setZoom → rerender,顺带消化 pendingRerender
|
|
723
911
|
else if (pendingRerender && pdfDoc) { pendingRerender = false; rerender(); } // 隐藏期间欠下的重渲,恢复尺寸后补画
|
|
724
912
|
});
|
|
725
913
|
|
|
914
|
+
/** 原文列的页节点:分屏时中文列有**同样的 page 号**,不加限定会取到中文列(C18/R7)。
|
|
915
|
+
* 统一收敛到 `#pdf-pages` 之下——四个引文高亮/跳转入口共用。 */
|
|
916
|
+
function origPageNode(n) {
|
|
917
|
+
return document.querySelector(`#pdf-pages .page[data-page="${n}"]`);
|
|
918
|
+
}
|
|
919
|
+
|
|
726
920
|
/* ================= 翻页模式:页码控件 + 模式切换 + 键盘 ================= */
|
|
727
921
|
function flashPage() {
|
|
728
|
-
const div =
|
|
922
|
+
const div = origPageNode(curPage);
|
|
729
923
|
if (!div) return;
|
|
730
924
|
div.classList.add('flash');
|
|
731
925
|
setTimeout(() => div.classList.remove('flash'), 1600);
|
|
@@ -748,7 +942,7 @@ async function goPage(n) {
|
|
|
748
942
|
if (pageMode === 'paged') {
|
|
749
943
|
if (p === curPage) { flashPage(); return; }
|
|
750
944
|
curPage = p;
|
|
751
|
-
await
|
|
945
|
+
await rerender();
|
|
752
946
|
flashPage();
|
|
753
947
|
} else {
|
|
754
948
|
curPage = p;
|
|
@@ -789,10 +983,10 @@ $('mode-toggle').onclick = async () => {
|
|
|
789
983
|
localStorage.setItem('dpr.pageMode', pageMode);
|
|
790
984
|
updateModeToggle();
|
|
791
985
|
if (pageMode === 'paged') {
|
|
792
|
-
await
|
|
986
|
+
await rerender(); // 分屏时转 renderSplit(两列同渲 curPage)
|
|
793
987
|
flashPage();
|
|
794
988
|
} else {
|
|
795
|
-
await
|
|
989
|
+
await rerender(); // 非分屏时等价于 renderAllPages
|
|
796
990
|
jumpToPage(curPage);
|
|
797
991
|
}
|
|
798
992
|
};
|
|
@@ -811,7 +1005,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
811
1005
|
|
|
812
1006
|
function jumpToPage(n) {
|
|
813
1007
|
if (pageMode === 'paged') { goPage(n); return; }
|
|
814
|
-
const div =
|
|
1008
|
+
const div = origPageNode(n); // 引文跳转只作用于原文列(分屏时中文列不得被滚到)
|
|
815
1009
|
if (!div) return;
|
|
816
1010
|
div.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
817
1011
|
div.classList.add('flash');
|
|
@@ -829,7 +1023,7 @@ function clearHighlights() {
|
|
|
829
1023
|
document.addEventListener('mousedown', clearHighlights);
|
|
830
1024
|
|
|
831
1025
|
function highlightSnippet(page, q) {
|
|
832
|
-
const spans = [...(
|
|
1026
|
+
const spans = [...(origPageNode(page)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])];
|
|
833
1027
|
if (!spans.length || !q) return false;
|
|
834
1028
|
let hay = '';
|
|
835
1029
|
const startOf = [];
|
|
@@ -862,12 +1056,12 @@ async function jumpTo(page, q) {
|
|
|
862
1056
|
if (pageMode === 'paged') {
|
|
863
1057
|
if (!pdfDoc) return;
|
|
864
1058
|
const p = Math.min(p0, pdfDoc.numPages);
|
|
865
|
-
if (p !== curPage) { curPage = p; await
|
|
1059
|
+
if (p !== curPage) { curPage = p; await rerender(); }
|
|
866
1060
|
if (!(q && highlightSnippet(p, q))) flashPage();
|
|
867
1061
|
return;
|
|
868
1062
|
}
|
|
869
1063
|
for (let i = 0; i < 100; i++) {
|
|
870
|
-
if (pdfDoc &&
|
|
1064
|
+
if (pdfDoc && origPageNode(Math.min(p0, pdfDoc.numPages))) {
|
|
871
1065
|
const p = Math.min(p0, pdfDoc.numPages);
|
|
872
1066
|
if (!(q && highlightSnippet(p, q))) jumpToPage(p);
|
|
873
1067
|
return;
|
|
@@ -962,11 +1156,17 @@ function eqGeom(eq, viewport) {
|
|
|
962
1156
|
};
|
|
963
1157
|
}
|
|
964
1158
|
|
|
965
|
-
/** 为已渲染的一页铺热区层(只画描边,不接事件)。
|
|
1159
|
+
/** 为已渲染的一页铺热区层(只画描边,不接事件)。
|
|
1160
|
+
* **只在原文列铺**:分屏(模式 B)时中文列的页号与原文列相同,而 EQ_BY_PAGE 是全局的,
|
|
1161
|
+
* 照铺会让中文列的点击命中原文几何(C18/R7)。文档作用域从页节点自身读——唯一铺层点的
|
|
1162
|
+
* 调用签名被既有断言钉死为 `paintEqLayer(div, viewport, +div.dataset.page)`
|
|
1163
|
+
* (test/merge-verify-t3.test.mjs 的 checkSolePaintPoint),不能再加第 4 个参数。 */
|
|
966
1164
|
function paintEqLayer(pageDiv, viewport, pageNo) {
|
|
967
1165
|
const old = pageDiv.querySelector('.eqLayer');
|
|
968
1166
|
if (old) old.remove();
|
|
969
1167
|
const items = [];
|
|
1168
|
+
const doc = pageDiv.dataset.doc || 'orig';
|
|
1169
|
+
if (doc !== 'orig') return; // 中文列:不建层、不写 EQ_BY_PAGE
|
|
970
1170
|
if (eqHotEnabled()) {
|
|
971
1171
|
const lay = document.createElement('div');
|
|
972
1172
|
lay.className = 'eqLayer';
|
|
@@ -1003,6 +1203,8 @@ function hitTestFormula(e) {
|
|
|
1003
1203
|
const el = e.target;
|
|
1004
1204
|
const pageDiv = el && el.closest ? el.closest('.page[data-page]') : null;
|
|
1005
1205
|
if (!pageDiv) return null;
|
|
1206
|
+
// 中文列(分屏)不得命中:它的页号与原文列相同,几何却属于原文页面(C18/R7)
|
|
1207
|
+
if (pageDiv.dataset.doc && pageDiv.dataset.doc !== 'orig') return null;
|
|
1006
1208
|
const items = EQ_BY_PAGE.get(+pageDiv.dataset.page);
|
|
1007
1209
|
if (!items || !items.length) return null;
|
|
1008
1210
|
const box = pageDiv.getBoundingClientRect();
|
|
@@ -1184,6 +1386,17 @@ function showAskPop(o) {
|
|
|
1184
1386
|
$('ask-quote').textContent = o.quote.length > 140 ? o.quote.slice(0, 140) + '…' : o.quote;
|
|
1185
1387
|
$('ask-input').value = '';
|
|
1186
1388
|
$('ask-copy').style.display = askSelection && askSelection.kind === 'equation' ? 'inline-block' : 'none';
|
|
1389
|
+
// 「译」只在「纯文字选区 + 原文视图 + **原文列** + 长度可翻译」时出现。
|
|
1390
|
+
// 分屏(模式 B)时 zhVariant 刻意保持 null(C17),中文列也满足那个条件 —— 但中文列的字符
|
|
1391
|
+
// 在 collectSelectionChars 里会被整体跳过(几何只对原文页成立)⇒ chunks 为空、只会弹
|
|
1392
|
+
// 「选区已失效」。所以这里按列禁用(R-07:e.target/doc 判别),不给误导性文案。
|
|
1393
|
+
$('ask-translate').style.display =
|
|
1394
|
+
askSelection && askSelection.kind === 'text' && zhVariant === null
|
|
1395
|
+
&& (askSelection.doc ?? 'orig') === 'orig'
|
|
1396
|
+
&& askSelection.text.length <= MAX_SELECTION_CHARS
|
|
1397
|
+
? 'inline-block' : 'none';
|
|
1398
|
+
hideTrPop(); // 新选区/新引用出现即收起上一个译文气泡(同一锚点必然重叠)
|
|
1399
|
+
invalidateTranslate(); // R-01:新选区/新引用必须让在飞请求失效,否则旧译文会盖到新锚点上
|
|
1187
1400
|
askPop.style.display = 'block';
|
|
1188
1401
|
// 夹在 iframe 视口内(rightbar 窄列里也要能用)
|
|
1189
1402
|
const popW = 336, popH = 200;
|
|
@@ -1213,7 +1426,22 @@ document.addEventListener('mouseup', (e) => {
|
|
|
1213
1426
|
const node = sel.anchorNode && sel.anchorNode.parentElement;
|
|
1214
1427
|
if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; }
|
|
1215
1428
|
const range = sel.getRangeAt(0).getBoundingClientRect();
|
|
1216
|
-
|
|
1429
|
+
// `doc` = 选区落在哪一列('orig' | 'zh'):分屏时中文列也有 .page 与文本层,但它的几何
|
|
1430
|
+
// 对不上原文的 equation 索引,且不该出现「译」(R-07)。无条件写:普通模式恒为 'orig'。
|
|
1431
|
+
const pageEl = node.closest('.page[data-doc]') || node.closest('.page');
|
|
1432
|
+
askSelection = {
|
|
1433
|
+
text,
|
|
1434
|
+
page: +((pageEl || {}).dataset?.page || 0) || null,
|
|
1435
|
+
kind: 'text',
|
|
1436
|
+
doc: (pageEl && pageEl.dataset && pageEl.dataset.doc) || 'orig',
|
|
1437
|
+
};
|
|
1438
|
+
// 选区 Range 必须在**选中时**克隆存下来:用户点「译」时浏览器多半已清掉选区。
|
|
1439
|
+
// 防御:DOM stub / 极端环境下 getRangeAt 可能不返回 Range(没有 cloneRange),此时存 null,
|
|
1440
|
+
// 点「译」时按「页面已重绘,请重新选择」如实提示——绝不静默失败。
|
|
1441
|
+
try {
|
|
1442
|
+
const r0 = sel.getRangeAt(0);
|
|
1443
|
+
if (r0 && typeof r0.cloneRange === 'function') askSelection.range = r0.cloneRange();
|
|
1444
|
+
} catch { /* 选区已失效:保持无 range */ }
|
|
1217
1445
|
showAskPop({ quote: text, x: e.clientX, y: range.bottom + 8 });
|
|
1218
1446
|
}, 10);
|
|
1219
1447
|
});
|
|
@@ -1272,6 +1500,759 @@ $('ask-send').onclick = async () => {
|
|
|
1272
1500
|
};
|
|
1273
1501
|
$('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('ask-send').click(); } });
|
|
1274
1502
|
|
|
1503
|
+
/* =====================================================================================
|
|
1504
|
+
划词翻译(公式保留)—— 契约 docs/selection-translate-requirements.md §3.1–§3.3
|
|
1505
|
+
· 触发:既有 #ask-pop 里新增的「译」按钮 → 隐藏 #ask-pop、弹独立译文气泡 #tr-pop
|
|
1506
|
+
· 气泡**只有译文**(#tr-body)+ 状态行 + 一行诚实提示(#tr-note);内部零 button
|
|
1507
|
+
· 选区切块:display 公式按「字符矩形中心点 ∩ equation 盒」整段丢掉乱码、换成紧凑 LaTeX;
|
|
1508
|
+
行内公式的还原在服务端做(客户端只送 chunk,不送任何矩形/bbox)
|
|
1509
|
+
· 新代码一律放在 $('ask-cancel').onclick **之后**(既有源码切片断言,
|
|
1510
|
+
test/reader-ux-verification.test.mjs VA9)
|
|
1511
|
+
===================================================================================== */
|
|
1512
|
+
const MAX_SELECTION_CHARS = 4000; // 冻结:选区超长截断并提示
|
|
1513
|
+
const MAX_CHUNKS = 200; // 与服务端同一套上限(§3.2-6)
|
|
1514
|
+
const MAX_TOTAL_CHARS = 8000;
|
|
1515
|
+
const TR_CLIENT_TIMEOUT_MS = 45000; // 服务端 30s,客户端 45s 兜底(§3.6)
|
|
1516
|
+
const trPop = $('tr-pop');
|
|
1517
|
+
let trBusy = false;
|
|
1518
|
+
let trSeq = 0; // 过期保护:换选区/换文献后旧译文不得盖上来(仿 renderToken)
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* 让**在飞**的划词翻译请求失效(R-01)。调用点:`showAskPop`(新选区/新引用)与 `openPaper`(换文献)。
|
|
1522
|
+
* 只自增 `trSeq` 会让旧请求的 `finally` 因为 `seq !== trSeq` 而不清 `trBusy`(按钮从此永久失效),
|
|
1523
|
+
* 所以这里一并把 busy 复位;旧响应回来时 `seq` 不匹配 ⇒ 直接丢弃,绝不会写到新锚点的气泡上。
|
|
1524
|
+
*/
|
|
1525
|
+
function invalidateTranslate() {
|
|
1526
|
+
trSeq++;
|
|
1527
|
+
trBusy = false;
|
|
1528
|
+
// 顺手收起气泡并清掉内容:在飞请求回来时会被 seq 拦下,**不会**再写气泡,
|
|
1529
|
+
// 所以留着「翻译中…」或上一篇的译文都是误导(N-01 的窗口里尤其明显)。
|
|
1530
|
+
hideTrPop();
|
|
1531
|
+
$('tr-status').textContent = '';
|
|
1532
|
+
$('tr-body').textContent = '';
|
|
1533
|
+
$('tr-note').textContent = '';
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function hideTrPop() { trPop.style.display = 'none'; }
|
|
1537
|
+
|
|
1538
|
+
/* ── 译文里的公式渲染(vendored KaTeX,离线)────────────────────────────────────────────
|
|
1539
|
+
译文是**模型输出,当不可信数据**处理,所以这里绝不 innerHTML:
|
|
1540
|
+
· 文本段:一律 textContent;
|
|
1541
|
+
· 数学段:KaTeX 渲染进**我们新建的**元素(trust:false → 不产原始 HTML;strict:'ignore');
|
|
1542
|
+
· 渲染失败:该段**降级**为原始 LaTeX 文本(可读可复制),并在 #tr-note 如实提示第几个失败。
|
|
1543
|
+
`splitTranslatedMath` / `trComposeNote` 是纯函数,可在 vm 里单测。 */
|
|
1544
|
+
|
|
1545
|
+
/** 「这段 `$…$` 像不像数学」(与 src/translate-selection.ts 的 `isLatexLikeFragment` 同判据 + 两道收窄)。
|
|
1546
|
+
* 过度识别的代价更大:`$5 到 $10`、`$HOME/bin:$PATH` 这类一旦被当公式,正文会被 KaTeX 吃掉。
|
|
1547
|
+
* 规则(自上而下):
|
|
1548
|
+
* ① 含 LaTeX 特征(`\` `_` `^` `{` `}` `&`)⇒ 像数学;
|
|
1549
|
+
* ② 含空白却没有特征 ⇒ 像散文/金额(`$5 up to $10`)⇒ 不是;
|
|
1550
|
+
* ③ 全大写标识符(`HOME`、`PATH`)⇒ 不是;
|
|
1551
|
+
* ④ `NAME:…` 形态(命令行/环境变量写法)⇒ 不是;
|
|
1552
|
+
* ⑤ 其余短符号串(`x`、`QK`、`a/b`)⇒ 是(上限 24 字符,避免把长路径吃掉)。 */
|
|
1553
|
+
function looksLikeMath(tex) {
|
|
1554
|
+
const t = String(tex);
|
|
1555
|
+
if (!t.trim()) return false;
|
|
1556
|
+
if (/[\\_^{}&]/.test(t)) return true;
|
|
1557
|
+
if (/\s/.test(t)) return false;
|
|
1558
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(t)) return false;
|
|
1559
|
+
if (/^[A-Za-z0-9_./-]+:/.test(t)) return false;
|
|
1560
|
+
return t.length <= 24;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** 把译文切成 [{kind:'text'|'math', text, display, delim}]。
|
|
1564
|
+
* `$$…$$` 独立成行(可跨行);`$…$` 行内(不跨行,避免把整段散文吞进公式);`\$` 是字面美元号;
|
|
1565
|
+
* `\(…\)` 行内、`\[…\]` 独立成行(模型/上游有时给这套定界符,见 pr7 诊断 P2);
|
|
1566
|
+
* 定界符没有闭合、或 `$…$` 内容不像数学时按普通字符处理(绝不产生"半个公式",也不吃正文)。 */
|
|
1567
|
+
function splitTranslatedMath(input) {
|
|
1568
|
+
const s = String(input ?? '');
|
|
1569
|
+
const out = [];
|
|
1570
|
+
let buf = '';
|
|
1571
|
+
const flush = () => { if (buf) { out.push({ kind: 'text', text: buf }); buf = ''; } };
|
|
1572
|
+
for (let i = 0; i < s.length;) {
|
|
1573
|
+
if (s[i] === '\\' && s[i + 1] === '$') { buf += '$'; i += 2; continue; }
|
|
1574
|
+
// \(…\) 行内 / \[…\] 独立成行:与 $ 一样,必须找到闭合才认(否则按字面字符处理)
|
|
1575
|
+
if (s[i] === '\\' && (s[i + 1] === '(' || s[i + 1] === '[')) {
|
|
1576
|
+
const display = s[i + 1] === '[';
|
|
1577
|
+
const closeSeq = display ? '\\]' : '\\)';
|
|
1578
|
+
let j = i + 2;
|
|
1579
|
+
let close = -1;
|
|
1580
|
+
while (j < s.length) {
|
|
1581
|
+
if (s[j] === '\\') {
|
|
1582
|
+
if (s[j + 1] === (display ? ']' : ')')) { close = j; break; }
|
|
1583
|
+
j += 2; continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (!display && s[j] === '\n') break; // 行内不跨行(同 $…$ 的口径)
|
|
1586
|
+
j += 1;
|
|
1587
|
+
}
|
|
1588
|
+
const tex = close === -1 ? '' : s.slice(i + 2, close);
|
|
1589
|
+
if (close !== -1 && tex.trim() && looksLikeMath(tex)) {
|
|
1590
|
+
flush();
|
|
1591
|
+
out.push({ kind: 'math', text: tex, display, delim: closeSeq });
|
|
1592
|
+
i = close + 2;
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
buf += s.slice(i, i + 2); i += 2; continue; // 不闭合/不像数学 → 原样当文本
|
|
1596
|
+
}
|
|
1597
|
+
if (s[i] !== '$') { buf += s[i]; i += 1; continue; }
|
|
1598
|
+
const display = s[i + 1] === '$';
|
|
1599
|
+
const open = display ? 2 : 1;
|
|
1600
|
+
let j = i + open;
|
|
1601
|
+
let close = -1;
|
|
1602
|
+
while (j < s.length) {
|
|
1603
|
+
if (s[j] === '\\') { j += 2; continue; }
|
|
1604
|
+
if (display ? (s[j] === '$' && s[j + 1] === '$') : (s[j] === '$')) { close = j; break; }
|
|
1605
|
+
if (!display && s[j] === '\n') break; // 行内公式不跨行
|
|
1606
|
+
j += 1;
|
|
1607
|
+
}
|
|
1608
|
+
if (close === -1) { buf += s.slice(i, i + open); i += open; continue; }
|
|
1609
|
+
const tex = s.slice(i + open, close);
|
|
1610
|
+
if (!tex.trim() || !looksLikeMath(tex)) { buf += s.slice(i, close + open); i = close + open; continue; }
|
|
1611
|
+
flush();
|
|
1612
|
+
out.push({ kind: 'math', text: tex, display, delim: open === 2 ? '$$' : '$' });
|
|
1613
|
+
i = close + open;
|
|
1614
|
+
}
|
|
1615
|
+
flush();
|
|
1616
|
+
return out;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/* 模式解析:MinerU 的**展示公式**常带 `\tag{}`(KaTeX 硬规则:\tag 只允许展示模式),而服务端
|
|
1620
|
+
`assembleSource` 会把展示公式统一包成**行内 `$…$`** ⇒ 按 `display:false` 渲染必失败(pr7 诊断 P1:
|
|
1621
|
+
35/37 真实 equation 命中)。这里先按切分结果探测,失败再按**另一种模式**探测:
|
|
1622
|
+
只有真的行内渲染不了时才升级成展示模式 ⇒ **零误伤**(不会把普通行内公式变成块级)。
|
|
1623
|
+
两种都失败返回 null,交给调用方走降级(原始 LaTeX + #tr-note,绝不留白)。 */
|
|
1624
|
+
function resolveMathMode(tex, display) {
|
|
1625
|
+
const k = typeof window !== 'undefined' ? window.katex : null;
|
|
1626
|
+
if (!k || typeof k.renderToString !== 'function') return null;
|
|
1627
|
+
const probe = (d) => {
|
|
1628
|
+
k.renderToString(tex, { displayMode: d, throwOnError: true, trust: false, strict: 'ignore' });
|
|
1629
|
+
return d;
|
|
1630
|
+
};
|
|
1631
|
+
try { return probe(display); } catch { /* 按切分结果渲染不了 → 试另一种模式 */ }
|
|
1632
|
+
try { return probe(!display); } catch { return null; }
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** 把一段 LaTeX 渲染进**我们新建的**元素(KaTeX,vendored 离线)。
|
|
1636
|
+
* · 先 `renderToString` + `throwOnError:true` 探一次:非法 LaTeX 在这里抛,交给调用方降级
|
|
1637
|
+
* (既满足契约参数 `throwOnError:false`,又不会把 KaTeX 自己画的红色错误当成功);
|
|
1638
|
+
* · 真正渲染用契约参数:`throwOnError:false` / `trust:false` / `strict:'ignore'`。
|
|
1639
|
+
* **不写 innerHTML**:KaTeX 自己建 DOM 节点,模型输出只作为待转义的 LaTeX 文本传入。 */
|
|
1640
|
+
function renderMathSegment(el, tex, display) {
|
|
1641
|
+
const k = typeof window !== 'undefined' ? window.katex : null;
|
|
1642
|
+
if (!k || typeof k.renderToString !== 'function' || typeof k.render !== 'function') throw new Error('katex 不可用');
|
|
1643
|
+
k.renderToString(tex, { displayMode: display, throwOnError: true, trust: false, strict: 'ignore' });
|
|
1644
|
+
k.render(tex, el, { displayMode: display, throwOnError: false, trust: false, strict: 'ignore' });
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** 渲染译文到 #tr-body:文本段 textContent,数学段 KaTeX → 新建元素;渲染失败的段落回原始 LaTeX
|
|
1648
|
+
* 文本(绝不空白)。返回未能渲染的公式序号(1 起,供 #tr-note 提示)。 */
|
|
1649
|
+
function trRenderBody(body) {
|
|
1650
|
+
const host = $('tr-body');
|
|
1651
|
+
host.textContent = '';
|
|
1652
|
+
const failed = [];
|
|
1653
|
+
let n = 0;
|
|
1654
|
+
for (const seg of splitTranslatedMath(body)) {
|
|
1655
|
+
if (seg.kind === 'text') {
|
|
1656
|
+
const el = document.createElement('span');
|
|
1657
|
+
el.textContent = seg.text; // ← 模型输出的文本:只用 textContent
|
|
1658
|
+
host.appendChild(el);
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
n += 1;
|
|
1662
|
+
// 先解析该用哪种模式(探测失败会换另一种重试),再决定元素的形态
|
|
1663
|
+
const mode = resolveMathMode(seg.text, seg.display);
|
|
1664
|
+
const block = mode === true; // 只有真的按展示模式渲染时才用块级
|
|
1665
|
+
const el = document.createElement(block ? 'div' : 'span');
|
|
1666
|
+
el.className = block ? 'tr-math tr-math-block' : 'tr-math';
|
|
1667
|
+
let ok = mode !== null;
|
|
1668
|
+
if (ok) { try { renderMathSegment(el, seg.text, mode); } catch { ok = false; } }
|
|
1669
|
+
if (!ok) {
|
|
1670
|
+
el.className = 'tr-math tr-math-fallback';
|
|
1671
|
+
const delim = seg.delim ?? (seg.display ? '$$' : '$');
|
|
1672
|
+
el.textContent = delim + seg.text + delim; // 降级:原始 LaTeX 原样可见、可复制
|
|
1673
|
+
failed.push(n);
|
|
1674
|
+
}
|
|
1675
|
+
host.appendChild(el);
|
|
1676
|
+
}
|
|
1677
|
+
return failed;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/** #tr-note 文案(**仍是纯文本**):原有提示 + 渲染失败提示,如「第 2、5 个公式未能渲染(已按原样显示 LaTeX)」。 */
|
|
1681
|
+
function trComposeNote(note, failed) {
|
|
1682
|
+
const base = String(note ?? '');
|
|
1683
|
+
if (!failed || !failed.length) return base;
|
|
1684
|
+
const which = failed.length === 1 ? `第 ${failed[0]} 个公式` : `第 ${failed.join('、')} 个公式`;
|
|
1685
|
+
const hint = `${which}未能渲染(已按原样显示 LaTeX)`;
|
|
1686
|
+
return base ? `${base} ${hint}` : hint;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** 展示译文气泡。**绝不 innerHTML 模型输出**:文本段 textContent,数学段交 trRenderBody 渲染进新建元素。
|
|
1690
|
+
* 定位照 openCfgPop:先显示再量尺寸,不写死宽高常量。 */
|
|
1691
|
+
function showTrPop(status, body, note) {
|
|
1692
|
+
$('tr-status').textContent = status || '';
|
|
1693
|
+
const failed = trRenderBody(body || '');
|
|
1694
|
+
$('tr-note').textContent = trComposeNote(note, failed);
|
|
1695
|
+
trPop.style.display = 'block';
|
|
1696
|
+
let rect = { left: 16, bottom: 16 };
|
|
1697
|
+
try { if (askSelection && askSelection.range) rect = askSelection.range.getBoundingClientRect(); } catch { /* 选区失效用兜底锚点 */ }
|
|
1698
|
+
trPop.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - trPop.offsetWidth - 8)) + 'px';
|
|
1699
|
+
trPop.style.top = Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - trPop.offsetHeight - 8)) + 'px';
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** 归属判定(§3.3① 冻结规则):**字符矩形中心点落在 equation 盒内**(不用「有任何交叠」——
|
|
1703
|
+
* 实测 2/18 例会把下一行正文吞进来);并列取面积最小者(与既有热区点击 hitTestFormula 同规则)。 */
|
|
1704
|
+
function formulaBoxByCenter(rect, items) {
|
|
1705
|
+
if (!items || !items.length) return null;
|
|
1706
|
+
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
|
|
1707
|
+
let best = null;
|
|
1708
|
+
for (const it of items) {
|
|
1709
|
+
if (cx >= it.x0 && cx <= it.x1 && cy >= it.y0 && cy <= it.y1) {
|
|
1710
|
+
if (!best || it.w * it.h < best.w * best.h) best = it;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return best;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** 当前页几何(paintEqLayer 写进 EQ_BY_PAGE 的 items)。仅在原文视图 + 有 bbox 时可用。 */
|
|
1717
|
+
function eqItemsFor(pageNo) {
|
|
1718
|
+
if (!eqHotEnabled()) return null;
|
|
1719
|
+
return EQ_BY_PAGE.get(pageNo) || null;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** 字符序列 → chunk 序列(§3.2-6 冻结切法)。
|
|
1723
|
+
* chars: [{ch, page, rect}](rect 为页面局部 px;缺失/退化的字符按纯文本处理)
|
|
1724
|
+
* 规则:同页 + 同归属 + 无换行的最大连续段;换行是 text 段的结尾字符;公式段整体丢弃乱码、
|
|
1725
|
+
* 换成紧凑 LaTeX(`$` 定界在服务端加)。 */
|
|
1726
|
+
function buildChunks(chars, itemsByPage) {
|
|
1727
|
+
const out = [];
|
|
1728
|
+
let cur = null;
|
|
1729
|
+
const flush = () => { if (cur) out.push(cur); cur = null; };
|
|
1730
|
+
for (const c of chars) {
|
|
1731
|
+
// 命中物是热区几何 {eq, x0, y0, x1, y1, …}(见 eqGeom);公式本体在 .eq 上
|
|
1732
|
+
const hit = c.rect ? formulaBoxByCenter(c.rect, itemsByPage ? itemsByPage(c.page) : null) : null;
|
|
1733
|
+
const entry = hit ? hit.eq : null;
|
|
1734
|
+
const kind = entry ? 'formula' : 'text';
|
|
1735
|
+
if (c.ch === '\n' && kind === 'text') {
|
|
1736
|
+
if (cur && cur.kind === 'text') { cur.text += '\n'; flush(); }
|
|
1737
|
+
else { flush(); out.push({ page: c.page, kind: 'text', text: '\n' }); }
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
if (cur && cur.page === c.page && cur.kind === kind && (kind === 'text' || cur.eqId === entry.id)) {
|
|
1741
|
+
if (kind === 'text') cur.text += c.ch;
|
|
1742
|
+
continue; // formula:同一条公式的后续字符直接丢弃(LaTeX 是原子的)
|
|
1743
|
+
}
|
|
1744
|
+
flush();
|
|
1745
|
+
cur = kind === 'formula'
|
|
1746
|
+
? { page: c.page, kind: 'formula', latex: eqLatex(entry.latex), eqId: entry.id }
|
|
1747
|
+
: { page: c.page, kind: 'text', text: c.ch };
|
|
1748
|
+
}
|
|
1749
|
+
flush();
|
|
1750
|
+
// 规模闸门:段数 ≤200、单段 text ≤4000、总长 ≤8000(超限从尾部截断,并让调用方提示)
|
|
1751
|
+
const capped = [];
|
|
1752
|
+
let total = 0;
|
|
1753
|
+
for (const c of out) {
|
|
1754
|
+
const payload = c.kind === 'text' ? c.text : c.latex;
|
|
1755
|
+
if (capped.length >= MAX_CHUNKS) break;
|
|
1756
|
+
const room = MAX_TOTAL_CHARS - total;
|
|
1757
|
+
if (room <= 0) break;
|
|
1758
|
+
let s = payload;
|
|
1759
|
+
const cap = c.kind === 'text' ? Math.min(MAX_SELECTION_CHARS, room) : room;
|
|
1760
|
+
if (s.length > cap) {
|
|
1761
|
+
// 截断回退到码点边界:slice 按 UTF-16 码元计数,切开代理对(数学斜体 𝑋 等)会让
|
|
1762
|
+
// 翻译请求带孤立代理项、上游整轮 400(issue #9 同类)。宁可短 1 个码元。
|
|
1763
|
+
let cut = cap;
|
|
1764
|
+
const before = s.charCodeAt(cut - 1);
|
|
1765
|
+
if (before >= 0xd800 && before <= 0xdbff && cut > 1) cut -= 1;
|
|
1766
|
+
s = s.slice(0, cut);
|
|
1767
|
+
}
|
|
1768
|
+
if (!s) continue;
|
|
1769
|
+
total += s.length;
|
|
1770
|
+
capped.push(c.kind === 'text' ? { page: c.page, kind: 'text', text: s } : { page: c.page, kind: 'formula', latex: s });
|
|
1771
|
+
}
|
|
1772
|
+
return capped;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** 选区覆盖的文本节点(按文档序)。手写递归而不是 createTreeWalker:
|
|
1776
|
+
* 选区只需要「起点与终点之间的文本节点」,而 TreeWalker 在部分 webview/测试桩里缺实现。 */
|
|
1777
|
+
function textNodesUnder(node) {
|
|
1778
|
+
const out = [];
|
|
1779
|
+
const stack = [node];
|
|
1780
|
+
while (stack.length) {
|
|
1781
|
+
const n = stack.pop();
|
|
1782
|
+
if (!n) continue;
|
|
1783
|
+
if (n.nodeType === 3) { out.push(n); continue; }
|
|
1784
|
+
const kids = n.childNodes ? [...n.childNodes] : [];
|
|
1785
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
1786
|
+
}
|
|
1787
|
+
return out;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** 选区内每个字符 + 它的页面局部矩形(页面局部 = rect 减 pageDiv 的 bounding rect;
|
|
1791
|
+
* `.page` 上没有 CSS transform,缩放体现在 viewport 尺寸里 ⇒ 与滚动/翻页/缩放都无关,
|
|
1792
|
+
* 与既有 hitTestFormula 用的是同一套坐标)。 */
|
|
1793
|
+
function collectSelectionChars(range) {
|
|
1794
|
+
const root = range.commonAncestorContainer;
|
|
1795
|
+
if (!root) return [];
|
|
1796
|
+
const nodes = textNodesUnder(root).filter((n) => {
|
|
1797
|
+
if (typeof range.intersectsNode === 'function') { try { return range.intersectsNode(n); } catch { return true; } }
|
|
1798
|
+
return n === range.startContainer || n === range.endContainer;
|
|
1799
|
+
});
|
|
1800
|
+
const out = [];
|
|
1801
|
+
for (const node of nodes) {
|
|
1802
|
+
const data = node.data ?? '';
|
|
1803
|
+
let start = 0, end = data.length;
|
|
1804
|
+
if (node === range.startContainer) start = range.startOffset;
|
|
1805
|
+
if (node === range.endContainer) end = range.endOffset;
|
|
1806
|
+
const parent = node.parentElement;
|
|
1807
|
+
const pageDiv = parent && parent.closest ? parent.closest('.page[data-doc]') : null;
|
|
1808
|
+
if (!pageDiv) continue;
|
|
1809
|
+
const doc = pageDiv.dataset.doc || 'orig';
|
|
1810
|
+
if (doc !== 'orig') continue; // 分屏时中文列不参与公式归属(同一套几何只对原文成立)
|
|
1811
|
+
const pageNo = +pageDiv.dataset.page || 0;
|
|
1812
|
+
const box = pageDiv.getBoundingClientRect();
|
|
1813
|
+
const cr = document.createRange();
|
|
1814
|
+
for (let i = start; i < end; i++) {
|
|
1815
|
+
let rect = null;
|
|
1816
|
+
try {
|
|
1817
|
+
cr.setStart(node, i); cr.setEnd(node, i + 1);
|
|
1818
|
+
const r = cr.getBoundingClientRect();
|
|
1819
|
+
if (r && r.width > 0 && r.height > 0) {
|
|
1820
|
+
rect = [r.left - box.left, r.top - box.top, r.right - box.left, r.bottom - box.top];
|
|
1821
|
+
}
|
|
1822
|
+
} catch { rect = null; } // 换行伪影/失效 Range:退化成纯文本字符
|
|
1823
|
+
out.push({ ch: data[i], page: pageNo, rect });
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return out;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** #tr-note 的诚实降级文案(§3.8 文案表,优先级自上而下)。
|
|
1830
|
+
* ⚠️ 与需求表的一处**有意偏离**(已在实现报告里回报队长):该表把 `unavailable` 排在
|
|
1831
|
+
* `formulaSkipped>0` 之前,但过短选区(如只选一个 `F`)会同时命中两者,按表顺序会显示
|
|
1832
|
+
* 「没能定位到原文公式」——而验收项 A13 要求显示「选区太短,公式未还原」。这里让
|
|
1833
|
+
* 「什么都没还原 + 有过短 chunk」先报「选区太短」:它更准确,也不会误导用户以为译文是准的。 */
|
|
1834
|
+
function trNote(resp) {
|
|
1835
|
+
const reason = eqIndex && eqIndex.reason;
|
|
1836
|
+
if (eqIndex && eqIndex.source === 'none' && reason === 'no-mineru-artifact') {
|
|
1837
|
+
return '⚠ 该论文未用 MinerU 转录:原文里的公式在文本层不可读,译文可能不准(顶栏 ∑ 公式 → 「用 MinerU 重新转录」)';
|
|
1838
|
+
}
|
|
1839
|
+
if (reason === 'no-equations') return '⚠ MinerU 产物里没有公式块:公式可能不准';
|
|
1840
|
+
if (reason === 'bad-artifact') return '⚠ MinerU 产物损坏或不可读,已忽略:公式可能不准';
|
|
1841
|
+
if (resp.formulaLost > 0) return `⚠ 有 ${resp.formulaLost} 个公式未原样保留,请对照原文`;
|
|
1842
|
+
if (resp.formulaSkipped > 0 && resp.formulaApplied === 0) return '⚠ 选区太短,公式未还原';
|
|
1843
|
+
if (resp.formulaStatus === 'unavailable' && resp.hasBlockIndex) return '⚠ 没能定位到原文公式,译文可能不准';
|
|
1844
|
+
if (resp.formulaStatus === 'partial') return '⚠ 部分公式未能还原,译文可能不准';
|
|
1845
|
+
if (resp.formulaSkipped > 0) return '⚠ 选区太短,公式未还原';
|
|
1846
|
+
return ''; // applied / formulaTotal===0:不显示任何提示
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** 失败反馈(§3.7② 表,逐条)。返回 {status, toast}:气泡状态行与 toast 的措辞不同处照表分开。 */
|
|
1850
|
+
function trFailure(e) {
|
|
1851
|
+
if (e && e.clientTimeout) return { status: '翻译超时,请重试', toast: '翻译超时,请重试' };
|
|
1852
|
+
if (e && e.code === 'timeout') return { status: '翻译超时,请重试', toast: '翻译超时,请重试' };
|
|
1853
|
+
if (e && e.code === 'network') return { status: '翻译失败:无法连接端点', toast: '翻译失败:无法连接端点' };
|
|
1854
|
+
const up = e && e.upstreamStatus;
|
|
1855
|
+
if (up === 401 || up === 403) return { status: '翻译失败:端点拒绝(请检查 API key)', toast: `翻译失败:端点拒绝(HTTP ${up})` };
|
|
1856
|
+
if (up === 429) return { status: '翻译失败:请求过于频繁,稍后再试', toast: '翻译失败:请求过于频繁,稍后再试' };
|
|
1857
|
+
if (typeof up === 'number' && up >= 500) return { status: '翻译失败:端点错误(HTTP 5xx)', toast: '翻译失败:端点错误(HTTP 5xx)' };
|
|
1858
|
+
const m = '翻译失败:' + ((e && e.message) || '未知原因');
|
|
1859
|
+
return { status: m, toast: m };
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** 划词翻译入口(#ask-translate.onclick)。 */
|
|
1863
|
+
async function translateSelection() {
|
|
1864
|
+
// 中文列(分屏,doc==='zh')不参与:几何只对原文页成立(R-07;按钮本身也不会显示)
|
|
1865
|
+
if (!current || !askSelection || askSelection.kind !== 'text' || zhVariant !== null) return;
|
|
1866
|
+
if ((askSelection.doc ?? 'orig') !== 'orig') return;
|
|
1867
|
+
if (trBusy) return;
|
|
1868
|
+
const range = askSelection.range;
|
|
1869
|
+
if (!range || !range.startContainer || range.startContainer.isConnected === false) {
|
|
1870
|
+
toast('页面已重绘,请重新选择', 4000); // R5:绝不缓存矩形,也不静默失败
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
// 用户点按钮时浏览器可能已把选区清掉:先把存下来的 Range 放回去,rects 才取得到
|
|
1874
|
+
const sel = window.getSelection();
|
|
1875
|
+
if (sel && sel.isCollapsed && typeof sel.addRange === 'function') {
|
|
1876
|
+
try { sel.removeAllRanges(); sel.addRange(range); } catch { /* 失效选区:下面按空选区处理 */ }
|
|
1877
|
+
}
|
|
1878
|
+
let text = '';
|
|
1879
|
+
try { text = String(range.toString() || ''); } catch { text = ''; }
|
|
1880
|
+
if (!text.trim()) { toast('选区已失效,请重新选择', 4000); return; }
|
|
1881
|
+
const chars = collectSelectionChars(range);
|
|
1882
|
+
const chunks = buildChunks(chars, eqItemsFor);
|
|
1883
|
+
if (!chunks.length) { toast('选区已失效,请重新选择', 4000); return; }
|
|
1884
|
+
if (chars.length > MAX_SELECTION_CHARS) toast(`选区过长,只翻译前 ${MAX_SELECTION_CHARS} 字符`, 4000);
|
|
1885
|
+
|
|
1886
|
+
askPop.style.display = 'none'; // 同一锚点必然重叠 ⇒ 气泡「变成」译文气泡(§3.1)
|
|
1887
|
+
showTrPop('翻译中…', '', '');
|
|
1888
|
+
trBusy = true;
|
|
1889
|
+
const seq = ++trSeq;
|
|
1890
|
+
const ac = new AbortController();
|
|
1891
|
+
let clientTimeout = false;
|
|
1892
|
+
const timer = setTimeout(() => { clientTimeout = true; ac.abort(); }, TR_CLIENT_TIMEOUT_MS);
|
|
1893
|
+
// 诊断可读性:记录本次送出的 chunk 规模(不含文本内容、不含 key)
|
|
1894
|
+
const charsSent = chunks.reduce((n, c) => n + (c.kind === 'text' ? c.text.length : c.latex.length), 0);
|
|
1895
|
+
try {
|
|
1896
|
+
const resp = await api('/translate-selection', {
|
|
1897
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1898
|
+
body: JSON.stringify({ ...current, chunks }),
|
|
1899
|
+
signal: ac.signal,
|
|
1900
|
+
});
|
|
1901
|
+
if (seq !== trSeq) return; // 过期保护:用户已换选区/换文献
|
|
1902
|
+
showTrPop('', resp.translation, trNote(resp));
|
|
1903
|
+
} catch (e) {
|
|
1904
|
+
if (seq !== trSeq) return;
|
|
1905
|
+
if (clientTimeout) e.clientTimeout = true;
|
|
1906
|
+
if (e && e.code === 'no-endpoint') {
|
|
1907
|
+
// 未配置端点:打开既有设置浮层,保存成功后自动重试这次翻译(§3.7①)
|
|
1908
|
+
showTrPop('未配置翻译端点', '', '在上面的设置里填好端点并保存,会自动重试这次翻译');
|
|
1909
|
+
cfgRetryTranslate = true;
|
|
1910
|
+
openCfgPop(false);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const f = trFailure(e);
|
|
1914
|
+
showTrPop(f.status, '', '');
|
|
1915
|
+
toast(f.toast + `(选区 ${charsSent} 字符)`, 8000);
|
|
1916
|
+
} finally {
|
|
1917
|
+
clearTimeout(timer);
|
|
1918
|
+
if (seq === trSeq) trBusy = false;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
$('ask-translate').onclick = () => translateSelection();
|
|
1923
|
+
document.addEventListener('mousedown', (e) => { if (!trPop.contains(e.target)) hideTrPop(); });
|
|
1924
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTrPop(); });
|
|
1925
|
+
|
|
1926
|
+
/* =====================================================================================
|
|
1927
|
+
分屏对照(模式 B):原文 PDF ↔ -zh.pdf 并排同步滚动 —— §4.2/§4.4
|
|
1928
|
+
· 两列各自一个 pdf.js 文档;全局 pdfDoc **保持指向原文**(既有逻辑零改动)
|
|
1929
|
+
· zhVariant 保持 null ⇒ eqHotEnabled()/公式面板/适宽分支继续按「原文」工作(C17)
|
|
1930
|
+
· 热区只在原文列铺(paintEqLayer 按 pageDiv.dataset.doc 早退)、只在原文列命中(C18/R7)
|
|
1931
|
+
===================================================================================== */
|
|
1932
|
+
const SPLIT_MIN_WIDTH = 640; // 冻结:面板太窄不做分屏(§4.2)
|
|
1933
|
+
let pdfDocZh = null;
|
|
1934
|
+
let splitSyncing = false;
|
|
1935
|
+
let splitRaf = 0;
|
|
1936
|
+
let splitWarned = false;
|
|
1937
|
+
|
|
1938
|
+
const rafFn = (fn) => (typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame(fn) : setTimeout(fn, 0));
|
|
1939
|
+
const crafFn = (id) => { if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(id); else clearTimeout(id); };
|
|
1940
|
+
|
|
1941
|
+
/** 纯函数:滚动位置 → 「顶部页号 + 页内比例」(§4.2 锚点)。
|
|
1942
|
+
* pages: [{page, offsetTop, offsetHeight}],按 DOM 序。 */
|
|
1943
|
+
function anchorOf(pages, scrollTop) {
|
|
1944
|
+
if (!pages || !pages.length) return null;
|
|
1945
|
+
let page = pages[pages.length - 1];
|
|
1946
|
+
for (const p of pages) { if (p.offsetTop + p.offsetHeight > scrollTop) { page = p; break; } }
|
|
1947
|
+
return { page: page.page, ratio: (scrollTop - page.offsetTop) / Math.max(1, page.offsetHeight) };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/** 纯函数:锚点 → 目标列 scrollTop。页数相同(实测常态 19/19)走同页号;不同则按比例映射,
|
|
1951
|
+
* 并让调用方给出「页数不一致」的一次性提示。 */
|
|
1952
|
+
function syncTargetOf(anchor, targetPages, srcPageCount) {
|
|
1953
|
+
if (!anchor || !targetPages || !targetPages.length) return null;
|
|
1954
|
+
const n = targetPages.length;
|
|
1955
|
+
let mismatch = false;
|
|
1956
|
+
let idx = targetPages.findIndex((p) => p.page === anchor.page);
|
|
1957
|
+
if (idx < 0) {
|
|
1958
|
+
mismatch = true;
|
|
1959
|
+
idx = Math.min(n - 1, Math.max(0, Math.round((anchor.page - 1) * n / Math.max(1, srcPageCount | 0))));
|
|
1960
|
+
} else if (srcPageCount && srcPageCount !== n) {
|
|
1961
|
+
mismatch = true;
|
|
1962
|
+
}
|
|
1963
|
+
const p = targetPages[idx];
|
|
1964
|
+
return { top: p.offsetTop + anchor.ratio * p.offsetHeight, mismatch };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/** 一列的页几何(DOM 序)。 */
|
|
1968
|
+
function columnPages(box) {
|
|
1969
|
+
return [...box.children].filter((c) => c.className === 'page')
|
|
1970
|
+
.map((d) => ({ page: +d.dataset.page || 1, offsetTop: d.offsetTop, offsetHeight: d.offsetHeight }));
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const splitCols = () => [
|
|
1974
|
+
{ pane: $('pdf-pane'), box: $('pdf-pages') },
|
|
1975
|
+
{ pane: $('pdf-pane-zh'), box: $('pdf-pages-zh') },
|
|
1976
|
+
];
|
|
1977
|
+
|
|
1978
|
+
/* =====================================================================================
|
|
1979
|
+
同步滚动开关(模式 B 的可选项)—— 两处入口(分屏控件旁 + dsh 设置面板)、全局记忆、默认开
|
|
1980
|
+
· 持久化键 `dpr.syncScroll`('1'/'0'):**故意不带 topic/name**,也**不在**
|
|
1981
|
+
`dpr.variant.` / `dpr.sessionN.` 两个前缀里 —— lib/client.js 的改名搬家(migrateLsKeys)
|
|
1982
|
+
只搬那两个前缀,所以论文/专题改名既不会搬它、更不会丢它(全局键本来就没有跟着某篇走的身份)。
|
|
1983
|
+
取值缺省 = 开:与改造前「两列被 rAF 强行同步」的既有行为一致,老用户观感不变。
|
|
1984
|
+
· 设置面板在**另一个文档**里(阅读器是同源 iframe):两处靠 localStorage 的 storage 事件互通
|
|
1985
|
+
(它只对**其他**文档触发,天然不会回声)——任一处改动,另一处立即反映。
|
|
1986
|
+
· 关掉只关「持续联动」:进分屏/重渲时的一次性对齐(alignSplit)仍然执行,否则两列会停在不同页。
|
|
1987
|
+
===================================================================================== */
|
|
1988
|
+
const SYNC_SCROLL_KEY = 'dpr.syncScroll';
|
|
1989
|
+
let syncScroll = readSyncPref();
|
|
1990
|
+
/** 最后一次被用户滚过的那一列(关同步时也记):重开同步时以它为基准对齐 —— 用户停在哪儿就对齐到哪儿。 */
|
|
1991
|
+
let splitLeadPane = null;
|
|
1992
|
+
|
|
1993
|
+
/** 读偏好:只有显式的 '0' 算关,其余(含缺失/脏值)都算开。 */
|
|
1994
|
+
function readSyncPref() {
|
|
1995
|
+
try { return localStorage.getItem(SYNC_SCROLL_KEY) !== '0'; } catch { return true; } // 隐私模式读不了 → 默认开
|
|
1996
|
+
}
|
|
1997
|
+
function persistSyncPref() {
|
|
1998
|
+
try { localStorage.setItem(SYNC_SCROLL_KEY, syncScroll ? '1' : '0'); } catch { /* 隐私模式写不了:本次会话内仍然生效 */ }
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/** 开关控件(分屏控件旁):**只在分屏(模式 B)下可见** —— 模式 A(-dual.pdf 一个 PDF 里并排嵌好)
|
|
2002
|
+
* 与原文/译文视图都只有一个滚动条,本开关没有意义;隐藏而不是留个死控件。 */
|
|
2003
|
+
function updateSyncToggle() {
|
|
2004
|
+
const b = $('sync-toggle');
|
|
2005
|
+
if (!b) return;
|
|
2006
|
+
b.style.display = viewMode === 'split' ? '' : 'none';
|
|
2007
|
+
// 状态换图标(与 mode-toggle 的 paged/scroll、zh-toggle 的 translate/spin 同法):
|
|
2008
|
+
// data-icon 跟着渲染出来的图标走,图标本身由 iconHtml 统一给出(class="ic"、size、aria-hidden)
|
|
2009
|
+
const icon = syncScroll ? 'sync' : 'syncOff';
|
|
2010
|
+
b.dataset.icon = icon;
|
|
2011
|
+
b.innerHTML = iconHtml(icon);
|
|
2012
|
+
b.classList.toggle('active', syncScroll);
|
|
2013
|
+
b.title = syncScroll
|
|
2014
|
+
? '同步滚动 / Sync scroll:开(两列一起滚,点击关闭)'
|
|
2015
|
+
: '同步滚动 / Sync scroll:关(两列各自独立滚动,点击打开并对齐回当前页)';
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** 唯一写入口(按钮点击 / 设置面板的 storage 事件都走这里):落持久化 → 刷新控件 → 打开时立刻对齐。 */
|
|
2019
|
+
function setSyncScroll(on, { persist = true, realign = true } = {}) {
|
|
2020
|
+
syncScroll = !!on;
|
|
2021
|
+
if (persist) persistSyncPref();
|
|
2022
|
+
updateSyncToggle();
|
|
2023
|
+
if (syncScroll && realign) alignSplit(currentLeadCol());
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/** 重开同步时的基准列:用户最后滚过的那一列;没滚过(或那列已不在)就用原文列。 */
|
|
2027
|
+
function currentLeadCol() {
|
|
2028
|
+
const cols = splitCols();
|
|
2029
|
+
return cols.find((c) => c.pane === splitLeadPane) || cols[0];
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/** 一次性对齐:把**跟随列**滚到基准列当前锚点对应的位置。
|
|
2033
|
+
* 与 onSplitScroll 的区别是**不看 syncScroll** —— 进分屏 / 重渲 / 重开同步都必须先把两列摆到同一页,
|
|
2034
|
+
* 否则用户看到「两列停在不同页」会以为坏了(关同步只关持续联动,不关「一开始就对齐」)。 */
|
|
2035
|
+
function alignSplit(src) {
|
|
2036
|
+
if (viewMode !== 'split') return;
|
|
2037
|
+
const other = splitCols().find((c) => c.pane !== src.pane);
|
|
2038
|
+
if (!other) return;
|
|
2039
|
+
const srcPages = columnPages(src.box);
|
|
2040
|
+
const target = syncTargetOf(anchorOf(srcPages, src.pane.scrollTop), columnPages(other.box), srcPages.length);
|
|
2041
|
+
if (!target) return;
|
|
2042
|
+
splitSyncing = true; // 跟随引起的 scroll 事件在下一帧被守卫吞掉(与 onSplitScroll 同法)
|
|
2043
|
+
crafFn(splitRaf);
|
|
2044
|
+
splitRaf = rafFn(() => {
|
|
2045
|
+
try {
|
|
2046
|
+
other.pane.scrollTop = target.top;
|
|
2047
|
+
if (target.mismatch && !splitWarned) { splitWarned = true; toast('⚠ 中文版页数与原文不一致,按比例同步', 4000); }
|
|
2048
|
+
} finally {
|
|
2049
|
+
splitSyncing = false;
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/** 分屏控件旁的开关:只在分屏下可见;模式 A / 单列下点不到,也绝不报错。 */
|
|
2055
|
+
$('sync-toggle').onclick = () => {
|
|
2056
|
+
if (viewMode !== 'split') return;
|
|
2057
|
+
setSyncScroll(!syncScroll);
|
|
2058
|
+
};
|
|
2059
|
+
|
|
2060
|
+
/** 设置面板(dsh 设置 → 论文伴读)改了同一个键 → 阅读器立即反映(含重新打开同步时的一次性对齐)。
|
|
2061
|
+
* 只在值真的变了的时候动:storage 只对**其他**文档触发,这里也不回写(不回写就不会回声)。 */
|
|
2062
|
+
window.addEventListener('storage', (e) => {
|
|
2063
|
+
if (!e || e.key !== SYNC_SCROLL_KEY) return;
|
|
2064
|
+
const next = e.newValue !== '0';
|
|
2065
|
+
if (next === syncScroll) return;
|
|
2066
|
+
setSyncScroll(next, { persist: false });
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
/** 滚动同步:主导方 = 用户正在滚的那一列;rAF 节流 + 守卫吞掉回声(§4.2/R7)。
|
|
2070
|
+
* syncScroll=false 时本函数直接早退 ⇒ 两列各自独立滚动(不节流、也不强行对齐)。 */
|
|
2071
|
+
function onSplitScroll(src) {
|
|
2072
|
+
if (viewMode !== 'split' || splitSyncing || !syncScroll) return;
|
|
2073
|
+
const other = splitCols().find((c) => c.pane !== src.pane);
|
|
2074
|
+
if (!other) return;
|
|
2075
|
+
splitSyncing = true; // 跟随引起的 scroll 事件被守卫吞掉
|
|
2076
|
+
crafFn(splitRaf);
|
|
2077
|
+
splitRaf = rafFn(() => {
|
|
2078
|
+
try {
|
|
2079
|
+
// 排到这一帧时开关可能已被关掉(用户点了开关、或面板刚同步过来):在飞的回调也不得再拉列,
|
|
2080
|
+
// 否则「关掉后各自独立」会在下一帧被自己打破(F1 同一族问题)。
|
|
2081
|
+
if (!syncScroll) return;
|
|
2082
|
+
const srcPages = columnPages(src.box);
|
|
2083
|
+
const a = anchorOf(srcPages, src.pane.scrollTop);
|
|
2084
|
+
const t = syncTargetOf(a, columnPages(other.box), srcPages.length);
|
|
2085
|
+
if (t) {
|
|
2086
|
+
other.pane.scrollTop = t.top; // behavior 默认 auto:不做平滑动画(会和同步互相打架)
|
|
2087
|
+
if (t.mismatch && !splitWarned) { splitWarned = true; toast('⚠ 中文版页数与原文不一致,按比例同步', 4000); }
|
|
2088
|
+
}
|
|
2089
|
+
} finally {
|
|
2090
|
+
splitSyncing = false;
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** 渲染一列的若干页(滚动=全量、翻页=curPage)。热区由 paintPage 按页节点的 data-doc 决定。 */
|
|
2096
|
+
async function renderColumn(doc, docKey, container, pages, token, scale) {
|
|
2097
|
+
for (const i of pages) {
|
|
2098
|
+
if (token !== renderToken) return;
|
|
2099
|
+
const page = await doc.getPage(i);
|
|
2100
|
+
if (token !== renderToken) return;
|
|
2101
|
+
const viewport = page.getViewport({ scale });
|
|
2102
|
+
const div = makePageNode(i, viewport, docKey);
|
|
2103
|
+
container.appendChild(div);
|
|
2104
|
+
await paintPage(page, div, viewport, token);
|
|
2105
|
+
if (token !== renderToken) return;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/** 清空前捕获一列的滚动锚点:优先「页号 + 页内比例」(此刻旧页节点还在,页内几何有效);
|
|
2110
|
+
* 页节点不可用(并发重渲刚好清空过容器)时退回滚动比例锚点 —— 与 renderAllPages 同口径,
|
|
2111
|
+
* 绝不把锚点归零成「回到第一页」。 */
|
|
2112
|
+
function captureScrollAnchor(pane, box) {
|
|
2113
|
+
const pages = columnPages(box);
|
|
2114
|
+
const a = pages.length ? anchorOf(pages, pane.scrollTop) : null;
|
|
2115
|
+
if (a) return a;
|
|
2116
|
+
const range = pane.scrollHeight - pane.clientHeight;
|
|
2117
|
+
return { ratio: range > 0 ? pane.scrollTop / range : 0 };
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** 把一列恢复到自己捕获的锚点:优先「同页号 + 页内比例」(zoom / dppx 前后都落回**同一页**,
|
|
2121
|
+
* 不因页面间 gap/padding 造成的比例漂移而挪页);该页这次没有(页数变了/节点缺失)时退回滚动比例。 */
|
|
2122
|
+
function restoreScrollAnchor(pane, box, anchor) {
|
|
2123
|
+
if (anchor && anchor.page != null) {
|
|
2124
|
+
const p = columnPages(box).find((x) => x.page === anchor.page);
|
|
2125
|
+
if (p) { pane.scrollTop = p.offsetTop + anchor.ratio * p.offsetHeight; return; }
|
|
2126
|
+
}
|
|
2127
|
+
const range = pane.scrollHeight - pane.clientHeight;
|
|
2128
|
+
pane.scrollTop = (anchor && typeof anchor.ratio === 'number' ? anchor.ratio : 0) * Math.max(0, range);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** 中文列这次重渲前是否有一个「和即将渲染的形态可比」的位置:重渲前的页数必须等于这次要渲染的页数
|
|
2132
|
+
* (还没渲过 / 页数变了 / 翻页↔滚动切换都不可比 —— 那些情况沿用统一对齐,见 renderSplit 尾注)。 */
|
|
2133
|
+
function zhOwnAnchorUsable(box, doc, paged) {
|
|
2134
|
+
const before = columnPages(box).length;
|
|
2135
|
+
if (before === 0) return false;
|
|
2136
|
+
return before === (doc ? (paged ? 1 : doc.numPages) : 0);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/** 分屏全量渲染入口:两列同时渲(滚动=各列全量、翻页=两列同渲 curPage)。 */
|
|
2140
|
+
async function renderSplit() {
|
|
2141
|
+
if (viewMode !== 'split') return rerender();
|
|
2142
|
+
if (!pdfDoc) return;
|
|
2143
|
+
const pane = $('pdf-pane');
|
|
2144
|
+
if (pane.clientHeight === 0) { pendingRerender = true; return; } // 与既有渲染入口同一条守卫
|
|
2145
|
+
const token = ++renderToken;
|
|
2146
|
+
const scale = zoomScale;
|
|
2147
|
+
const origBox = $('pdf-pages'), zhBox = $('pdf-pages-zh');
|
|
2148
|
+
const range = pane.scrollHeight - pane.clientHeight;
|
|
2149
|
+
const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
|
|
2150
|
+
// F1:清空前**各自**记下两列的锚点(此刻旧页节点还在)。同步关着时两列各回各的 ——
|
|
2151
|
+
// 关掉同步的全部意义就是「两列读不同位置」,zoom / dppx / resize 一下不该把这个意图抹掉。
|
|
2152
|
+
const zhPane = $('pdf-pane-zh');
|
|
2153
|
+
const origAnchor = captureScrollAnchor(pane, origBox);
|
|
2154
|
+
const zhAnchor = captureScrollAnchor(zhPane, zhBox);
|
|
2155
|
+
const paged = pageMode === 'paged';
|
|
2156
|
+
const zhOwn = zhOwnAnchorUsable(zhBox, pdfDocZh, paged);
|
|
2157
|
+
origBox.innerHTML = '';
|
|
2158
|
+
zhBox.innerHTML = '';
|
|
2159
|
+
clearEqGeometry(); // 旧页几何 + 跨列残留的 hover/hit 描边一起作废
|
|
2160
|
+
$('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
|
|
2161
|
+
origBox.classList.toggle('paged', paged);
|
|
2162
|
+
zhBox.classList.toggle('paged', paged);
|
|
2163
|
+
curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
|
|
2164
|
+
updatePager();
|
|
2165
|
+
const origList = paged ? [curPage] : Array.from({ length: pdfDoc.numPages }, (_, i) => i + 1);
|
|
2166
|
+
await renderColumn(pdfDoc, 'orig', origBox, origList, token, scale);
|
|
2167
|
+
if (token !== renderToken) return;
|
|
2168
|
+
if (pdfDocZh) {
|
|
2169
|
+
const zhPage = Math.min(Math.max(1, curPage), pdfDocZh.numPages);
|
|
2170
|
+
const zhList = paged ? [zhPage] : Array.from({ length: pdfDocZh.numPages }, (_, i) => i + 1);
|
|
2171
|
+
await renderColumn(pdfDocZh, 'zh', zhBox, zhList, token, scale);
|
|
2172
|
+
if (token !== renderToken) return;
|
|
2173
|
+
}
|
|
2174
|
+
// 锚点恢复:重渲后两列各自回到重渲前的位置。
|
|
2175
|
+
// · 同步开着 / 刚进分屏(中文列还没有可比的位置):**既有语义一字不动** —— 原文列按滚动比例回位,
|
|
2176
|
+
// 中文列统一对齐到原文列(「进分屏的一次性对齐不看开关」是评审已裁定必要的行为,保持);
|
|
2177
|
+
// · 同步关着且中文列有**同形态**的重渲前位置(F1 修法):两列各回各的锚点 —— 不能让一次
|
|
2178
|
+
// zoom / dppx / resize 把「两列读不同位置」的意图抹掉。
|
|
2179
|
+
if (!paged) {
|
|
2180
|
+
if (syncScroll || !zhOwn) {
|
|
2181
|
+
const newRange = pane.scrollHeight - pane.clientHeight;
|
|
2182
|
+
pane.scrollTop = anchorRatio * Math.max(0, newRange);
|
|
2183
|
+
splitSyncing = false;
|
|
2184
|
+
alignSplit(splitCols()[0]);
|
|
2185
|
+
} else {
|
|
2186
|
+
restoreScrollAnchor(pane, origBox, origAnchor);
|
|
2187
|
+
restoreScrollAnchor(zhPane, zhBox, zhAnchor);
|
|
2188
|
+
splitSyncing = false;
|
|
2189
|
+
crafFn(splitRaf); // 作废可能在飞的对齐/联动回调:别让它下一帧再把中文列拉回原文列
|
|
2190
|
+
splitRaf = 0;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
observePages(); // 只观察原文列(#pdf-pages .page 的 id 限定必须保持)
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** 进入分屏(或 openPaper 恢复分屏):拉原文 + -zh.pdf 两个文档,两列同渲。 */
|
|
2197
|
+
async function openSplit() {
|
|
2198
|
+
if (!current || !zhState.zh) return;
|
|
2199
|
+
if (window.innerWidth < SPLIT_MIN_WIDTH) {
|
|
2200
|
+
toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000);
|
|
2201
|
+
if (viewMode === 'split') await setViewMode('orig');
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
// pdf.js 必须先就绪(与 loadPdf 同一条通道):openPaper 恢复「分屏」时页脚的经典脚本还在解析期,
|
|
2205
|
+
// 动态 import 尚未完成 —— 直接触 pdfjsLib 会 ReferenceError,表现为
|
|
2206
|
+
// 「打开中文版失败:pdfjsLib is not defined,已退回原文」+ 持久化写空(t3 报告 F3,6 次跑 5 次失败)。
|
|
2207
|
+
await ensurePdfjs();
|
|
2208
|
+
$('split-wrap').classList.add('on');
|
|
2209
|
+
$('pdf-pane-zh').style.display = '';
|
|
2210
|
+
splitWarned = false;
|
|
2211
|
+
$('empty').style.display = 'none';
|
|
2212
|
+
$('pager').style.display = '';
|
|
2213
|
+
$('mode-toggle').style.display = '';
|
|
2214
|
+
if (pdfDocZh) { try { await pdfDocZh.destroy?.(); } catch { /* 旧文档已释放 */ } pdfDocZh = null; }
|
|
2215
|
+
try {
|
|
2216
|
+
pdfDocZh = await pdfjsLib.getDocument({ url: pdfUrl('zh') }).promise;
|
|
2217
|
+
} catch (e) {
|
|
2218
|
+
// -zh.pdf 拿不到(被删/损坏):回落原文 + 如实提示,不静默失败
|
|
2219
|
+
await setViewMode('orig');
|
|
2220
|
+
toast('打开中文版失败:' + (e && e.message ? e.message : e) + ',已退回原文', 6000);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
await loadPdf(pdfUrl(null), { deferRender: true }); // 原文列(pdfDoc 恒指原文),本函数自己安排渲染
|
|
2224
|
+
// 分屏适宽:按较窄的那一列算一次(等价于 fitToWidth,但不让它触发一次多余的单列渲染)
|
|
2225
|
+
const w = fitPaneWidth();
|
|
2226
|
+
if (w > 0) {
|
|
2227
|
+
const p1 = await pdfDoc.getPage(1);
|
|
2228
|
+
zoomScale = Math.min(4.5, Math.max(0.6, w / p1.getViewport({ scale: 1 }).width));
|
|
2229
|
+
}
|
|
2230
|
+
await renderSplit();
|
|
2231
|
+
attachSplitScroll();
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/** 退出分屏:拆掉中文列、释放中文文档、同步监听。 */
|
|
2235
|
+
function teardownSplit() {
|
|
2236
|
+
$('split-wrap').classList.remove('on');
|
|
2237
|
+
$('pdf-pane-zh').style.display = 'none';
|
|
2238
|
+
$('pdf-pages-zh').innerHTML = '';
|
|
2239
|
+
$('pdf-pages-zh').classList.remove('paged');
|
|
2240
|
+
splitCols().forEach((c) => { c.pane.onscroll = null; });
|
|
2241
|
+
splitSyncing = false;
|
|
2242
|
+
splitLeadPane = null; // 分屏退出:上一轮「最后滚过的那一列」作废(下次进分屏回到原文列为准)
|
|
2243
|
+
crafFn(splitRaf);
|
|
2244
|
+
if (pdfDocZh) { try { pdfDocZh.destroy?.(); } catch { /* 已释放 */ } }
|
|
2245
|
+
pdfDocZh = null;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/** 两列的滚动监听(各自一个,**不**共用一个滚动容器——R7 明令)。 */
|
|
2249
|
+
function attachSplitScroll() {
|
|
2250
|
+
splitCols().forEach((col) => {
|
|
2251
|
+
// 关掉同步时也记「最后滚过的那一列」:重开同步要按它对齐(见 setSyncScroll/currentLeadCol)
|
|
2252
|
+
col.pane.onscroll = () => { splitLeadPane = col.pane; onSplitScroll(col); };
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
|
|
1275
2256
|
/* ================= 启动:URL 参数 > localStorage > 第一篇 ================= */
|
|
1276
2257
|
async function bootstrap() {
|
|
1277
2258
|
const params = new URLSearchParams(location.search);
|