@ggboy123/dsh-paper-reader 1.4.0 → 1.5.0

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.
Files changed (33) hide show
  1. package/README.md +36 -297
  2. package/dist/babeldoc-install.js +6 -2
  3. package/dist/formulas.js +87 -14
  4. package/dist/host.js +107 -3
  5. package/dist/translate-config.js +4 -1
  6. package/dist/translate-selection.js +772 -0
  7. package/lib/client.js +70 -0
  8. package/package.json +1 -1
  9. package/reader/index.html +1064 -64
  10. package/reader/vendor/KaTeX_AMS-Regular.woff2 +0 -0
  11. package/reader/vendor/KaTeX_Caligraphic-Bold.woff2 +0 -0
  12. package/reader/vendor/KaTeX_Caligraphic-Regular.woff2 +0 -0
  13. package/reader/vendor/KaTeX_Fraktur-Bold.woff2 +0 -0
  14. package/reader/vendor/KaTeX_Fraktur-Regular.woff2 +0 -0
  15. package/reader/vendor/KaTeX_Main-Bold.woff2 +0 -0
  16. package/reader/vendor/KaTeX_Main-BoldItalic.woff2 +0 -0
  17. package/reader/vendor/KaTeX_Main-Italic.woff2 +0 -0
  18. package/reader/vendor/KaTeX_Main-Regular.woff2 +0 -0
  19. package/reader/vendor/KaTeX_Math-BoldItalic.woff2 +0 -0
  20. package/reader/vendor/KaTeX_Math-Italic.woff2 +0 -0
  21. package/reader/vendor/KaTeX_SansSerif-Bold.woff2 +0 -0
  22. package/reader/vendor/KaTeX_SansSerif-Italic.woff2 +0 -0
  23. package/reader/vendor/KaTeX_SansSerif-Regular.woff2 +0 -0
  24. package/reader/vendor/KaTeX_Script-Regular.woff2 +0 -0
  25. package/reader/vendor/KaTeX_Size1-Regular.woff2 +0 -0
  26. package/reader/vendor/KaTeX_Size2-Regular.woff2 +0 -0
  27. package/reader/vendor/KaTeX_Size3-Regular.woff2 +0 -0
  28. package/reader/vendor/KaTeX_Size4-Regular.woff2 +0 -0
  29. package/reader/vendor/KaTeX_Typewriter-Regular.woff2 +0 -0
  30. package/reader/vendor/katex.LICENSE +21 -0
  31. package/reader/vendor/katex.NOTICE.md +26 -0
  32. package/reader/vendor/katex.min.css +1 -0
  33. 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;
@@ -24,11 +29,16 @@
24
29
  #bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; row-gap: 6px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--border); flex: none; }
25
30
  #bar button { font: inherit; }
26
31
  /* flex:none —— 列被拖窄时按钮不许被压缩(否则文字竖排、按钮变窄条) */
27
- .tbtn { flex: none; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
32
+ /* inline-flex + 固定高:图标/文字统一垂直居中,按钮高度不再随各家字体字形漂移 */
33
+ .tbtn { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); cursor: pointer; }
28
34
  .tbtn:hover { background: var(--bg); }
29
35
  .tbtn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
30
36
  .tbtn:disabled { opacity: .45; cursor: default; }
31
- #zh-toggle { font-weight: 700; }
37
+ /* 统一 SVG 图标:stroke=currentColor,active/disabled 的变色自动继承到图标 */
38
+ .tbtn svg.ic { width: 16px; height: 16px; flex: none; }
39
+ .tbtn svg.spin { transform-origin: center; animation: dpr-spin 1s linear infinite; }
40
+ @keyframes dpr-spin { to { transform: rotate(360deg); } }
41
+ @media (prefers-reduced-motion: reduce) { .tbtn svg.spin { animation-duration: 2.4s; } }
32
42
  #zoom-label { flex: none; min-width: 44px; text-align: center; color: var(--text-2); }
33
43
  #bar .spacer { flex: 1; }
34
44
  /* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
@@ -43,16 +53,28 @@
43
53
  @media (max-width: 560px) { #ver { display: none; } }
44
54
 
45
55
  /* ---------- PDF 区 ---------- */
56
+ /* #split-wrap 是分屏(模式 B)的两列行容器:非分屏时只有原文列,行为与改造前一致。
57
+ #pdf-pane 从 #main 的直接子节点变成它的子节点——#main 的 flex 语义不变。 */
46
58
  #main { display: flex; flex: 1; min-height: 0; position: relative; } /* relative:公式面板绝对定位的锚 */
47
- #pdf-pane { flex: 1; overflow: auto; padding: 16px; }
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; }
48
67
  /* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
49
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; }
50
70
  /* 翻页模式:单页垂直居中(safe center 同理,放大后顶部仍可滚到) */
51
- #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; }
52
72
 
53
73
  /* ---------- 翻页控件 ---------- */
54
74
  #pager { display: flex; align-items: center; gap: 4px; flex: none; }
55
- #page-input { width: 3.5em; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; text-align: right; }
75
+ /* 数字居中:text-align:right 会让单页码贴右框边、左侧空一块;
76
+ 上 padding 多 1px —— 数字无下伸部,光字心本就偏高,对称内边距看起来反而偏上 */
77
+ #page-input { width: 3.5em; padding: 4px 6px 2px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; line-height: 20px; text-align: center; }
56
78
  #page-total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; }
57
79
  /* 插件版本号(host 注入;未被替换时隐藏——比如旧 host 配新页面的错位场景) */
58
80
  #ver { flex: none; font-size: 10.5px; color: var(--text-2); opacity: .65; user-select: none; }
@@ -120,6 +142,25 @@
120
142
  #ask-send { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; }
121
143
  #ask-send:disabled { opacity: .5; cursor: default; }
122
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; }
123
164
 
124
165
  /* ---------- 翻译端点设置浮层 ---------- */
125
166
  /* 阅读器在窄 iframe 里,宽度要能收缩 */
@@ -143,30 +184,43 @@
143
184
  <div id="bar">
144
185
  <span id="status">论文伴读</span>
145
186
  <button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
146
- <button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
147
- <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
148
- <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none">⤓</button>
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>
194
+ <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none" data-icon="regen"></button>
195
+ <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none" data-icon="download"></button>
149
196
  <span class="spacer"></span>
150
- <button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none">▣</button>
197
+ <button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none" data-icon="paged"></button>
151
198
  <span id="pager" style="display:none">
152
- <button id="page-prev" class="tbtn" title="上一页">‹</button>
199
+ <button id="page-prev" class="tbtn" title="上一页" data-icon="prev"></button>
153
200
  <input id="page-input" type="text" inputmode="numeric" title="页码,回车跳转">
154
201
  <span id="page-total">/ ?</span>
155
- <button id="page-next" class="tbtn" title="下一页">›</button>
202
+ <button id="page-next" class="tbtn" title="下一页" data-icon="next"></button>
156
203
  </span>
157
- <button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
158
- <button id="zoom-out" class="tbtn">−</button>
204
+ <button id="cfg-open" class="tbtn" title="翻译端点设置" data-icon="settings"></button>
205
+ <button id="zoom-out" class="tbtn" title="缩小" data-icon="minus"></button>
159
206
  <span id="zoom-label">100%</span>
160
- <button id="zoom-in" class="tbtn">+</button>
207
+ <button id="zoom-in" class="tbtn" title="放大" data-icon="plus"></button>
161
208
  <button id="zoom-fit" class="tbtn">适宽</button>
162
- <button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)">∑ 公式</button>
209
+ <button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)" data-icon="sigma">公式</button>
163
210
  <span id="ver" title="插件版本">__DPR_VERSION__</span>
164
211
  </div>
165
212
 
166
213
  <div id="main">
167
- <div id="pdf-pane">
168
- <div id="pdf-pages"></div>
169
- <div id="empty" style="display:none"></div>
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>
170
224
  </div>
171
225
  <div id="eq-panel">
172
226
  <h3>公式</h3>
@@ -180,11 +234,19 @@
180
234
  <textarea id="ask-input" rows="2" placeholder="围绕选中内容提问…"></textarea>
181
235
  <div id="ask-actions">
182
236
  <button id="ask-copy" style="display:none">复制 LaTeX</button>
237
+ <button id="ask-translate" style="display:none" title="翻译选中内容(含公式时用 MinerU 的 LaTeX 还原)">译</button>
183
238
  <button id="ask-cancel">取消</button>
184
239
  <button id="ask-send">提问</button>
185
240
  </div>
186
241
  </div>
187
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
+
188
250
  <div id="cfg-pop">
189
251
  <h3>翻译端点</h3>
190
252
  <div id="cfg-hint">选预设供应商,或选「自定义」填任意 OpenAI 兼容端点(通义 / 本地 vLLM 等)。保存前会先测试连接。</div>
@@ -211,6 +273,29 @@
211
273
  'use strict';
212
274
  /* ================= 基础 ================= */
213
275
  const $ = (id) => document.getElementById(id);
276
+ /* 顶栏图标:统一的 24×24 描边图标集(stroke=currentColor,active/disabled 变色自动跟随按钮)。
277
+ 曾经混用 Unicode 字符(↻/⤓/▣/⚙/+…)——各自渲染自不同字体,粗细、大小、基线互不一致。 */
278
+ const ICONS = {
279
+ translate: '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
280
+ regen: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
281
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
282
+ paged: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
283
+ scroll: '<path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
284
+ prev: '<path d="m15 18-6-6 6-6"/>',
285
+ next: '<path d="m9 18 6-6-6-6"/>',
286
+ settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
287
+ minus: '<path d="M5 12h14"/>',
288
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
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"/>',
294
+ spin: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
295
+ };
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>`;
297
+ // 静态按钮按 data-icon 注入一次;动态按钮(翻译/重翻/模式切换)在状态变化时用同一函数换图标
298
+ document.querySelectorAll('#bar [data-icon]').forEach((b) => b.insertAdjacentHTML('afterbegin', iconHtml(b.dataset.icon)));
214
299
  const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) => {
215
300
  if (!r.ok) {
216
301
  // 服务端错误体有两种:{error}(参数/权限)与 {reason}(zh/generate 的未启动原因,带完整说明)
@@ -218,6 +303,9 @@ const api = (p, opts) => fetch('/paper-reader/api' + p, opts).then(async (r) =>
218
303
  const err = new Error(body.error || body.reason || ('HTTP ' + r.status));
219
304
  err.code = body.code; // 机器可读码(如 no-endpoint)——让调用方靠它分支,别去匹配中文文案
220
305
  err.status = r.status;
306
+ // 划词翻译的 502 带上游状态码:客户端据此出 §3.7② 的分档文案(拒绝/限流/端点错误),
307
+ // 而不是去 match 服务端的自由文案。
308
+ err.upstreamStatus = body.upstreamStatus;
221
309
  throw err;
222
310
  }
223
311
  return r.json();
@@ -238,7 +326,11 @@ $('pdf-path').onclick = async () => {
238
326
  let current = null; // { topic, name }
239
327
  let zhState = { zh: false, dual: false, busy: false };
240
328
  let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它
241
- 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'
242
334
  // 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载,
243
335
  // 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
244
336
  const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
@@ -257,6 +349,10 @@ function pdfUrl(variant) {
257
349
  async function openPaper(topic, name, opts = {}) {
258
350
  if (!name) return;
259
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();
260
356
  localStorage.setItem('dpr.last', JSON.stringify(current));
261
357
  curPage = opts.page || 1; // 换论文从第一页(或跳转目标页)开始;变体切换/重译不走这里,页码保持
262
358
  $('status').textContent = '加载 PDF…';
@@ -272,17 +368,28 @@ async function openPaper(topic, name, opts = {}) {
272
368
  pathBtn.title = (meta.pdfPath || '') + '(点击复制)';
273
369
  pathBtn.style.display = meta.pdfPath ? '' : 'none';
274
370
  await refreshZhState();
275
- // 恢复该论文上次的语言版本(译文已不存在时回落原文)
371
+ // 恢复该论文上次的视图模式(持久化复用同一个键,取值域扩到 'split';见 §4.3)
276
372
  const saved = localStorage.getItem(variantKey(topic, name));
277
- zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
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
278
376
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
279
- if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
377
+ // 译文版页宽与原文不同(对照双栏≈2×),分屏每列只有一半宽 ⇒ 都先进适宽
378
+ if (zhVariant || viewMode === 'split') { fitMode = true; fitBtn.classList.add('active'); }
280
379
  // 公式索引(MinerU content_list):**必须在 loadPdf 之前到货**——热区是在 paintPage 里
281
380
  // 按 eqIndex 铺的,先渲染会漏画/残留上一篇的矩形;又必须排在 zhVariant 定稿之后——
282
381
  // eqHotEnabled()/renderEqPanel() 都按 zhVariant 决定「译文视图关热区」。失败不影响打开文献。
283
382
  await loadFormulas();
284
383
  updateDlBtn();
285
- await loadPdf(pdfUrl(zhVariant));
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
+ }
286
393
  if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
287
394
  }
288
395
 
@@ -313,7 +420,7 @@ async function refreshZhState() {
313
420
  const btn = $('zh-toggle');
314
421
  btn.style.display = '';
315
422
  btn.disabled = zhState.busy;
316
- btn.textContent = zhState.busy ? '译' : '中';
423
+ btn.innerHTML = iconHtml(zhState.busy ? 'spin' : 'translate'); // busy:旋转弧图标(原「译」字)
317
424
  btn.title = zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`)
318
425
  : (zhState.zh || zhState.dual) ? '切换中文译文'
319
426
  : '还没有中文版,点击生成(babeldoc,需要几分钟)';
@@ -326,30 +433,112 @@ async function refreshZhState() {
326
433
  delete statusEl.dataset.phaseShown;
327
434
  if (statusMeta) statusEl.textContent = statusMeta;
328
435
  }
329
- // ↻ 只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
436
+ // 重翻按钮只在「已有译文且空闲」时出现;状态变化时顺手拆掉两击确认的待确认态
330
437
  const regen = $('zh-regen');
331
438
  const canRegen = (zhState.zh || zhState.dual) && !zhState.busy;
332
439
  regen.style.display = canRegen ? '' : 'none';
333
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();
334
484
  }
335
485
 
336
486
  $('zh-toggle').onclick = async () => {
337
- if (!current || zhState.busy) return;
487
+ if (!current || zhState.busy || viewMode === 'split') return;
338
488
  if (zhState.dual || zhState.zh) {
339
- // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照
340
- zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
341
- localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
342
- $('zh-toggle').classList.toggle('active', zhVariant !== null);
343
- updateDlBtn();
344
- // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
345
- fitMode = true; fitBtn.classList.add('active');
346
- renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉)
347
- await loadPdf(pdfUrl(zhVariant));
489
+ // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照(既有语义一字不动)
490
+ await setViewMode(zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : 'orig');
348
491
  return;
349
492
  }
350
493
  await startGenerate(false);
351
494
  };
352
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
+
353
542
  // 生成/重翻共用:force=true 时服务端先删旧译文再启动;成功后轮询到完成
354
543
  async function startGenerate(force) {
355
544
  try {
@@ -366,7 +555,7 @@ async function startGenerate(force) {
366
555
  if (e.code === 'no-endpoint') { cfgRetryForce = force; openCfgPop(true); return; }
367
556
  toast(e.message, 8000); return;
368
557
  }
369
- $('zh-toggle').disabled = true; $('zh-toggle').textContent = '译';
558
+ $('zh-toggle').disabled = true; $('zh-toggle').innerHTML = iconHtml('spin');
370
559
  $('zh-regen').style.display = 'none';
371
560
  const timer = setInterval(async () => {
372
561
  await refreshZhState();
@@ -374,8 +563,9 @@ async function startGenerate(force) {
374
563
  clearInterval(timer);
375
564
  if (zhState.dual || zhState.zh) {
376
565
  toast('中文版已生成');
377
- // 重翻完成时用户可能正停在译文视图:原地刷新同一变体,而不是来回切换
378
- if (zhVariant) await loadPdf(pdfUrl(zhVariant));
566
+ // 重翻完成时用户可能正停在译文视图/分屏:原地刷新,而不是来回切换
567
+ if (viewMode === 'split') await renderSplit();
568
+ else if (zhVariant) await loadPdf(pdfUrl(zhVariant));
379
569
  else $('zh-toggle').click();
380
570
  }
381
571
  else toast('生成失败:' + (zhState.error || '未知原因'), 8000);
@@ -383,14 +573,14 @@ async function startGenerate(force) {
383
573
  }, 5000);
384
574
  }
385
575
 
386
- /* ================= 重新翻译(↻,两击确认) ================= */
576
+ /* ================= 重新翻译(重翻按钮,两击确认) ================= */
387
577
  // 不用原生 confirm:阅读器跑在 dsh 桌面壳的 iframe 里,原生对话框行为不可靠。
388
578
  // 第一击把按钮变成「重翻?」待确认态(3 秒有效),第二击才真删真跑。
389
579
  let regenArmTimer = null;
390
580
  function disarmRegen() {
391
581
  clearTimeout(regenArmTimer); regenArmTimer = null;
392
582
  const btn = $('zh-regen');
393
- btn.textContent = '↻'; btn.classList.remove('active');
583
+ btn.innerHTML = iconHtml('regen'); btn.classList.remove('active');
394
584
  }
395
585
  $('zh-regen').onclick = async () => {
396
586
  if (!current || zhState.busy) return;
@@ -435,9 +625,10 @@ const providerOf = (baseUrl) => PROVIDERS.find((p) => p.id !== 'custom' && normU
435
625
  };
436
626
  })();
437
627
 
438
- // 保存成功后是否自动接着生成:点「中」/「↻」发现没配 → 弹本表单 → 填完直接开跑
628
+ // 保存成功后是否自动接着生成:点「中译」/「重翻」发现没配 → 弹本表单 → 填完直接开跑
439
629
  let cfgRetryGenerate = false;
440
- let cfgRetryForce = false; // 待重试的是不是「重翻」(↻ 进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
630
+ let cfgRetryForce = false; // 待重试的是不是「重翻」(regen 图标进来时旧译文还在,zh-toggle 点击语义不对,必须带 force)
631
+ let cfgRetryTranslate = false; // 待重试的是不是「划词翻译」(点「译」发现没配端点 → 填完自动重试这次翻译)
441
632
 
442
633
  async function openCfgPop(retryGenerate) {
443
634
  cfgRetryGenerate = Boolean(retryGenerate);
@@ -469,14 +660,14 @@ async function openCfgPop(retryGenerate) {
469
660
  pop.style.top = Math.max(8, (window.innerHeight - pop.offsetHeight) / 3) + 'px';
470
661
  $('cfg-key').focus();
471
662
  }
472
- 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; }
473
664
 
474
665
  $('cfg-open').onclick = () => openCfgPop(false);
475
666
  $('cfg-cancel').onclick = closeCfgPop;
476
667
 
477
668
  $('cfg-save').onclick = async () => {
478
- // retry/force 先捞出来:closeCfgPop 会把这两个待确认态清零
479
- 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;
480
671
  btn.disabled = true; btn.textContent = '测试中…';
481
672
  try {
482
673
  // 服务端先打一发极小的 chat/completions 验证 url/key/model,通过才落盘
@@ -497,7 +688,8 @@ $('cfg-save').onclick = async () => {
497
688
  }
498
689
  closeCfgPop();
499
690
  await refreshZhState();
500
- if (retry) startGenerate(force); // 沿用触发来源的语义:「中」=生成,「↻」=重翻
691
+ if (retry) startGenerate(force); // 沿用触发来源的语义:zh-toggle=生成,regen=重翻
692
+ else if (retryTr) translateSelection(); // 划词翻译发现没配端点 → 填完自动重试这一次翻译(§3.7①)
501
693
  else toast('翻译端点已保存');
502
694
  };
503
695
 
@@ -539,22 +731,27 @@ let renderToken = 0;
539
731
  });
540
732
  })();
541
733
 
542
- async function loadPdf(url) {
734
+ async function loadPdf(url, opts = {}) {
543
735
  await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
544
736
  pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
545
737
  $('empty').style.display = 'none';
546
738
  // 翻页控件与模式切换按钮:文档就绪后才出现
547
739
  $('pager').style.display = '';
548
740
  $('mode-toggle').style.display = '';
741
+ // 分屏(模式 B)自己安排两列的渲染与适宽:这里只把原文文档装上,不触发单列渲染
742
+ if (opts.deferRender) return;
549
743
  if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → rerender
550
744
  await rerender();
551
745
  }
552
746
 
553
- /** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。 */
554
- function makePageNode(i, viewport) {
747
+ /** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。
748
+ * `doc` = 'orig' | 'zh':**无条件**写在每个页节点上(分屏时两个文档的页号相同,热区几何
749
+ * 与选区归属都靠它判别;只有分屏才写的话普通模式的选择器会取到 null)。 */
750
+ function makePageNode(i, viewport, doc = 'orig') {
555
751
  const div = document.createElement('div');
556
752
  div.className = 'page';
557
753
  div.dataset.page = i;
754
+ div.dataset.doc = doc;
558
755
  div.style.width = viewport.width + 'px';
559
756
 
560
757
  const canvas = document.createElement('canvas');
@@ -599,8 +796,10 @@ async function paintPage(page, div, viewport, token) {
599
796
  paintEqLayer(div, viewport, +div.dataset.page);
600
797
  }
601
798
 
602
- /** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */
799
+ /** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。
800
+ * 分屏(模式 B)是第三套布局,转到 renderSplit();renderPaged/renderAllPages 的既有语义**不变**。 */
603
801
  function rerender() {
802
+ if (viewMode === 'split') return renderSplit();
604
803
  return pageMode === 'paged' ? renderPaged() : renderAllPages();
605
804
  }
606
805
 
@@ -683,9 +882,18 @@ let fitMode = true;
683
882
  const fitBtn = $('zoom-fit');
684
883
  fitBtn.classList.add('active');
685
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
+ }
686
894
  function fitToWidth() {
687
895
  if (!pdfDoc) return // resize 可能先于首次 loadPdf 触发(布局换位/条带注入会改容器尺寸)
688
- const paneWidth = $('pdf-pane').clientWidth - 32
896
+ const paneWidth = fitPaneWidth()
689
897
  if (paneWidth <= 0) return // 隐藏期间尺寸为 0,算出的缩放无意义
690
898
  pdfDoc.getPage(1).then((p) => setZoom(paneWidth / p.getViewport({ scale: 1 }).width))
691
899
  }
@@ -693,13 +901,25 @@ $('zoom-in').onclick = () => { exitFitMode(); setZoom(zoomScale + 0.25); };
693
901
  $('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); };
694
902
  fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); };
695
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
+ }
696
910
  if (fitMode) fitToWidth(); // setZoom → rerender,顺带消化 pendingRerender
697
911
  else if (pendingRerender && pdfDoc) { pendingRerender = false; rerender(); } // 隐藏期间欠下的重渲,恢复尺寸后补画
698
912
  });
699
913
 
914
+ /** 原文列的页节点:分屏时中文列有**同样的 page 号**,不加限定会取到中文列(C18/R7)。
915
+ * 统一收敛到 `#pdf-pages` 之下——四个引文高亮/跳转入口共用。 */
916
+ function origPageNode(n) {
917
+ return document.querySelector(`#pdf-pages .page[data-page="${n}"]`);
918
+ }
919
+
700
920
  /* ================= 翻页模式:页码控件 + 模式切换 + 键盘 ================= */
701
921
  function flashPage() {
702
- const div = document.querySelector(`.page[data-page="${curPage}"]`);
922
+ const div = origPageNode(curPage);
703
923
  if (!div) return;
704
924
  div.classList.add('flash');
705
925
  setTimeout(() => div.classList.remove('flash'), 1600);
@@ -722,7 +942,7 @@ async function goPage(n) {
722
942
  if (pageMode === 'paged') {
723
943
  if (p === curPage) { flashPage(); return; }
724
944
  curPage = p;
725
- await renderPaged();
945
+ await rerender();
726
946
  flashPage();
727
947
  } else {
728
948
  curPage = p;
@@ -749,10 +969,10 @@ $('page-input').addEventListener('blur', () => {
749
969
  });
750
970
 
751
971
  /** 模式切换:paged→scroll 先全量渲染再跳回当前页;scroll→paged 用观察器最近回写的 curPage。
752
- 图标显示「当前模式」:▣ 单页翻页(active)/ ≣ 连续滚动。 */
972
+ 图标显示「当前模式」:书本 = 单页翻页(active)/ 卷轴 = 连续滚动。 */
753
973
  function updateModeToggle() {
754
974
  const btn = $('mode-toggle');
755
- btn.textContent = pageMode === 'paged' ? '▣' : '≣';
975
+ btn.innerHTML = iconHtml(pageMode === 'paged' ? 'paged' : 'scroll');
756
976
  btn.classList.toggle('active', pageMode === 'paged');
757
977
  btn.title = pageMode === 'paged' ? '当前:单页翻页,点击切回连续滚动' : '当前:连续滚动,点击切到单页翻页';
758
978
  updatePager();
@@ -763,10 +983,10 @@ $('mode-toggle').onclick = async () => {
763
983
  localStorage.setItem('dpr.pageMode', pageMode);
764
984
  updateModeToggle();
765
985
  if (pageMode === 'paged') {
766
- await renderPaged();
986
+ await rerender(); // 分屏时转 renderSplit(两列同渲 curPage)
767
987
  flashPage();
768
988
  } else {
769
- await renderAllPages();
989
+ await rerender(); // 非分屏时等价于 renderAllPages
770
990
  jumpToPage(curPage);
771
991
  }
772
992
  };
@@ -785,7 +1005,7 @@ document.addEventListener('keydown', (e) => {
785
1005
 
786
1006
  function jumpToPage(n) {
787
1007
  if (pageMode === 'paged') { goPage(n); return; }
788
- const div = document.querySelector(`.page[data-page="${n}"]`);
1008
+ const div = origPageNode(n); // 引文跳转只作用于原文列(分屏时中文列不得被滚到)
789
1009
  if (!div) return;
790
1010
  div.scrollIntoView({ behavior: 'smooth', block: 'start' });
791
1011
  div.classList.add('flash');
@@ -803,7 +1023,7 @@ function clearHighlights() {
803
1023
  document.addEventListener('mousedown', clearHighlights);
804
1024
 
805
1025
  function highlightSnippet(page, q) {
806
- const spans = [...(document.querySelector(`.page[data-page="${page}"]`)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])];
1026
+ const spans = [...(origPageNode(page)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])];
807
1027
  if (!spans.length || !q) return false;
808
1028
  let hay = '';
809
1029
  const startOf = [];
@@ -836,12 +1056,12 @@ async function jumpTo(page, q) {
836
1056
  if (pageMode === 'paged') {
837
1057
  if (!pdfDoc) return;
838
1058
  const p = Math.min(p0, pdfDoc.numPages);
839
- if (p !== curPage) { curPage = p; await renderPaged(); }
1059
+ if (p !== curPage) { curPage = p; await rerender(); }
840
1060
  if (!(q && highlightSnippet(p, q))) flashPage();
841
1061
  return;
842
1062
  }
843
1063
  for (let i = 0; i < 100; i++) {
844
- if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) {
1064
+ if (pdfDoc && origPageNode(Math.min(p0, pdfDoc.numPages))) {
845
1065
  const p = Math.min(p0, pdfDoc.numPages);
846
1066
  if (!(q && highlightSnippet(p, q))) jumpToPage(p);
847
1067
  return;
@@ -936,11 +1156,17 @@ function eqGeom(eq, viewport) {
936
1156
  };
937
1157
  }
938
1158
 
939
- /** 为已渲染的一页铺热区层(只画描边,不接事件)。 */
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 个参数。 */
940
1164
  function paintEqLayer(pageDiv, viewport, pageNo) {
941
1165
  const old = pageDiv.querySelector('.eqLayer');
942
1166
  if (old) old.remove();
943
1167
  const items = [];
1168
+ const doc = pageDiv.dataset.doc || 'orig';
1169
+ if (doc !== 'orig') return; // 中文列:不建层、不写 EQ_BY_PAGE
944
1170
  if (eqHotEnabled()) {
945
1171
  const lay = document.createElement('div');
946
1172
  lay.className = 'eqLayer';
@@ -977,6 +1203,8 @@ function hitTestFormula(e) {
977
1203
  const el = e.target;
978
1204
  const pageDiv = el && el.closest ? el.closest('.page[data-page]') : null;
979
1205
  if (!pageDiv) return null;
1206
+ // 中文列(分屏)不得命中:它的页号与原文列相同,几何却属于原文页面(C18/R7)
1207
+ if (pageDiv.dataset.doc && pageDiv.dataset.doc !== 'orig') return null;
980
1208
  const items = EQ_BY_PAGE.get(+pageDiv.dataset.page);
981
1209
  if (!items || !items.length) return null;
982
1210
  const box = pageDiv.getBoundingClientRect();
@@ -1123,7 +1351,7 @@ async function transcribeWithMineru(btn) {
1123
1351
  toast('MinerU 转录完成');
1124
1352
  await loadFormulas();
1125
1353
  // 新索引到手:按**当前模式**重渲一次把热区铺上去(翻页模式下必须仍是 renderPaged,
1126
- // 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏 ▣ 的 active 状态脱节)
1354
+ // 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏模式切换按钮的 active 状态脱节)
1127
1355
  if (pdfDoc) await rerender();
1128
1356
  } catch (e) {
1129
1357
  toast('MinerU 转录失败:' + e.message, 6000);
@@ -1158,6 +1386,17 @@ function showAskPop(o) {
1158
1386
  $('ask-quote').textContent = o.quote.length > 140 ? o.quote.slice(0, 140) + '…' : o.quote;
1159
1387
  $('ask-input').value = '';
1160
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:新选区/新引用必须让在飞请求失效,否则旧译文会盖到新锚点上
1161
1400
  askPop.style.display = 'block';
1162
1401
  // 夹在 iframe 视口内(rightbar 窄列里也要能用)
1163
1402
  const popW = 336, popH = 200;
@@ -1187,7 +1426,22 @@ document.addEventListener('mouseup', (e) => {
1187
1426
  const node = sel.anchorNode && sel.anchorNode.parentElement;
1188
1427
  if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; }
1189
1428
  const range = sel.getRangeAt(0).getBoundingClientRect();
1190
- askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null, kind: 'text' };
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 */ }
1191
1445
  showAskPop({ quote: text, x: e.clientX, y: range.bottom + 8 });
1192
1446
  }, 10);
1193
1447
  });
@@ -1246,6 +1500,752 @@ $('ask-send').onclick = async () => {
1246
1500
  };
1247
1501
  $('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('ask-send').click(); } });
1248
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) s = s.slice(0, cap);
1761
+ if (!s) continue;
1762
+ total += s.length;
1763
+ capped.push(c.kind === 'text' ? { page: c.page, kind: 'text', text: s } : { page: c.page, kind: 'formula', latex: s });
1764
+ }
1765
+ return capped;
1766
+ }
1767
+
1768
+ /** 选区覆盖的文本节点(按文档序)。手写递归而不是 createTreeWalker:
1769
+ * 选区只需要「起点与终点之间的文本节点」,而 TreeWalker 在部分 webview/测试桩里缺实现。 */
1770
+ function textNodesUnder(node) {
1771
+ const out = [];
1772
+ const stack = [node];
1773
+ while (stack.length) {
1774
+ const n = stack.pop();
1775
+ if (!n) continue;
1776
+ if (n.nodeType === 3) { out.push(n); continue; }
1777
+ const kids = n.childNodes ? [...n.childNodes] : [];
1778
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
1779
+ }
1780
+ return out;
1781
+ }
1782
+
1783
+ /** 选区内每个字符 + 它的页面局部矩形(页面局部 = rect 减 pageDiv 的 bounding rect;
1784
+ * `.page` 上没有 CSS transform,缩放体现在 viewport 尺寸里 ⇒ 与滚动/翻页/缩放都无关,
1785
+ * 与既有 hitTestFormula 用的是同一套坐标)。 */
1786
+ function collectSelectionChars(range) {
1787
+ const root = range.commonAncestorContainer;
1788
+ if (!root) return [];
1789
+ const nodes = textNodesUnder(root).filter((n) => {
1790
+ if (typeof range.intersectsNode === 'function') { try { return range.intersectsNode(n); } catch { return true; } }
1791
+ return n === range.startContainer || n === range.endContainer;
1792
+ });
1793
+ const out = [];
1794
+ for (const node of nodes) {
1795
+ const data = node.data ?? '';
1796
+ let start = 0, end = data.length;
1797
+ if (node === range.startContainer) start = range.startOffset;
1798
+ if (node === range.endContainer) end = range.endOffset;
1799
+ const parent = node.parentElement;
1800
+ const pageDiv = parent && parent.closest ? parent.closest('.page[data-doc]') : null;
1801
+ if (!pageDiv) continue;
1802
+ const doc = pageDiv.dataset.doc || 'orig';
1803
+ if (doc !== 'orig') continue; // 分屏时中文列不参与公式归属(同一套几何只对原文成立)
1804
+ const pageNo = +pageDiv.dataset.page || 0;
1805
+ const box = pageDiv.getBoundingClientRect();
1806
+ const cr = document.createRange();
1807
+ for (let i = start; i < end; i++) {
1808
+ let rect = null;
1809
+ try {
1810
+ cr.setStart(node, i); cr.setEnd(node, i + 1);
1811
+ const r = cr.getBoundingClientRect();
1812
+ if (r && r.width > 0 && r.height > 0) {
1813
+ rect = [r.left - box.left, r.top - box.top, r.right - box.left, r.bottom - box.top];
1814
+ }
1815
+ } catch { rect = null; } // 换行伪影/失效 Range:退化成纯文本字符
1816
+ out.push({ ch: data[i], page: pageNo, rect });
1817
+ }
1818
+ }
1819
+ return out;
1820
+ }
1821
+
1822
+ /** #tr-note 的诚实降级文案(§3.8 文案表,优先级自上而下)。
1823
+ * ⚠️ 与需求表的一处**有意偏离**(已在实现报告里回报队长):该表把 `unavailable` 排在
1824
+ * `formulaSkipped>0` 之前,但过短选区(如只选一个 `F`)会同时命中两者,按表顺序会显示
1825
+ * 「没能定位到原文公式」——而验收项 A13 要求显示「选区太短,公式未还原」。这里让
1826
+ * 「什么都没还原 + 有过短 chunk」先报「选区太短」:它更准确,也不会误导用户以为译文是准的。 */
1827
+ function trNote(resp) {
1828
+ const reason = eqIndex && eqIndex.reason;
1829
+ if (eqIndex && eqIndex.source === 'none' && reason === 'no-mineru-artifact') {
1830
+ return '⚠ 该论文未用 MinerU 转录:原文里的公式在文本层不可读,译文可能不准(顶栏 ∑ 公式 → 「用 MinerU 重新转录」)';
1831
+ }
1832
+ if (reason === 'no-equations') return '⚠ MinerU 产物里没有公式块:公式可能不准';
1833
+ if (reason === 'bad-artifact') return '⚠ MinerU 产物损坏或不可读,已忽略:公式可能不准';
1834
+ if (resp.formulaLost > 0) return `⚠ 有 ${resp.formulaLost} 个公式未原样保留,请对照原文`;
1835
+ if (resp.formulaSkipped > 0 && resp.formulaApplied === 0) return '⚠ 选区太短,公式未还原';
1836
+ if (resp.formulaStatus === 'unavailable' && resp.hasBlockIndex) return '⚠ 没能定位到原文公式,译文可能不准';
1837
+ if (resp.formulaStatus === 'partial') return '⚠ 部分公式未能还原,译文可能不准';
1838
+ if (resp.formulaSkipped > 0) return '⚠ 选区太短,公式未还原';
1839
+ return ''; // applied / formulaTotal===0:不显示任何提示
1840
+ }
1841
+
1842
+ /** 失败反馈(§3.7② 表,逐条)。返回 {status, toast}:气泡状态行与 toast 的措辞不同处照表分开。 */
1843
+ function trFailure(e) {
1844
+ if (e && e.clientTimeout) return { status: '翻译超时,请重试', toast: '翻译超时,请重试' };
1845
+ if (e && e.code === 'timeout') return { status: '翻译超时,请重试', toast: '翻译超时,请重试' };
1846
+ if (e && e.code === 'network') return { status: '翻译失败:无法连接端点', toast: '翻译失败:无法连接端点' };
1847
+ const up = e && e.upstreamStatus;
1848
+ if (up === 401 || up === 403) return { status: '翻译失败:端点拒绝(请检查 API key)', toast: `翻译失败:端点拒绝(HTTP ${up})` };
1849
+ if (up === 429) return { status: '翻译失败:请求过于频繁,稍后再试', toast: '翻译失败:请求过于频繁,稍后再试' };
1850
+ if (typeof up === 'number' && up >= 500) return { status: '翻译失败:端点错误(HTTP 5xx)', toast: '翻译失败:端点错误(HTTP 5xx)' };
1851
+ const m = '翻译失败:' + ((e && e.message) || '未知原因');
1852
+ return { status: m, toast: m };
1853
+ }
1854
+
1855
+ /** 划词翻译入口(#ask-translate.onclick)。 */
1856
+ async function translateSelection() {
1857
+ // 中文列(分屏,doc==='zh')不参与:几何只对原文页成立(R-07;按钮本身也不会显示)
1858
+ if (!current || !askSelection || askSelection.kind !== 'text' || zhVariant !== null) return;
1859
+ if ((askSelection.doc ?? 'orig') !== 'orig') return;
1860
+ if (trBusy) return;
1861
+ const range = askSelection.range;
1862
+ if (!range || !range.startContainer || range.startContainer.isConnected === false) {
1863
+ toast('页面已重绘,请重新选择', 4000); // R5:绝不缓存矩形,也不静默失败
1864
+ return;
1865
+ }
1866
+ // 用户点按钮时浏览器可能已把选区清掉:先把存下来的 Range 放回去,rects 才取得到
1867
+ const sel = window.getSelection();
1868
+ if (sel && sel.isCollapsed && typeof sel.addRange === 'function') {
1869
+ try { sel.removeAllRanges(); sel.addRange(range); } catch { /* 失效选区:下面按空选区处理 */ }
1870
+ }
1871
+ let text = '';
1872
+ try { text = String(range.toString() || ''); } catch { text = ''; }
1873
+ if (!text.trim()) { toast('选区已失效,请重新选择', 4000); return; }
1874
+ const chars = collectSelectionChars(range);
1875
+ const chunks = buildChunks(chars, eqItemsFor);
1876
+ if (!chunks.length) { toast('选区已失效,请重新选择', 4000); return; }
1877
+ if (chars.length > MAX_SELECTION_CHARS) toast(`选区过长,只翻译前 ${MAX_SELECTION_CHARS} 字符`, 4000);
1878
+
1879
+ askPop.style.display = 'none'; // 同一锚点必然重叠 ⇒ 气泡「变成」译文气泡(§3.1)
1880
+ showTrPop('翻译中…', '', '');
1881
+ trBusy = true;
1882
+ const seq = ++trSeq;
1883
+ const ac = new AbortController();
1884
+ let clientTimeout = false;
1885
+ const timer = setTimeout(() => { clientTimeout = true; ac.abort(); }, TR_CLIENT_TIMEOUT_MS);
1886
+ // 诊断可读性:记录本次送出的 chunk 规模(不含文本内容、不含 key)
1887
+ const charsSent = chunks.reduce((n, c) => n + (c.kind === 'text' ? c.text.length : c.latex.length), 0);
1888
+ try {
1889
+ const resp = await api('/translate-selection', {
1890
+ method: 'POST', headers: { 'content-type': 'application/json' },
1891
+ body: JSON.stringify({ ...current, chunks }),
1892
+ signal: ac.signal,
1893
+ });
1894
+ if (seq !== trSeq) return; // 过期保护:用户已换选区/换文献
1895
+ showTrPop('', resp.translation, trNote(resp));
1896
+ } catch (e) {
1897
+ if (seq !== trSeq) return;
1898
+ if (clientTimeout) e.clientTimeout = true;
1899
+ if (e && e.code === 'no-endpoint') {
1900
+ // 未配置端点:打开既有设置浮层,保存成功后自动重试这次翻译(§3.7①)
1901
+ showTrPop('未配置翻译端点', '', '在上面的设置里填好端点并保存,会自动重试这次翻译');
1902
+ cfgRetryTranslate = true;
1903
+ openCfgPop(false);
1904
+ return;
1905
+ }
1906
+ const f = trFailure(e);
1907
+ showTrPop(f.status, '', '');
1908
+ toast(f.toast + `(选区 ${charsSent} 字符)`, 8000);
1909
+ } finally {
1910
+ clearTimeout(timer);
1911
+ if (seq === trSeq) trBusy = false;
1912
+ }
1913
+ }
1914
+
1915
+ $('ask-translate').onclick = () => translateSelection();
1916
+ document.addEventListener('mousedown', (e) => { if (!trPop.contains(e.target)) hideTrPop(); });
1917
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTrPop(); });
1918
+
1919
+ /* =====================================================================================
1920
+ 分屏对照(模式 B):原文 PDF ↔ -zh.pdf 并排同步滚动 —— §4.2/§4.4
1921
+ · 两列各自一个 pdf.js 文档;全局 pdfDoc **保持指向原文**(既有逻辑零改动)
1922
+ · zhVariant 保持 null ⇒ eqHotEnabled()/公式面板/适宽分支继续按「原文」工作(C17)
1923
+ · 热区只在原文列铺(paintEqLayer 按 pageDiv.dataset.doc 早退)、只在原文列命中(C18/R7)
1924
+ ===================================================================================== */
1925
+ const SPLIT_MIN_WIDTH = 640; // 冻结:面板太窄不做分屏(§4.2)
1926
+ let pdfDocZh = null;
1927
+ let splitSyncing = false;
1928
+ let splitRaf = 0;
1929
+ let splitWarned = false;
1930
+
1931
+ const rafFn = (fn) => (typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame(fn) : setTimeout(fn, 0));
1932
+ const crafFn = (id) => { if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(id); else clearTimeout(id); };
1933
+
1934
+ /** 纯函数:滚动位置 → 「顶部页号 + 页内比例」(§4.2 锚点)。
1935
+ * pages: [{page, offsetTop, offsetHeight}],按 DOM 序。 */
1936
+ function anchorOf(pages, scrollTop) {
1937
+ if (!pages || !pages.length) return null;
1938
+ let page = pages[pages.length - 1];
1939
+ for (const p of pages) { if (p.offsetTop + p.offsetHeight > scrollTop) { page = p; break; } }
1940
+ return { page: page.page, ratio: (scrollTop - page.offsetTop) / Math.max(1, page.offsetHeight) };
1941
+ }
1942
+
1943
+ /** 纯函数:锚点 → 目标列 scrollTop。页数相同(实测常态 19/19)走同页号;不同则按比例映射,
1944
+ * 并让调用方给出「页数不一致」的一次性提示。 */
1945
+ function syncTargetOf(anchor, targetPages, srcPageCount) {
1946
+ if (!anchor || !targetPages || !targetPages.length) return null;
1947
+ const n = targetPages.length;
1948
+ let mismatch = false;
1949
+ let idx = targetPages.findIndex((p) => p.page === anchor.page);
1950
+ if (idx < 0) {
1951
+ mismatch = true;
1952
+ idx = Math.min(n - 1, Math.max(0, Math.round((anchor.page - 1) * n / Math.max(1, srcPageCount | 0))));
1953
+ } else if (srcPageCount && srcPageCount !== n) {
1954
+ mismatch = true;
1955
+ }
1956
+ const p = targetPages[idx];
1957
+ return { top: p.offsetTop + anchor.ratio * p.offsetHeight, mismatch };
1958
+ }
1959
+
1960
+ /** 一列的页几何(DOM 序)。 */
1961
+ function columnPages(box) {
1962
+ return [...box.children].filter((c) => c.className === 'page')
1963
+ .map((d) => ({ page: +d.dataset.page || 1, offsetTop: d.offsetTop, offsetHeight: d.offsetHeight }));
1964
+ }
1965
+
1966
+ const splitCols = () => [
1967
+ { pane: $('pdf-pane'), box: $('pdf-pages') },
1968
+ { pane: $('pdf-pane-zh'), box: $('pdf-pages-zh') },
1969
+ ];
1970
+
1971
+ /* =====================================================================================
1972
+ 同步滚动开关(模式 B 的可选项)—— 两处入口(分屏控件旁 + dsh 设置面板)、全局记忆、默认开
1973
+ · 持久化键 `dpr.syncScroll`('1'/'0'):**故意不带 topic/name**,也**不在**
1974
+ `dpr.variant.` / `dpr.sessionN.` 两个前缀里 —— lib/client.js 的改名搬家(migrateLsKeys)
1975
+ 只搬那两个前缀,所以论文/专题改名既不会搬它、更不会丢它(全局键本来就没有跟着某篇走的身份)。
1976
+ 取值缺省 = 开:与改造前「两列被 rAF 强行同步」的既有行为一致,老用户观感不变。
1977
+ · 设置面板在**另一个文档**里(阅读器是同源 iframe):两处靠 localStorage 的 storage 事件互通
1978
+ (它只对**其他**文档触发,天然不会回声)——任一处改动,另一处立即反映。
1979
+ · 关掉只关「持续联动」:进分屏/重渲时的一次性对齐(alignSplit)仍然执行,否则两列会停在不同页。
1980
+ ===================================================================================== */
1981
+ const SYNC_SCROLL_KEY = 'dpr.syncScroll';
1982
+ let syncScroll = readSyncPref();
1983
+ /** 最后一次被用户滚过的那一列(关同步时也记):重开同步时以它为基准对齐 —— 用户停在哪儿就对齐到哪儿。 */
1984
+ let splitLeadPane = null;
1985
+
1986
+ /** 读偏好:只有显式的 '0' 算关,其余(含缺失/脏值)都算开。 */
1987
+ function readSyncPref() {
1988
+ try { return localStorage.getItem(SYNC_SCROLL_KEY) !== '0'; } catch { return true; } // 隐私模式读不了 → 默认开
1989
+ }
1990
+ function persistSyncPref() {
1991
+ try { localStorage.setItem(SYNC_SCROLL_KEY, syncScroll ? '1' : '0'); } catch { /* 隐私模式写不了:本次会话内仍然生效 */ }
1992
+ }
1993
+
1994
+ /** 开关控件(分屏控件旁):**只在分屏(模式 B)下可见** —— 模式 A(-dual.pdf 一个 PDF 里并排嵌好)
1995
+ * 与原文/译文视图都只有一个滚动条,本开关没有意义;隐藏而不是留个死控件。 */
1996
+ function updateSyncToggle() {
1997
+ const b = $('sync-toggle');
1998
+ if (!b) return;
1999
+ b.style.display = viewMode === 'split' ? '' : 'none';
2000
+ // 状态换图标(与 mode-toggle 的 paged/scroll、zh-toggle 的 translate/spin 同法):
2001
+ // data-icon 跟着渲染出来的图标走,图标本身由 iconHtml 统一给出(class="ic"、size、aria-hidden)
2002
+ const icon = syncScroll ? 'sync' : 'syncOff';
2003
+ b.dataset.icon = icon;
2004
+ b.innerHTML = iconHtml(icon);
2005
+ b.classList.toggle('active', syncScroll);
2006
+ b.title = syncScroll
2007
+ ? '同步滚动 / Sync scroll:开(两列一起滚,点击关闭)'
2008
+ : '同步滚动 / Sync scroll:关(两列各自独立滚动,点击打开并对齐回当前页)';
2009
+ }
2010
+
2011
+ /** 唯一写入口(按钮点击 / 设置面板的 storage 事件都走这里):落持久化 → 刷新控件 → 打开时立刻对齐。 */
2012
+ function setSyncScroll(on, { persist = true, realign = true } = {}) {
2013
+ syncScroll = !!on;
2014
+ if (persist) persistSyncPref();
2015
+ updateSyncToggle();
2016
+ if (syncScroll && realign) alignSplit(currentLeadCol());
2017
+ }
2018
+
2019
+ /** 重开同步时的基准列:用户最后滚过的那一列;没滚过(或那列已不在)就用原文列。 */
2020
+ function currentLeadCol() {
2021
+ const cols = splitCols();
2022
+ return cols.find((c) => c.pane === splitLeadPane) || cols[0];
2023
+ }
2024
+
2025
+ /** 一次性对齐:把**跟随列**滚到基准列当前锚点对应的位置。
2026
+ * 与 onSplitScroll 的区别是**不看 syncScroll** —— 进分屏 / 重渲 / 重开同步都必须先把两列摆到同一页,
2027
+ * 否则用户看到「两列停在不同页」会以为坏了(关同步只关持续联动,不关「一开始就对齐」)。 */
2028
+ function alignSplit(src) {
2029
+ if (viewMode !== 'split') return;
2030
+ const other = splitCols().find((c) => c.pane !== src.pane);
2031
+ if (!other) return;
2032
+ const srcPages = columnPages(src.box);
2033
+ const target = syncTargetOf(anchorOf(srcPages, src.pane.scrollTop), columnPages(other.box), srcPages.length);
2034
+ if (!target) return;
2035
+ splitSyncing = true; // 跟随引起的 scroll 事件在下一帧被守卫吞掉(与 onSplitScroll 同法)
2036
+ crafFn(splitRaf);
2037
+ splitRaf = rafFn(() => {
2038
+ try {
2039
+ other.pane.scrollTop = target.top;
2040
+ if (target.mismatch && !splitWarned) { splitWarned = true; toast('⚠ 中文版页数与原文不一致,按比例同步', 4000); }
2041
+ } finally {
2042
+ splitSyncing = false;
2043
+ }
2044
+ });
2045
+ }
2046
+
2047
+ /** 分屏控件旁的开关:只在分屏下可见;模式 A / 单列下点不到,也绝不报错。 */
2048
+ $('sync-toggle').onclick = () => {
2049
+ if (viewMode !== 'split') return;
2050
+ setSyncScroll(!syncScroll);
2051
+ };
2052
+
2053
+ /** 设置面板(dsh 设置 → 论文伴读)改了同一个键 → 阅读器立即反映(含重新打开同步时的一次性对齐)。
2054
+ * 只在值真的变了的时候动:storage 只对**其他**文档触发,这里也不回写(不回写就不会回声)。 */
2055
+ window.addEventListener('storage', (e) => {
2056
+ if (!e || e.key !== SYNC_SCROLL_KEY) return;
2057
+ const next = e.newValue !== '0';
2058
+ if (next === syncScroll) return;
2059
+ setSyncScroll(next, { persist: false });
2060
+ });
2061
+
2062
+ /** 滚动同步:主导方 = 用户正在滚的那一列;rAF 节流 + 守卫吞掉回声(§4.2/R7)。
2063
+ * syncScroll=false 时本函数直接早退 ⇒ 两列各自独立滚动(不节流、也不强行对齐)。 */
2064
+ function onSplitScroll(src) {
2065
+ if (viewMode !== 'split' || splitSyncing || !syncScroll) return;
2066
+ const other = splitCols().find((c) => c.pane !== src.pane);
2067
+ if (!other) return;
2068
+ splitSyncing = true; // 跟随引起的 scroll 事件被守卫吞掉
2069
+ crafFn(splitRaf);
2070
+ splitRaf = rafFn(() => {
2071
+ try {
2072
+ // 排到这一帧时开关可能已被关掉(用户点了开关、或面板刚同步过来):在飞的回调也不得再拉列,
2073
+ // 否则「关掉后各自独立」会在下一帧被自己打破(F1 同一族问题)。
2074
+ if (!syncScroll) return;
2075
+ const srcPages = columnPages(src.box);
2076
+ const a = anchorOf(srcPages, src.pane.scrollTop);
2077
+ const t = syncTargetOf(a, columnPages(other.box), srcPages.length);
2078
+ if (t) {
2079
+ other.pane.scrollTop = t.top; // behavior 默认 auto:不做平滑动画(会和同步互相打架)
2080
+ if (t.mismatch && !splitWarned) { splitWarned = true; toast('⚠ 中文版页数与原文不一致,按比例同步', 4000); }
2081
+ }
2082
+ } finally {
2083
+ splitSyncing = false;
2084
+ }
2085
+ });
2086
+ }
2087
+
2088
+ /** 渲染一列的若干页(滚动=全量、翻页=curPage)。热区由 paintPage 按页节点的 data-doc 决定。 */
2089
+ async function renderColumn(doc, docKey, container, pages, token, scale) {
2090
+ for (const i of pages) {
2091
+ if (token !== renderToken) return;
2092
+ const page = await doc.getPage(i);
2093
+ if (token !== renderToken) return;
2094
+ const viewport = page.getViewport({ scale });
2095
+ const div = makePageNode(i, viewport, docKey);
2096
+ container.appendChild(div);
2097
+ await paintPage(page, div, viewport, token);
2098
+ if (token !== renderToken) return;
2099
+ }
2100
+ }
2101
+
2102
+ /** 清空前捕获一列的滚动锚点:优先「页号 + 页内比例」(此刻旧页节点还在,页内几何有效);
2103
+ * 页节点不可用(并发重渲刚好清空过容器)时退回滚动比例锚点 —— 与 renderAllPages 同口径,
2104
+ * 绝不把锚点归零成「回到第一页」。 */
2105
+ function captureScrollAnchor(pane, box) {
2106
+ const pages = columnPages(box);
2107
+ const a = pages.length ? anchorOf(pages, pane.scrollTop) : null;
2108
+ if (a) return a;
2109
+ const range = pane.scrollHeight - pane.clientHeight;
2110
+ return { ratio: range > 0 ? pane.scrollTop / range : 0 };
2111
+ }
2112
+
2113
+ /** 把一列恢复到自己捕获的锚点:优先「同页号 + 页内比例」(zoom / dppx 前后都落回**同一页**,
2114
+ * 不因页面间 gap/padding 造成的比例漂移而挪页);该页这次没有(页数变了/节点缺失)时退回滚动比例。 */
2115
+ function restoreScrollAnchor(pane, box, anchor) {
2116
+ if (anchor && anchor.page != null) {
2117
+ const p = columnPages(box).find((x) => x.page === anchor.page);
2118
+ if (p) { pane.scrollTop = p.offsetTop + anchor.ratio * p.offsetHeight; return; }
2119
+ }
2120
+ const range = pane.scrollHeight - pane.clientHeight;
2121
+ pane.scrollTop = (anchor && typeof anchor.ratio === 'number' ? anchor.ratio : 0) * Math.max(0, range);
2122
+ }
2123
+
2124
+ /** 中文列这次重渲前是否有一个「和即将渲染的形态可比」的位置:重渲前的页数必须等于这次要渲染的页数
2125
+ * (还没渲过 / 页数变了 / 翻页↔滚动切换都不可比 —— 那些情况沿用统一对齐,见 renderSplit 尾注)。 */
2126
+ function zhOwnAnchorUsable(box, doc, paged) {
2127
+ const before = columnPages(box).length;
2128
+ if (before === 0) return false;
2129
+ return before === (doc ? (paged ? 1 : doc.numPages) : 0);
2130
+ }
2131
+
2132
+ /** 分屏全量渲染入口:两列同时渲(滚动=各列全量、翻页=两列同渲 curPage)。 */
2133
+ async function renderSplit() {
2134
+ if (viewMode !== 'split') return rerender();
2135
+ if (!pdfDoc) return;
2136
+ const pane = $('pdf-pane');
2137
+ if (pane.clientHeight === 0) { pendingRerender = true; return; } // 与既有渲染入口同一条守卫
2138
+ const token = ++renderToken;
2139
+ const scale = zoomScale;
2140
+ const origBox = $('pdf-pages'), zhBox = $('pdf-pages-zh');
2141
+ const range = pane.scrollHeight - pane.clientHeight;
2142
+ const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
2143
+ // F1:清空前**各自**记下两列的锚点(此刻旧页节点还在)。同步关着时两列各回各的 ——
2144
+ // 关掉同步的全部意义就是「两列读不同位置」,zoom / dppx / resize 一下不该把这个意图抹掉。
2145
+ const zhPane = $('pdf-pane-zh');
2146
+ const origAnchor = captureScrollAnchor(pane, origBox);
2147
+ const zhAnchor = captureScrollAnchor(zhPane, zhBox);
2148
+ const paged = pageMode === 'paged';
2149
+ const zhOwn = zhOwnAnchorUsable(zhBox, pdfDocZh, paged);
2150
+ origBox.innerHTML = '';
2151
+ zhBox.innerHTML = '';
2152
+ clearEqGeometry(); // 旧页几何 + 跨列残留的 hover/hit 描边一起作废
2153
+ $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
2154
+ origBox.classList.toggle('paged', paged);
2155
+ zhBox.classList.toggle('paged', paged);
2156
+ curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
2157
+ updatePager();
2158
+ const origList = paged ? [curPage] : Array.from({ length: pdfDoc.numPages }, (_, i) => i + 1);
2159
+ await renderColumn(pdfDoc, 'orig', origBox, origList, token, scale);
2160
+ if (token !== renderToken) return;
2161
+ if (pdfDocZh) {
2162
+ const zhPage = Math.min(Math.max(1, curPage), pdfDocZh.numPages);
2163
+ const zhList = paged ? [zhPage] : Array.from({ length: pdfDocZh.numPages }, (_, i) => i + 1);
2164
+ await renderColumn(pdfDocZh, 'zh', zhBox, zhList, token, scale);
2165
+ if (token !== renderToken) return;
2166
+ }
2167
+ // 锚点恢复:重渲后两列各自回到重渲前的位置。
2168
+ // · 同步开着 / 刚进分屏(中文列还没有可比的位置):**既有语义一字不动** —— 原文列按滚动比例回位,
2169
+ // 中文列统一对齐到原文列(「进分屏的一次性对齐不看开关」是评审已裁定必要的行为,保持);
2170
+ // · 同步关着且中文列有**同形态**的重渲前位置(F1 修法):两列各回各的锚点 —— 不能让一次
2171
+ // zoom / dppx / resize 把「两列读不同位置」的意图抹掉。
2172
+ if (!paged) {
2173
+ if (syncScroll || !zhOwn) {
2174
+ const newRange = pane.scrollHeight - pane.clientHeight;
2175
+ pane.scrollTop = anchorRatio * Math.max(0, newRange);
2176
+ splitSyncing = false;
2177
+ alignSplit(splitCols()[0]);
2178
+ } else {
2179
+ restoreScrollAnchor(pane, origBox, origAnchor);
2180
+ restoreScrollAnchor(zhPane, zhBox, zhAnchor);
2181
+ splitSyncing = false;
2182
+ crafFn(splitRaf); // 作废可能在飞的对齐/联动回调:别让它下一帧再把中文列拉回原文列
2183
+ splitRaf = 0;
2184
+ }
2185
+ }
2186
+ observePages(); // 只观察原文列(#pdf-pages .page 的 id 限定必须保持)
2187
+ }
2188
+
2189
+ /** 进入分屏(或 openPaper 恢复分屏):拉原文 + -zh.pdf 两个文档,两列同渲。 */
2190
+ async function openSplit() {
2191
+ if (!current || !zhState.zh) return;
2192
+ if (window.innerWidth < SPLIT_MIN_WIDTH) {
2193
+ toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000);
2194
+ if (viewMode === 'split') await setViewMode('orig');
2195
+ return;
2196
+ }
2197
+ // pdf.js 必须先就绪(与 loadPdf 同一条通道):openPaper 恢复「分屏」时页脚的经典脚本还在解析期,
2198
+ // 动态 import 尚未完成 —— 直接触 pdfjsLib 会 ReferenceError,表现为
2199
+ // 「打开中文版失败:pdfjsLib is not defined,已退回原文」+ 持久化写空(t3 报告 F3,6 次跑 5 次失败)。
2200
+ await ensurePdfjs();
2201
+ $('split-wrap').classList.add('on');
2202
+ $('pdf-pane-zh').style.display = '';
2203
+ splitWarned = false;
2204
+ $('empty').style.display = 'none';
2205
+ $('pager').style.display = '';
2206
+ $('mode-toggle').style.display = '';
2207
+ if (pdfDocZh) { try { await pdfDocZh.destroy?.(); } catch { /* 旧文档已释放 */ } pdfDocZh = null; }
2208
+ try {
2209
+ pdfDocZh = await pdfjsLib.getDocument({ url: pdfUrl('zh') }).promise;
2210
+ } catch (e) {
2211
+ // -zh.pdf 拿不到(被删/损坏):回落原文 + 如实提示,不静默失败
2212
+ await setViewMode('orig');
2213
+ toast('打开中文版失败:' + (e && e.message ? e.message : e) + ',已退回原文', 6000);
2214
+ return;
2215
+ }
2216
+ await loadPdf(pdfUrl(null), { deferRender: true }); // 原文列(pdfDoc 恒指原文),本函数自己安排渲染
2217
+ // 分屏适宽:按较窄的那一列算一次(等价于 fitToWidth,但不让它触发一次多余的单列渲染)
2218
+ const w = fitPaneWidth();
2219
+ if (w > 0) {
2220
+ const p1 = await pdfDoc.getPage(1);
2221
+ zoomScale = Math.min(4.5, Math.max(0.6, w / p1.getViewport({ scale: 1 }).width));
2222
+ }
2223
+ await renderSplit();
2224
+ attachSplitScroll();
2225
+ }
2226
+
2227
+ /** 退出分屏:拆掉中文列、释放中文文档、同步监听。 */
2228
+ function teardownSplit() {
2229
+ $('split-wrap').classList.remove('on');
2230
+ $('pdf-pane-zh').style.display = 'none';
2231
+ $('pdf-pages-zh').innerHTML = '';
2232
+ $('pdf-pages-zh').classList.remove('paged');
2233
+ splitCols().forEach((c) => { c.pane.onscroll = null; });
2234
+ splitSyncing = false;
2235
+ splitLeadPane = null; // 分屏退出:上一轮「最后滚过的那一列」作废(下次进分屏回到原文列为准)
2236
+ crafFn(splitRaf);
2237
+ if (pdfDocZh) { try { pdfDocZh.destroy?.(); } catch { /* 已释放 */ } }
2238
+ pdfDocZh = null;
2239
+ }
2240
+
2241
+ /** 两列的滚动监听(各自一个,**不**共用一个滚动容器——R7 明令)。 */
2242
+ function attachSplitScroll() {
2243
+ splitCols().forEach((col) => {
2244
+ // 关掉同步时也记「最后滚过的那一列」:重开同步要按它对齐(见 setSyncScroll/currentLeadCol)
2245
+ col.pane.onscroll = () => { splitLeadPane = col.pane; onSplitScroll(col); };
2246
+ });
2247
+ }
2248
+
1249
2249
  /* ================= 启动:URL 参数 > localStorage > 第一篇 ================= */
1250
2250
  async function bootstrap() {
1251
2251
  const params = new URLSearchParams(location.search);