@ggboy123/dsh-paper-reader 1.3.4 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,8 @@
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "pretest": "npm run build",
49
+ "test": "node --test --test-timeout=60000 --test-concurrency=1 \"test/*.test.mjs\"",
48
50
  "smoke:engine": "node scripts/engine-smoke.mjs",
49
51
  "prepublishOnly": "tsc -p tsconfig.json"
50
52
  },
package/reader/index.html CHANGED
@@ -43,7 +43,7 @@
43
43
  @media (max-width: 560px) { #ver { display: none; } }
44
44
 
45
45
  /* ---------- PDF 区 ---------- */
46
- #main { display: flex; flex: 1; min-height: 0; }
46
+ #main { display: flex; flex: 1; min-height: 0; position: relative; } /* relative:公式面板绝对定位的锚 */
47
47
  #pdf-pane { flex: 1; overflow: auto; padding: 16px; }
48
48
  /* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
49
49
  #pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
@@ -87,6 +87,31 @@
87
87
  .textLayer span.dpr-hl { background: rgba(255,196,0,.45); border-radius: 2px; }
88
88
  #empty { margin: 60px auto; color: var(--text-2); text-align: center; max-width: 420px; }
89
89
 
90
+ /* ---------- 公式引用(MinerU bbox 热区 + 列表面板)----------
91
+ 契约 §1.3/§1.4:热区**不用** pointer-events:auto 的覆盖层——那会夺走 textLayer 的
92
+ mousedown/拖拽,破坏既有划词提问。这里 .eqLayer 只负责画描边(pointer-events:none),
93
+ 命中判定在 JS 里按坐标算。 */
94
+ .eqLayer { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
95
+ .eq-box { position: absolute; border-radius: 3px; }
96
+ .eq-box.hover { outline: 1.5px dashed rgba(9,105,218,.85); background: rgba(9,105,218,.07); }
97
+ .eq-box.hit { outline: 2px solid rgba(255,140,0,.9); background: rgba(255,196,0,.16); }
98
+ #eq-panel { position: absolute; top: 0; right: 0; bottom: 0; width: min(320px, 88%); display: none;
99
+ background: var(--panel); border-left: 1px solid var(--border); box-shadow: -4px 0 16px rgba(0,0,0,.08);
100
+ overflow: auto; z-index: 3; padding: 10px 12px; }
101
+ #eq-panel h3 { margin: 0 0 4px; font-size: 13px; }
102
+ #eq-meta { font-size: 11.5px; color: var(--text-2); margin-bottom: 6px; line-height: 1.5; }
103
+ .eq-page { margin: 10px 0 2px; font-size: 12px; font-weight: 600; color: var(--text-2); }
104
+ .eq-item { display: block; width: 100%; text-align: left; border: 1px solid var(--border); border-radius: 6px;
105
+ background: var(--bg); color: var(--text); padding: 6px 8px; margin: 4px 0; cursor: pointer; font: inherit; }
106
+ .eq-item:hover { border-color: var(--accent); }
107
+ .eq-item code { display: block; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px;
108
+ white-space: pre-wrap; word-break: break-word; margin-top: 2px; }
109
+ .eq-item .eq-tag { font-size: 11px; color: var(--text-2); }
110
+ #eq-note { font-size: 12px; color: var(--text-2); line-height: 1.6; }
111
+ #eq-transcribe { margin-top: 8px; }
112
+ #eq-toggle.muted { opacity: .55; }
113
+ #ask-copy { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); cursor: pointer; margin-right: auto; }
114
+
90
115
  /* ---------- 选中即问浮层 ---------- */
91
116
  #ask-pop { position: fixed; z-index: 50; display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 6px 24px rgba(0,0,0,.16); padding: 10px; width: 320px; }
92
117
  #ask-quote { font-size: 12px; color: var(--text-2); max-height: 64px; overflow: hidden; margin-bottom: 8px; border-left: 3px solid var(--accent-soft); padding-left: 8px; }
@@ -134,6 +159,7 @@
134
159
  <span id="zoom-label">100%</span>
135
160
  <button id="zoom-in" class="tbtn">+</button>
136
161
  <button id="zoom-fit" class="tbtn">适宽</button>
162
+ <button id="eq-toggle" class="tbtn" title="公式引用(MinerU LaTeX)">∑ 公式</button>
137
163
  <span id="ver" title="插件版本">__DPR_VERSION__</span>
138
164
  </div>
139
165
 
@@ -142,12 +168,18 @@
142
168
  <div id="pdf-pages"></div>
143
169
  <div id="empty" style="display:none"></div>
144
170
  </div>
171
+ <div id="eq-panel">
172
+ <h3>公式</h3>
173
+ <div id="eq-meta"></div>
174
+ <div id="eq-list"></div>
175
+ </div>
145
176
  </div>
146
177
 
147
178
  <div id="ask-pop">
148
179
  <div id="ask-quote"></div>
149
180
  <textarea id="ask-input" rows="2" placeholder="围绕选中内容提问…"></textarea>
150
181
  <div id="ask-actions">
182
+ <button id="ask-copy" style="display:none">复制 LaTeX</button>
151
183
  <button id="ask-cancel">取消</button>
152
184
  <button id="ask-send">提问</button>
153
185
  </div>
@@ -196,16 +228,11 @@ function toast(msg, ms = 3200) {
196
228
  }
197
229
  function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
198
230
 
199
- // 路径复制:clipboard API 失败(iframe 权限等)退回 textarea + execCommand
231
+ // 路径复制:与「复制 LaTeX」共用同一个 copyText(剪贴板写入只有一处实现,见文件末尾定义)
200
232
  $('pdf-path').onclick = async () => {
201
233
  const p = $('pdf-path').textContent;
202
234
  if (!p) return;
203
- try { await navigator.clipboard.writeText(p); toast('路径已复制'); return; } catch { /* 走兜底 */ }
204
- const ta = document.createElement('textarea');
205
- ta.value = p; document.body.appendChild(ta); ta.select();
206
- try { document.execCommand('copy'); toast('路径已复制'); }
207
- catch { toast('复制失败,路径见按钮悬停提示'); }
208
- ta.remove();
235
+ toast((await copyText(p)) ? '路径已复制' : '复制失败,路径见按钮悬停提示');
209
236
  };
210
237
 
211
238
  let current = null; // { topic, name }
@@ -250,6 +277,10 @@ async function openPaper(topic, name, opts = {}) {
250
277
  zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
251
278
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
252
279
  if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
280
+ // 公式索引(MinerU content_list):**必须在 loadPdf 之前到货**——热区是在 paintPage 里
281
+ // 按 eqIndex 铺的,先渲染会漏画/残留上一篇的矩形;又必须排在 zhVariant 定稿之后——
282
+ // eqHotEnabled()/renderEqPanel() 都按 zhVariant 决定「译文视图关热区」。失败不影响打开文献。
283
+ await loadFormulas();
253
284
  updateDlBtn();
254
285
  await loadPdf(pdfUrl(zhVariant));
255
286
  if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
@@ -312,6 +343,7 @@ $('zh-toggle').onclick = async () => {
312
343
  updateDlBtn();
313
344
  // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
314
345
  fitMode = true; fitBtn.classList.add('active');
346
+ renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉)
315
347
  await loadPdf(pdfUrl(zhVariant));
316
348
  return;
317
349
  }
@@ -556,6 +588,15 @@ async function paintPage(page, div, viewport, token) {
556
588
  if (token !== renderToken) return;
557
589
  // pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
558
590
  await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
591
+ // 公式热区层(pointer-events:none,只画描边;命中在 JS 里按坐标算)。
592
+ // 唯一铺层点放在这里(不是各自的渲染循环里):滚动与翻页两条路径共用它——
593
+ // renderPaged 完全绕过 renderAllPages 的循环,写在那边翻页模式热区会全哑。
594
+ // 也不放进 makePageNode:它靠 div.firstChild/lastChild 取 canvas/textLayer,
595
+ // 提前 append 热区层会让 lastChild 变成热区层 → TextLayer 挂错容器、文本层错位。
596
+ // token 守卫(t4 评审 O5):TextLayer.render() 也是 await,期间可能已被更新的渲染抢占;
597
+ // 少了这一行,被抢占的收尾会把**上一档缩放**的几何写回 EQ_BY_PAGE → 点击热区整体偏移。
598
+ if (token !== renderToken) return;
599
+ paintEqLayer(div, viewport, +div.dataset.page);
559
600
  }
560
601
 
561
602
  /** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */
@@ -580,7 +621,8 @@ async function renderAllPages() {
580
621
  const range = pane.scrollHeight - pane.clientHeight;
581
622
  const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
582
623
  container.innerHTML = '';
583
- container.classList.remove('paged');
624
+ container.classList.remove('paged'); // 切回滚动:必须摘掉单页布局类(paged 由 renderPaged 加上)
625
+ clearEqGeometry(); // 旧页几何 + 跨页残留的 hover/hit 描边随 DOM 一起作废
584
626
  $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
585
627
 
586
628
  for (let i = 1; i <= pdfDoc.numPages; i++) {
@@ -590,6 +632,7 @@ async function renderAllPages() {
590
632
  const viewport = page.getViewport({ scale });
591
633
  const div = makePageNode(i, viewport);
592
634
  container.appendChild(div);
635
+ // 位图 + 文本层 + 公式热区层都在 paintPage 里(滚动/翻页两条路径共用同一个铺层点)
593
636
  await paintPage(page, div, viewport, token);
594
637
  if (token !== renderToken) return;
595
638
  }
@@ -606,6 +649,7 @@ async function renderPaged() {
606
649
  const scale = zoomScale; // 同 renderAllPages:快照,防混合缩放
607
650
  curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
608
651
  container.innerHTML = '';
652
+ clearEqGeometry(); // 同上:翻页模式下换页/缩放时,旧页几何与 hover 残留必须一起作废(否则命中整体偏移)
609
653
  container.classList.add('paged');
610
654
  $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
611
655
  updatePager();
@@ -753,6 +797,7 @@ function jumpToPage(n) {
753
797
  找不到(如用户在中文界面而 q 是英文原文)返回 false,调用方退回仅跳页。 */
754
798
  function clearHighlights() {
755
799
  document.querySelectorAll('.textLayer span.dpr-hl').forEach((sp) => sp.classList.remove('dpr-hl'));
800
+ clearEqHit(); // 公式命中描边与引文高亮同生共死(用户在别处点一下即清)
756
801
  }
757
802
  // 高亮的消失交给用户:阅读器里任意点一下即清(mousedown 比 click 早,划选文字的那一下也算)
758
803
  document.addEventListener('mousedown', clearHighlights);
@@ -805,35 +850,379 @@ async function jumpTo(page, q) {
805
850
  }
806
851
  }
807
852
 
808
- /* ================= 选中即问 ================= */
853
+ /* ================= 公式引用(MinerU LaTeX,契约 §1) =================
854
+ PDF 文本层里的公式是乱码(`𝑧𝑧1 𝑧𝑧2`),鼠标选中拿不到可用内容;真相源是 MinerU
855
+ content_list 的 equation 块(LaTeX + bbox)。bbox 坐标系实测为「页面归一化坐标 ×1000」
856
+ (x÷页宽、y÷页高,各自归一),映射直接用渲染时的同一个 viewport,因此与缩放无关:
857
+ x = bbox[0]/1000*viewport.width ,y = bbox[1]/1000*viewport.height
858
+ 热区外扩 6pt(实测最坏欠覆盖 4.60pt;6pt 覆盖全部 17/17 公式块)。 */
859
+ const EQ_PAD = 6; // pt(= scale 1 时的 CSS px),随 zoomScale 放大
860
+ let eqIndex = { source: 'none', equations: [], reason: 'no-mineru-artifact', bboxSpace: 'normalized-1000' };
861
+ const EQ_BY_PAGE = new Map(); // pageNo → [{eq,x0,y0,x1,y1,w,h,pad}]
862
+ let eqHoverId = null;
863
+
864
+ /** 页节点被整体重建(renderAllPages / renderPaged 里 `container.innerHTML = ''`)时调用:
865
+ 旧页的几何必须一起作废,否则命中测试会打到已卸载的页/上一档缩放的矩形(翻页模式下表现为
866
+ 「点击整体偏移」);顺手清掉跨页残留的 hover 态 —— eqHoverId 不清的话,setEqHover(id) 会在
867
+ `id === eqHoverId` 时提前 return,换页后鼠标停在公式上也不再出现虚线描边。 */
868
+ function clearEqGeometry() {
869
+ EQ_BY_PAGE.clear();
870
+ eqHoverId = null;
871
+ clearEqHit();
872
+ }
873
+
874
+ /** 去掉 MinerU 文本外层 `$$`:`$$\n\\pi…\n$$` → `\pi…` */
875
+ function eqLatex(raw) {
876
+ return String(raw == null ? '' : raw).replace(/^\s*\$\$?/, '').replace(/\$\$?\s*$/, '').trim();
877
+ }
878
+ /** 进会话的引用串:保留 `$$` 定界(模型对数学最友好) */
879
+ function eqCite(eq) { return '$$ ' + eqLatex(eq.latex) + ' $$'; }
880
+
881
+ /**
882
+ * MinerU **生效 mode**(GET /mineru/config 的 mode 字段):'off' | 'local' | 'cloud' | null(未知)。
883
+ * 它是「重新转录」按钮的门控依据——`source:'auto'` 会按 mode 选引擎,而 mode==='off' 时
884
+ * auto 落到 pdf.js,pdf.js 重解析会**删掉** <文献>.mineru.md/.mineru.json 富产物
885
+ * (src/transcribe.ts);宁可少一个按钮,也不给一个会反噬的按钮。
886
+ */
887
+ let mineruMode = null;
888
+ async function loadMineruMode() {
889
+ try {
890
+ const c = await api('/mineru/config');
891
+ mineruMode = c && typeof c.mode === 'string' ? c.mode : null;
892
+ } catch { mineruMode = null; } // 取不到按「未知」处理(照常给按钮,行为与旧版一致)
893
+ }
894
+
895
+ function loadFormulas() {
896
+ return (async () => {
897
+ eqIndex = { source: 'none', equations: [], reason: 'no-mineru-artifact', bboxSpace: 'normalized-1000' };
898
+ if (!current) { renderEqPanel(); return; }
899
+ await loadMineruMode();
900
+ const q = `topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`;
901
+ try {
902
+ const idx = await api('/formulas?' + q);
903
+ if (idx && Array.isArray(idx.equations)) eqIndex = idx;
904
+ } catch (e) {
905
+ // 接口异常绝不打崩阅读器:降级成「无公式」,划词提问照常
906
+ eqIndex = { source: 'none', equations: [], reason: 'bad-artifact', warning: e.message, bboxSpace: 'normalized-1000' };
907
+ }
908
+ const btn = $('eq-toggle');
909
+ btn.classList.toggle('muted', eqIndex.equations.length === 0);
910
+ btn.title = eqIndex.equations.length
911
+ ? `公式引用:${eqIndex.equations.length} 个(点击 PDF 上的公式即可引用其 LaTeX)`
912
+ : '公式引用:本论文还没有 MinerU 解析产物';
913
+ renderEqPanel();
914
+ })();
915
+ }
916
+
917
+ /** 热区只在「原文视图 + 有 bbox」时开启:译文版(-zh/-dual)页面几何与原文不同。 */
918
+ function eqHotEnabled() {
919
+ return zhVariant === null && eqIndex.equations.some((e) => e.bbox);
920
+ }
921
+
922
+ function eqGeom(eq, viewport) {
923
+ if (!eq.bbox) return null;
924
+ const space = eqIndex.bboxSpace === 'page-units'; // 兜底分支(云端版本差异,见需求 R3)
925
+ const kx = space ? zoomScale : viewport.width / 1000;
926
+ const ky = space ? zoomScale : viewport.height / 1000;
927
+ const x = eq.bbox[0] * kx;
928
+ const y = eq.bbox[1] * ky;
929
+ const w = (eq.bbox[2] - eq.bbox[0]) * kx;
930
+ const h = (eq.bbox[3] - eq.bbox[1]) * ky;
931
+ if (!(w > 0) || !(h > 0)) return null;
932
+ return {
933
+ eq, x, y, w, h, pad: EQ_PAD * zoomScale,
934
+ x0: Math.max(0, x), y0: Math.max(0, y),
935
+ x1: Math.min(viewport.width, x + w), y1: Math.min(viewport.height, y + h),
936
+ };
937
+ }
938
+
939
+ /** 为已渲染的一页铺热区层(只画描边,不接事件)。 */
940
+ function paintEqLayer(pageDiv, viewport, pageNo) {
941
+ const old = pageDiv.querySelector('.eqLayer');
942
+ if (old) old.remove();
943
+ const items = [];
944
+ if (eqHotEnabled()) {
945
+ const lay = document.createElement('div');
946
+ lay.className = 'eqLayer';
947
+ lay.style.width = viewport.width + 'px';
948
+ lay.style.height = viewport.height + 'px';
949
+ for (const eq of eqIndex.equations) {
950
+ if (eq.page !== pageNo) continue;
951
+ const g = eqGeom(eq, viewport);
952
+ if (!g) continue;
953
+ items.push(g);
954
+ const b = document.createElement('div');
955
+ b.className = 'eq-box';
956
+ b.dataset.eqId = eq.id;
957
+ b.style.left = g.x0 + 'px';
958
+ b.style.top = g.y0 + 'px';
959
+ b.style.width = Math.max(1, g.x1 - g.x0) + 'px';
960
+ b.style.height = Math.max(1, g.y1 - g.y0) + 'px';
961
+ lay.appendChild(b);
962
+ }
963
+ pageDiv.appendChild(lay);
964
+ }
965
+ EQ_BY_PAGE.set(pageNo, items);
966
+ }
967
+
968
+ /**
969
+ * 命中判定(契约 §1.4):
970
+ * 1) 未外扩矩形包含点击点 → 取面积最小者(并列取阅读序靠前);
971
+ * 2) 否则取外扩后包含该点、且到未外扩矩形距离最小者(并列同上);
972
+ * 3) 都没有 → 不是公式点击。
973
+ * 为什么必须严格:实测同一页相邻公式块竖直间隙最小只有 0.8pt,6pt 外扩后必然重叠。
974
+ */
975
+ function hitTestFormula(e) {
976
+ if (!eqHotEnabled()) return null;
977
+ const el = e.target;
978
+ const pageDiv = el && el.closest ? el.closest('.page[data-page]') : null;
979
+ if (!pageDiv) return null;
980
+ const items = EQ_BY_PAGE.get(+pageDiv.dataset.page);
981
+ if (!items || !items.length) return null;
982
+ const box = pageDiv.getBoundingClientRect();
983
+ const px = e.clientX - box.left;
984
+ const py = e.clientY - box.top;
985
+ let best = null;
986
+ for (const it of items) {
987
+ if (px >= it.x0 && px <= it.x1 && py >= it.y0 && py <= it.y1) {
988
+ if (!best || it.w * it.h < best.w * best.h) best = it;
989
+ }
990
+ }
991
+ if (best) return best;
992
+ let bestD = Infinity;
993
+ for (const it of items) {
994
+ if (px < it.x0 - it.pad || px > it.x1 + it.pad || py < it.y0 - it.pad || py > it.y1 + it.pad) continue;
995
+ const dx = px < it.x0 ? it.x0 - px : (px > it.x1 ? px - it.x1 : 0);
996
+ const dy = py < it.y0 ? it.y0 - py : (py > it.y1 ? py - it.y1 : 0);
997
+ const d = dx * dx + dy * dy;
998
+ if (d < bestD - 1e-6) { bestD = d; best = it; }
999
+ else if (Math.abs(d - bestD) <= 1e-6 && best && it.w * it.h < best.w * best.h) best = it;
1000
+ }
1001
+ return best;
1002
+ }
1003
+
1004
+ function setEqHover(id) {
1005
+ if (id === eqHoverId) return;
1006
+ eqHoverId = id;
1007
+ document.querySelectorAll('.eq-box.hover').forEach((el) => el.classList.remove('hover'));
1008
+ if (id) {
1009
+ const el = document.querySelector(`.eq-box[data-eq-id="${id}"]`);
1010
+ if (el) el.classList.add('hover');
1011
+ }
1012
+ }
1013
+
1014
+ function clearEqHit() { document.querySelectorAll('.eq-box.hit').forEach((el) => el.classList.remove('hit')); }
1015
+
1016
+ function markEqHit(eq) {
1017
+ clearEqHit();
1018
+ const el = document.querySelector(`.eq-box[data-eq-id="${eq.id}"]`);
1019
+ if (el) el.classList.add('hit');
1020
+ }
1021
+
1022
+ /** 引用一个公式:热区点击与面板条目共用这一条链路(契约 §1.5)。 */
1023
+ function citeFormula(eq, x, y) {
1024
+ askSelection = {
1025
+ text: eqCite(eq),
1026
+ page: eq.page,
1027
+ kind: 'equation',
1028
+ latex: eqLatex(eq.latex),
1029
+ equationIndex: eq.index,
1030
+ };
1031
+ markEqHit(eq);
1032
+ const tag = `第 ${eq.page} 页 · 公式 ${eq.index}`;
1033
+ const body = eqLatex(eq.latex).replace(/\s+/g, ' ');
1034
+ showAskPop({ quote: body ? `${tag}:${body}` : tag, x, y });
1035
+ }
1036
+
1037
+ function renderEqPanel() {
1038
+ const meta = $('eq-meta');
1039
+ const list = $('eq-list');
1040
+ const eqs = eqIndex.equations;
1041
+ list.innerHTML = '';
1042
+ if (eqs.length) {
1043
+ const src = [eqIndex.producer || 'MinerU', eqIndex.backend].filter(Boolean).join(' · ');
1044
+ meta.textContent = `${eqs.length} 个公式 · ${src}${eqIndex.warning ? '(' + eqIndex.warning + ')' : ''}`;
1045
+ } else {
1046
+ meta.textContent = '';
1047
+ }
1048
+ if (zhVariant !== null) {
1049
+ const n = document.createElement('div');
1050
+ n.id = 'eq-note';
1051
+ n.textContent = '当前是译文视图:没有公式位置信息(bbox 只对原文页面成立),切回原文即可点击公式。';
1052
+ list.appendChild(n);
1053
+ }
1054
+ if (!eqs.length) {
1055
+ const n = document.createElement('div');
1056
+ n.id = 'eq-note';
1057
+ n.textContent = eqIndex.reason === 'bad-artifact'
1058
+ ? 'MinerU 产物损坏或不可读,已忽略(不影响阅读与划词提问)。'
1059
+ : eqIndex.reason === 'no-equations'
1060
+ ? '这篇论文没有解析出公式块(MinerU 产物存在,但 content_list 里没有 equation)。'
1061
+ : mineruMode === 'off'
1062
+ ? '未启用 MinerU:请到 dsh 设置 → 论文伴读 → MinerU 解析后端 选择「本地」或「云端」并保存,然后回到这里点「重新转录」。'
1063
+ : '未找到 MinerU 解析产物。在 dsh 设置 → 论文伴读 → MinerU 解析后端 启用后点下面按钮重新转录。';
1064
+ list.appendChild(n);
1065
+ // 门控:mode==='off' 时不渲染按钮(auto 会落到 pdf.js 并删掉已有的 MinerU 富产物);
1066
+ // mode 未知(接口不可用)时照旧给按钮,保持与旧版一致的行为。
1067
+ const canRetranscribe = (eqIndex.reason === 'no-mineru-artifact' || eqIndex.reason === 'no-equations') && mineruMode !== 'off';
1068
+ if (canRetranscribe) {
1069
+ const b = document.createElement('button');
1070
+ b.id = 'eq-transcribe';
1071
+ b.className = 'tbtn';
1072
+ b.textContent = '用 MinerU 重新转录';
1073
+ b.onclick = () => transcribeWithMineru(b);
1074
+ list.appendChild(b);
1075
+ }
1076
+ return;
1077
+ }
1078
+ const byPage = new Map();
1079
+ for (const eq of eqs) {
1080
+ if (!byPage.has(eq.page)) byPage.set(eq.page, []);
1081
+ byPage.get(eq.page).push(eq);
1082
+ }
1083
+ for (const [page, arr] of byPage) {
1084
+ const h = document.createElement('div');
1085
+ h.className = 'eq-page';
1086
+ h.textContent = `第 ${page} 页`;
1087
+ list.appendChild(h);
1088
+ for (const eq of arr) {
1089
+ const item = document.createElement('button');
1090
+ item.type = 'button';
1091
+ item.className = 'eq-item';
1092
+ item.dataset.eqId = eq.id;
1093
+ const tag = document.createElement('span');
1094
+ tag.className = 'eq-tag';
1095
+ tag.textContent = `公式 ${eq.index}${eq.bbox ? '' : ' · 无位置信息'}`;
1096
+ const code = document.createElement('code');
1097
+ code.textContent = eqLatex(eq.latex) || '(空)';
1098
+ item.appendChild(tag);
1099
+ item.appendChild(code);
1100
+ item.onclick = () => {
1101
+ const r = item.getBoundingClientRect();
1102
+ citeFormula(eq, r.left, r.bottom + 6);
1103
+ };
1104
+ list.appendChild(item);
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ async function transcribeWithMineru(btn) {
1110
+ if (!current) return;
1111
+ const old = btn.textContent;
1112
+ btn.disabled = true;
1113
+ btn.textContent = '转录中(可能需要几十秒)…';
1114
+ try {
1115
+ await api('/transcribe', {
1116
+ method: 'POST',
1117
+ headers: { 'content-type': 'application/json' },
1118
+ // source:'auto' = 按**当前生效 mode** 选引擎(local → 本地 MinerU;cloud → mineru.net 云端;
1119
+ // off → pdf.js)。硬编码 'mineru-local' 会让配了云端的用户点一次失败一次(上游评审第 6 条)。
1120
+ // 按钮本身在 mode==='off' 时不渲染(见 renderEqPanel 的门控),所以走到这里 mode 必为 local/cloud。
1121
+ body: JSON.stringify({ ...current, force: true, source: 'auto' }),
1122
+ });
1123
+ toast('MinerU 转录完成');
1124
+ await loadFormulas();
1125
+ // 新索引到手:按**当前模式**重渲一次把热区铺上去(翻页模式下必须仍是 renderPaged,
1126
+ // 否则会把全量页渲出来、摘掉 #pdf-pages 的 paged 类,与顶栏 ▣ 的 active 状态脱节)
1127
+ if (pdfDoc) await rerender();
1128
+ } catch (e) {
1129
+ toast('MinerU 转录失败:' + e.message, 6000);
1130
+ } finally {
1131
+ if (document.body.contains(btn)) { btn.disabled = false; btn.textContent = old; }
1132
+ }
1133
+ }
1134
+
1135
+ $('eq-toggle').onclick = () => {
1136
+ const open = $('eq-panel').style.display !== 'block';
1137
+ $('eq-panel').style.display = open ? 'block' : 'none';
1138
+ $('eq-toggle').classList.toggle('active', open);
1139
+ };
1140
+
1141
+ let lastMove = { x: -1, y: -1 };
1142
+ document.addEventListener('mousemove', (e) => {
1143
+ if (!eqHotEnabled()) { setEqHover(null); return; }
1144
+ if (Math.abs(e.clientX - lastMove.x) < 2 && Math.abs(e.clientY - lastMove.y) < 2) return;
1145
+ lastMove = { x: e.clientX, y: e.clientY };
1146
+ const hit = hitTestFormula(e);
1147
+ setEqHover(hit ? hit.eq.id : null);
1148
+ });
1149
+
1150
+ /* ================= 选中即问(文字 + 公式共用一条链路) ================= */
809
1151
  const askPop = $('ask-pop');
810
1152
  let askSelection = null;
1153
+ // 拖拽起点:公式「点击」判定要求 mousedown→mouseup 位移很小(拖选文字不算点击)
1154
+ let downPt = null;
1155
+
1156
+ /** 弹层共用的展示动作(文字引用与公式引用都走这里)。 */
1157
+ function showAskPop(o) {
1158
+ $('ask-quote').textContent = o.quote.length > 140 ? o.quote.slice(0, 140) + '…' : o.quote;
1159
+ $('ask-input').value = '';
1160
+ $('ask-copy').style.display = askSelection && askSelection.kind === 'equation' ? 'inline-block' : 'none';
1161
+ askPop.style.display = 'block';
1162
+ // 夹在 iframe 视口内(rightbar 窄列里也要能用)
1163
+ const popW = 336, popH = 200;
1164
+ askPop.style.left = Math.max(8, Math.min(o.x, window.innerWidth - popW - 8)) + 'px';
1165
+ askPop.style.top = Math.max(8, Math.min(o.y, window.innerHeight - popH - 8)) + 'px';
1166
+ $('ask-input').focus();
1167
+ }
1168
+
1169
+ document.addEventListener('mousedown', (e) => { downPt = { x: e.clientX, y: e.clientY }; }, true);
811
1170
 
812
1171
  document.addEventListener('mouseup', (e) => {
813
1172
  if (askPop.contains(e.target)) return;
814
1173
  setTimeout(() => {
1174
+ const moved = downPt ? Math.abs(e.clientX - downPt.x) + Math.abs(e.clientY - downPt.y) > 4 : false;
1175
+ downPt = null;
815
1176
  const sel = window.getSelection();
816
1177
  const text = sel ? sel.toString().trim() : '';
817
- if (!text || !current) { askPop.style.display = 'none'; return; }
1178
+ if (!text || !current) {
1179
+ // 无选区(或没有当前文献)时才尝试公式点击 —— 有选区时「划词提问」优先,绝不抢。
1180
+ if (!text && !moved && current) {
1181
+ const hit = hitTestFormula(e);
1182
+ if (hit) { citeFormula(hit.eq, e.clientX, e.clientY + 6); return; }
1183
+ }
1184
+ askPop.style.display = 'none';
1185
+ return;
1186
+ }
818
1187
  const node = sel.anchorNode && sel.anchorNode.parentElement;
819
1188
  if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; }
820
1189
  const range = sel.getRangeAt(0).getBoundingClientRect();
821
- askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null };
822
- $('ask-quote').textContent = text.length > 140 ? text.slice(0, 140) + '…' : text;
823
- $('ask-input').value = '';
824
- askPop.style.display = 'block';
825
- // 夹在 iframe 视口内(rightbar 窄列里也要能用)
826
- const popW = 336, popH = 190;
827
- const x = Math.max(8, Math.min(e.clientX, window.innerWidth - popW - 8));
828
- const y = Math.max(8, Math.min(range.bottom + 8, window.innerHeight - popH - 8));
829
- askPop.style.left = x + 'px';
830
- askPop.style.top = y + 'px';
831
- $('ask-input').focus();
1190
+ askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null, kind: 'text' };
1191
+ showAskPop({ quote: text, x: e.clientX, y: range.bottom + 8 });
832
1192
  }, 10);
833
1193
  });
834
1194
  document.addEventListener('mousedown', (e) => { if (!askPop.contains(e.target)) askPop.style.display = 'none'; });
835
1195
  $('ask-cancel').onclick = () => { askPop.style.display = 'none'; };
836
1196
 
1197
+ // 复制 LaTeX(显式动作:不自动写剪贴板,静默覆盖用户剪贴板是意外副作用)
1198
+ $('ask-copy').onclick = async () => {
1199
+ const latex = askSelection && askSelection.kind === 'equation' ? askSelection.text : '';
1200
+ if (!latex) return;
1201
+ // 只在用户显式点按钮时写剪贴板;引用公式/弹气泡本身绝不自动写
1202
+ toast((await copyText(latex)) ? '已复制公式 LaTeX' : '复制失败,请手动选择文本', 5000);
1203
+ };
1204
+
1205
+ /** 剪贴板写入的**唯一**实现(路径复制与复制 LaTeX 共用):clipboard API 优先,
1206
+ * 失败/不可用(iframe 权限、非安全上下文)退回 textarea + execCommand。
1207
+ * 返回是否成功——文案由调用方决定。 */
1208
+ async function copyText(text) {
1209
+ try {
1210
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1211
+ await navigator.clipboard.writeText(text);
1212
+ return true;
1213
+ }
1214
+ } catch { /* 走下面的兜底 */ }
1215
+ try {
1216
+ const ta = document.createElement('textarea');
1217
+ ta.value = text;
1218
+ document.body.appendChild(ta);
1219
+ ta.select();
1220
+ document.execCommand('copy');
1221
+ ta.remove();
1222
+ return true;
1223
+ } catch { return false; }
1224
+ }
1225
+
837
1226
  $('ask-send').onclick = async () => {
838
1227
  const question = $('ask-input').value.trim();
839
1228
  if (!question || !askSelection || !current) return;
@@ -842,12 +1231,16 @@ $('ask-send').onclick = async () => {
842
1231
  // 会话序号由侧栏树激活时写入 localStorage(默认最新)
843
1232
  const key = `dpr.sessionN.${current.topic}/${current.name}`;
844
1233
  const n = +(localStorage.getItem(key) || 0) || undefined;
1234
+ // 公式引用额外带 kind/equationIndex:host 侧据此换用「引用了公式(LaTeX 源)」文案
1235
+ const eq = askSelection.kind === 'equation'
1236
+ ? { kind: 'equation', equationIndex: askSelection.equationIndex }
1237
+ : {};
845
1238
  const r = await api('/ask', {
846
1239
  method: 'POST', headers: { 'content-type': 'application/json' },
847
- body: JSON.stringify({ ...current, question, selectedText: askSelection.text, page: askSelection.page, ...(n ? { n } : {}) }),
1240
+ body: JSON.stringify({ ...current, question, selectedText: askSelection.text, page: askSelection.page, ...eq, ...(n ? { n } : {}) }),
848
1241
  });
849
1242
  askPop.style.display = 'none';
850
- toast(`已发送到 会话${r.n},回答见对话区`);
1243
+ toast(askSelection.kind === 'equation' ? `已引用公式并发送到 会话${r.n}` : `已发送到 会话${r.n},回答见对话区`);
851
1244
  } catch (e) { toast('提问失败:' + e.message, 6000); }
852
1245
  $('ask-send').disabled = false;
853
1246
  };