From 341868168e3619737ecd7d7df49baadeae800f90 Mon Sep 17 00:00:00 2001 From: Veryyoung957 Date: Sun, 11 Oct 2026 15:16:22 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=88=92=E8=AF=8D=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=EF=BC=88=E5=90=AB=E5=85=AC=E5=BC=8F=E4=BF=9D=E7=95=99?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=E4=B8=AD=E8=8B=B1=E6=96=87=E5=88=86=E5=B1=8F?= =?UTF-8?q?=E5=AF=B9=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在阅读器里选中文字后于气泡中显示译文;选区含公式时,用 MinerU 的 LaTeX 替换 PDF 文本层里的乱码公式,并要求译文原样保留公式。 【两条公式还原路径,机制不同】 - display(独立成行):equation 块有自己的 bbox,按「字符矩形中心点落进 bbox」归属。不用「任何交叠」——实测会在 2/18 例把下一行正文吞进来。 bbox 是页面归一化 ×1000 且 x、y 各自归一,绝不能用同一个比例尺。 - 行内(正文里穿插的 $…$):**没有自己的 bbox**,嵌在 text 块的 text 字段 里,靠「候选块定位 + 双向行走对齐」还原。对齐不过门槛时**拒绝还原并如实 提示**,不降格选跨段候选——「译错句子且看不出来」比「公式仍是乱码但有 诚实提示」严重得多。 【诚实机制】译文之外给一行状态:无 MinerU 产物 / 对齐失败 / 公式未原样保留 (逐 token 校验)等,如实告知而非让用户以为译文准确。选区含字面 $ 时不会 触发假的「公式未保留」告警。 【分屏对照两种模式】模式 A 用 babeldoc 的 -dual.pdf(并修好「同时存在 -zh/-dual 时三态循环永远进不到 dual」);模式 B 原文 PDF ↔ -zh.pdf 并排 同步滚动(rAF 节流,页数不同按比例映射)。状态机复用既有 zhVariant/zhState, 不另写一套。 【前提与代价(README 已如实写明)】需先配置翻译端点;公式保留需该论文用 MinerU 转录过(无产物时降级为普通翻译);模式 A/B 分别需要 -dual.pdf / -zh.pdf(通常要先跑 babeldoc)。 测试 385 tests / 373 pass / 0 fail / 12 skip(零新增依赖、离线可跑;需真实 服务的用例在不可达时显式 skip 而非静默通过)。经独立验证与两轮对抗评审 (含变异测试:删掉任一护栏对应用例必须变红)通过。 --- README.md | 50 +- README_EN.md | 48 + package.json | 2 +- reader/index.html | 740 ++++++- src/formulas.ts | 121 +- src/host.ts | 100 +- src/translate-config.ts | 5 +- src/translate-selection.ts | 852 +++++++ .../selection-translate-verification.test.mjs | 214 ++ test/selection-translate.test.mjs | 1959 +++++++++++++++++ 10 files changed, 4033 insertions(+), 58 deletions(-) create mode 100644 src/translate-selection.ts create mode 100644 test/selection-translate-verification.test.mjs create mode 100644 test/selection-translate.test.mjs diff --git a/README.md b/README.md index 025b300..fe2d976 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **English**: [README_EN.md](README_EN.md) -[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:**论文伴读工作台** —— 文献库 + PDF 阅读器 + AI 伴读,回答自带页码、点页码跳回原文。当前版本 **v1.4.1**。 +[DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:**论文伴读工作台** —— 文献库 + PDF 阅读器 + AI 伴读,回答自带页码、点页码跳回原文。当前版本 **v1.5.0**。 > 💬 **QQ 交流群:1127244508** —— 安装/使用问题、功能建议、bug 反馈都欢迎来聊。 @@ -24,6 +24,8 @@ - 🤖 **教练式带读**:paper 会话自动用专用 preset——定分步阅读计划、逐节指导、出题批改,学习进度跨会话累积 - 🀄 **中文版**:原文 ↔ 纯中文原地切换(babeldoc 零预装,自动安装),译文 PDF 一键导出(纯中文 / 中英对照) - 🗂️ **文献管理**:专题/论文改名、删除(删除前列文件清单、进回收站**可恢复**、非空专题批量清空一次确认) +- 🈯 **划词翻译(公式保留)**(v1.5.0):阅读器里选中正文 → 提问气泡上多一个 **「译」** 按钮 → 弹出**只显示译文**的气泡(**没有**「插入会话」「复制」按钮,也从不自动写剪贴板)。**选区含公式时**,用 MinerU 识别出的 LaTeX 替换 PDF 文本层里的乱码公式,并要求模型**原样保留**公式(`$…$` 里的内容不翻译、不改写)。**前提与代价**:① 需先配置翻译端点(与生成中文版用的是同一份配置;未配置时点「译」会直接弹设置表单,保存后自动重试);② **公式保留要求该论文用 MinerU 转录过**——没有产物时**降级为普通翻译**,公式在文本层里本就不读,气泡会**如实提示「译文可能不准」**(这是设计,不是缺陷) +- 🖥️ **中英分屏对照(两种模式,可切换)**(v1.5.0):顶栏新增 **「对照」** 与 **「分屏」** 两个按钮,随时切换、再点一次回原文,选择按文献记忆。**模式 A「对照」** 直接看 babeldoc 的 `-dual.pdf`(原文与译文并排画在同一张加宽页面上,自动适宽);**模式 B「分屏」** 左原文、右中文版,两列**同步滚动**(同页号 + 页内比例)。**前提与代价**:模式 A 需要 `-dual.pdf`、模式 B 需要 `-zh.pdf`,两者都要先用 babeldoc 生成(顶栏「中」一键触发,**首次约几分钟**,且需要翻译端点);**没有对应产物时按钮置灰并说明**;分屏需要面板宽度 ≥640px,太窄会提示而不切换 - ➕ **两个可选增强**(默认关,见「进阶」):MinerU 解析——扫描件可读、公式可点引用可检索;语义检索端点——大白话命中术语段落 安装后**不改动官方界面**:侧栏底部多一行「📚 论文伴读」开关,点击才进入伴读模式,再点即还原。 @@ -68,6 +70,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest ```bash dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # 永远装最新 +# dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.5.0 # 或钉住具体版本 grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json # 查当前版本 # 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存 ``` @@ -120,6 +123,50 @@ grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/pac - 改名后:转录缓存/译文/学习档案全部跟随搬家;历史伴读会话**本体保留**但不再挂在新名字下(会话 id 编码旧名);纯大小写改名在 macOS/Windows 可用 - 手工放进 `data/` 的怪名字文件(如 `.hidden.pdf`)也能正常删除/改名,不会成为孤儿 +## 划词翻译与中英分屏对照(v1.5.0) + +### 划词翻译(公式保留) + +**怎么用**:在阅读器里**选中一段正文** → 弹出「选中即问」气泡(行为与以前完全一致)→ 点气泡里的 **「译」** → 弹出**译文气泡**,里面**只有译文**(外加一行诚实提示)。想重翻就重新选一次;点气泡外或按 Esc 关掉。 + +译文气泡里**没有**「插入会话」「复制」按钮(按你的要求),也**从不**自动写剪贴板。提问链路一点没动:重新选一次字就回到原来的提问气泡。 + +**公式是怎么处理的**(这是本功能的核心): + +- PDF 文本层里的公式**不可读**:`$F_{\theta}$` 常常只剩一个 `F`(下标整字丢失)、`\sum` 变成 `X`、分式被拉平成一行、括号对甚至变成控制字符。把这串东西直接发给模型,译文里的公式必然错。 +- 所以:**选区里的公式先用 MinerU 的 LaTeX 把那一段乱码替换掉**,再明确要求模型**原样保留** `$…$` 里的内容(不翻译、不改写、不省略、不增删符号)。译文回来后还会**逐个公式核对**是否原样保留,缺了就在气泡里如实告诉你。 +- 两类公式都覆盖:**独立成行的公式**(用 MinerU `equation` 块的 bbox 精确定位)与**正文里穿插的行内公式**(如 `π(a|s)`、`Q(s,a)`、`x_t`)。行内公式在 MinerU 产物里**没有自己的 bbox**(它嵌在正文块里),插件靠「选区文本 ↔ MinerU 块文本」的模糊对齐把它还原出来。 + +**前提与代价(务必先读)**: + +| # | 前提 | 没有它会怎样 | +| --- | --- | --- | +| 1 | **需要先配置翻译端点**(设置 → 论文伴读,或阅读器右上角 ⚙;与生成中文版共用同一份配置) | 点「译」会**自动弹出端点设置表单**;填好保存后**自动重试这次翻译**(不会白点一次)。端点三项不齐时服务端直接拒绝,前端据此弹表单 | +| 2 | **公式保留需要该论文用 MinerU 转录过**(存在 `<文献>.mineru.json`) | **降级为普通翻译**:正文照常翻,但公式在文本层里本就不读,气泡会如实提示「该论文未用 MinerU 转录:原文里的公式在文本层不可读,译文可能不准」。**这是设计,不是缺陷** | +| 3 | 有 MinerU 产物、但某一段公式没对上 | 只还原能对上的部分,气泡提示「部分公式未能还原,译文可能不准」 | +| 4 | 选区太短(不足 4 个有效字符,例如只选中孤零零的 `F`) | 不做还原(不足以判别),气泡提示「选区太短,公式未还原」 | + +**已知限制(如实声明,不粉饰)**: + +- **未对真实 LLM 端点打过任何请求**:开发期验证全程使用假上游(避免花钱、避免用你的 key)。「模型是否真的原样保留公式」目前靠**提示词字节契约**+**回包逐公式核对**两道可判定断言保证,没有真实服务商 key 的端到端实测。 +- MinerU 偶尔会把**一个公式拆成两个 `$…$`**(首个片段以 `=` 结尾)。这种形态下可能只还原出半截公式(如 `action $\hat{a}_t\sim\pi_\theta(\cdot\ |$`),`$` 配对计数拦不住;本机语料里未出现,留待后续。 +- 选区里含**字面** `$`(正文里真印了一个美元符号)时,极端形态下可能产生一句「有 N 个公式未原样保留」的**误报提示**(已记录,不影响译文本身)。 +- 中文版 / 对照视图下**不做**公式替换(bbox 只对原文页成立),所以「译」按钮只在原文视图出现。 + +### 中英分屏对照(两种模式) + +顶栏新增两个按钮,随时切换、**再点一次回原文**;每篇文献上次用的视图会被记住(跟着文献走,改名也不丢): + +| 按钮 | 模式 | 看的是哪个文件 | 前提 | +| --- | --- | --- | --- | +| **对照** | A:单页内对照 | babeldoc 的 `-dual.pdf`(原文与译文并排画在同一张加宽页上,页宽约 2 倍) | 需要 `-dual.pdf` | +| **分屏** | B:左右并排 | 左:原文 `<文献>.pdf`;右:`<文献>-zh.pdf` | 需要 `-zh.pdf` | + +- **两个产物都要先用 babeldoc 生成**:顶栏点「中」一键触发(**首次要装引擎 + 翻译,约几分钟**,需要翻译端点)。没有对应产物时按钮**置灰并写明原因**,不会点了没反应。 +- **同步滚动**:滚任意一列,另一列自动跟到「同一页号 + 同一页内比例」;谁在滚谁主导,不会两列互相拉锯。翻页模式(`▣`)下两列同步同页。**中文版与原文页数不一致时按比例映射**,并在状态栏给出一次性提示。 +- **分屏宽度**:需要面板宽度 ≥640px;太窄时点「分屏」只提示「请把阅读器拉宽」,**不会**切进去;分屏期间把面板缩到 640px 以下会自动退回原文。 +- **与既有能力的关系**:分屏时左列就是普通原文视图——**公式热区照常可点**(右列不参与命中,也不会被画上热区);公式面板照常可用;「中」按钮在分屏期间置灰(避免与分屏语义打架)。切到中文 / 对照视图时热区仍按既有规则关闭(bbox 只对原文页成立)。 + ## 参与开发 ```bash @@ -154,6 +201,7 @@ src/ study.ts 纯函数:学习档案(计划 + 检验成绩)读写 translate.ts 纯函数:babeldoc 调用(中文/中英对照 PDF 生成) babeldoc-install.ts 纯函数:无 Python 环境时自动安装 babeldoc(uv → 托管 Python → venv) + translate-selection.ts 纯函数:划词翻译(公式保留 —— 行内公式与 MinerU 块文本对齐还原、请求体与提示词契约、逐公式保全校验、内存 LRU 缓存) rerank.ts 纯函数:search_paper 语义重排(TypeSafe/Jev,无凭据时退回关键词排序) preset.ts 纯函数:自带 agent preset 安装到 $DSH_HOME/.agent-presets/ presets/paper-reader/ 「论文伴读」agent preset(persona 完整提示词 + compaction) diff --git a/README_EN.md b/README_EN.md index 43cefe4..175f89c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -24,6 +24,8 @@ A [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) plug - 🤖 **Tutor mode**: paper sessions automatically use a dedicated preset — step-by-step reading plans, section-by-section coaching, graded quizzes; progress accumulates across sessions - 🀄 **Chinese version**: original ↔ full-Chinese in-place toggle (babeldoc auto-installed, zero preinstall); one-click export (Chinese / bilingual PDF) - 🗂️ **Library management**: rename/delete topics and papers (deletion shows a file manifest first, goes to a **recoverable** trash; non-empty topics support batch-clear with one confirmation) +- 🈯 **Selection translation (formulas preserved)** (v1.5.0): select text in the reader → the ask bubble gains a **"译" (Translate)** button → a **translation-only** bubble opens (there is **no** "insert into chat" and **no** "copy" button, and the plugin never writes to your clipboard on its own). **When the selection overlaps a formula**, the plugin replaces the garbage formula glyphs in the PDF text layer with the LaTeX MinerU recognized and asks the model to **keep formulas verbatim** (nothing inside `$…$` is translated or rewritten). **Costs and prerequisites**: ① you must configure the translation endpoint first (the same config the Chinese-PDF generation uses; with nothing configured, clicking "译" opens the endpoint form and retries automatically after saving); ② **formula preservation requires that paper to have been parsed by MinerU** — without an artifact it **falls back to plain translation**, and since formulas are unreadable in the text layer the bubble **says so honestly** ("translation may be inaccurate"). That is by design, not a defect. +- 🖥️ **Bilingual split view (two modes, switchable)** (v1.5.0): two new toolbar buttons, **"对照" (Side-by-side page)** and **"分屏" (Split)** — switch any time, click again to go back to the original; the choice is remembered per paper. **Mode A 「对照」** shows babeldoc's `-dual.pdf` (original and translation laid out on the same doubled-width page, auto fit-width); **Mode B 「分屏」** puts the original on the left and `-zh.pdf` on the right with **synchronised scrolling** (same page + same in-page ratio). **Costs and prerequisites**: mode A needs `-dual.pdf` and mode B needs `-zh.pdf`; both are produced by babeldoc (click "中" in the toolbar — **the first run takes a few minutes** and needs the translation endpoint); **without the artifact the button is greyed out with an explanation**; the split view needs a panel at least 640px wide — narrower than that it explains instead of switching. - ➕ **Two optional add-ons** (off by default, see "Advanced"): MinerU parsing — scanned PDFs become readable, formulas become click-citable and searchable; an embeddings endpoint — plain-language semantic search Installing **does not modify the official UI**: a single "📚 Paper Reading" toggle appears at the bottom of the sidebar; click to enter reading mode, click again to restore. @@ -68,6 +70,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest ```bash dsh plugin --profile web add @ggboy123/dsh-paper-reader@latest # always newest +# dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.5.0 # or pin a version grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/package.json # check current version # Restart dsh web; hard-refresh the browser (Cmd+Shift+R) to avoid cached reader pages ``` @@ -120,6 +123,50 @@ Configure one of three ways (same priority as the translate endpoint): the setti - After a rename: transcripts/translations/study profiles move along; past chat sessions are **kept** but no longer listed under the new name (session ids encode the old name); case-only renames work on macOS/Windows - Oddly named files placed into `data/` by hand (e.g. `.hidden.pdf`) can still be deleted/renamed — nothing becomes a visible-but-undeletable orphan +## Selection translation and bilingual split view (v1.5.0) + +### Selection translation (formulas preserved) + +**How to use**: select a passage in the reader → the familiar "select-to-ask" bubble appears (behaviour unchanged) → click **"译" (Translate)** → a **translation bubble** opens containing **only the translation** (plus one honest note line). Re-select to retranslate; click outside or press Esc to dismiss. + +That bubble has **no** "insert into chat" and **no** "copy" button (as requested), and the plugin **never** writes to your clipboard automatically. The asking path is untouched: just select again and you are back in the original ask bubble. + +**What happens to formulas** (the core of this feature): + +- Formulas in the PDF text layer are **unreadable**: `$F_{\theta}$` often degrades to a bare `F` (the subscript is dropped entirely), `\sum` becomes `X`, fractions get flattened into one line, and delimiter pairs can even turn into control characters. Feeding that to a model guarantees wrong formulas in the translation. +- So: **each formula inside the selection is replaced by the LaTeX MinerU recognized**, and the model is explicitly told to **keep `$…$` verbatim** (no translation, no rewriting, no omissions, no added symbols). When the reply comes back, every formula token is **checked** to still be present, and the bubble tells you honestly if any went missing. +- Both kinds are covered: **display formulas** (located exactly with the MinerU `equation` block bbox) and **inline formulas embedded in prose** (e.g. `π(a|s)`, `Q(s,a)`, `x_t`). Inline formulas have **no bbox of their own** in MinerU's output (they live inside a text block), so the plugin recovers them by fuzzy-aligning the selection text against the MinerU block text. + +**Prerequisites and costs (please read first)**: + +| # | Prerequisite | What happens without it | +| --- | --- | --- | +| 1 | **A configured translation endpoint** (Settings → Paper Reader, or ⚙ in the reader toolbar; the same config used for Chinese-PDF generation) | Clicking "译" **opens the endpoint form automatically**, and **retries this translation** after you save (no wasted click). If the endpoint is incomplete the server refuses and the UI opens that form | +| 2 | **Formula preservation requires the paper to have been parsed by MinerU** (a `.mineru.json` artifact) | **Falls back to plain translation**: prose is translated normally, but formulas are unreadable in the text layer, so the bubble honestly says "this paper was not parsed with MinerU: the translation may be inaccurate". **By design, not a defect** | +| 3 | An artifact exists but one passage could not be aligned | Only the alignable parts are restored; the bubble says "some formulas could not be restored" | +| 4 | A very short selection (fewer than 4 effective characters, e.g. the lone `F`) | No restoration (not enough signal); the bubble says "selection too short, formula not restored" | + +**Known limitations (stated plainly, not glossed over)**: + +- **No request has ever been sent to a real LLM endpoint**: all development verification used a fake upstream (to avoid spending money and to avoid using your key). "Does the model really keep formulas verbatim" is backed by two decidable assertions — a **byte-level prompt contract** and a **per-formula check on the reply** — but there is no end-to-end run against a real provider key. +- MinerU occasionally splits **one formula into two `$…$` fragments** (the first ending in `=`). In that shape only half a formula may be restored (e.g. `action $\hat{a}_t\sim\pi_\theta(\cdot\ |$`); the `$`-pairing check cannot catch it. Not observed in the local corpus; left for follow-up. +- A **literal `$`** inside the selection (a real dollar sign printed in the text) can, in an extreme shape, produce a false "N formulas were not preserved" note. Recorded; it does not affect the translation itself. +- **No formula replacement in the Chinese / dual views** (the bbox only holds for original pages), which is why the "译" button only appears in the original view. + +### Bilingual split view (two modes) + +Two new toolbar buttons; switch any time and **click again to return to the original**. Each paper remembers its last view (and the memory follows the paper across renames): + +| Button | Mode | What you are looking at | Prerequisite | +| --- | --- | --- | --- | +| **对照** | A: side-by-side page | babeldoc's `-dual.pdf` (original + translation on one doubled-width page) | needs `-dual.pdf` | +| **分屏** | B: two panes | left: original `.pdf`; right: `-zh.pdf` | needs `-zh.pdf` | + +- **Both artifacts come from babeldoc**: click "中" in the toolbar (**the first run installs the engine and translates — a few minutes**, and needs the translation endpoint). If the artifact is missing the button is **greyed out with the reason stated** — never a dead click. +- **Synchronised scrolling**: scrolling either pane moves the other to the **same page + same in-page ratio**; whichever pane you are scrolling leads, so the two never fight each other. In paged mode (`▣`) both panes show the same page. **If the Chinese PDF has a different page count, the panes are mapped proportionally** and a one-off notice appears in the status bar. +- **Width**: the split view needs a panel at least **640px** wide; narrower, clicking "分屏" only explains and does **not** switch. Shrinking below 640px while split falls back to the original automatically. +- **Interaction with existing features**: while split, the left pane is the ordinary original view — **formula hotspots stay clickable** (the right pane neither participates in hit-testing nor gets a hotspot layer), the formula panel keeps working, and the "中" toggle is disabled during split (to avoid conflicting semantics). Switching to the Chinese / dual view still disables hotspots, exactly as before (the bbox only holds for original pages). + ## Contributing ```bash @@ -154,6 +201,7 @@ src/ study.ts pure functions: study profile (plan + quiz scores) I/O translate.ts pure functions: babeldoc invocation (Chinese / bilingual PDF generation) babeldoc-install.ts pure functions: auto-install babeldoc without a Python env (uv → managed Python → venv) + translate-selection.ts pure functions: selection translation with formula preservation (aligns inline formulas against MinerU block text, prompt/request-body contract, per-formula preservation check, in-memory LRU cache) rerank.ts pure functions: search_paper semantic rerank (TypeSafe/Jev; falls back to keyword ranking) preset.ts pure functions: installs the bundled agent preset into $DSH_HOME/.agent-presets/ presets/paper-reader/ the "Paper Tutor" agent preset (full persona prompt + compaction) diff --git a/package.json b/package.json index 3db6823..6c59331 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ggboy123/dsh-paper-reader", - "version": "1.4.1", + "version": "1.5.0", "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。", "license": "MIT", "type": "module", diff --git a/reader/index.html b/reader/index.html index f97fbba..dca6544 100644 --- a/reader/index.html +++ b/reader/index.html @@ -48,12 +48,22 @@ @media (max-width: 560px) { #ver { display: none; } } /* ---------- PDF 区 ---------- */ + /* #split-wrap 是分屏(模式 B)的两列行容器:非分屏时只有原文列,行为与改造前一致。 + #pdf-pane 从 #main 的直接子节点变成它的子节点——#main 的 flex 语义不变。 */ #main { display: flex; flex: 1; min-height: 0; position: relative; } /* relative:公式面板绝对定位的锚 */ - #pdf-pane { flex: 1; overflow: auto; padding: 16px; } + #split-wrap { display: flex; flex-direction: row; gap: 8px; flex: 1; min-width: 0; } + #pdf-pane { flex: 1 1 0; min-width: 0; overflow: auto; padding: 16px; } + /* 中文列(仅分屏出现):与原文列同权重、各自滚动 */ + #pdf-pane-zh { flex: 1 1 0; min-width: 0; overflow: auto; padding: 16px; display: none; } + #split-wrap.on > #pdf-pane-zh { display: block; } + .pane-label { display: none; position: sticky; top: -16px; z-index: 3; background: var(--bg); color: var(--text-2); + font-size: 12px; font-weight: 600; padding: 2px 0 6px; } + #split-wrap.on .pane-label { display: block; } /* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */ #pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; } + #pdf-pages-zh { display: flex; flex-direction: column; align-items: safe center; gap: 14px; } /* 翻页模式:单页垂直居中(safe center 同理,放大后顶部仍可滚到) */ - #pdf-pages.paged { min-height: 100%; justify-content: safe center; gap: 0; } + #pdf-pages.paged, #pdf-pages-zh.paged { min-height: 100%; justify-content: safe center; gap: 0; } /* ---------- 翻页控件 ---------- */ #pager { display: flex; align-items: center; gap: 4px; flex: none; } @@ -127,6 +137,20 @@ #ask-send { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 5px 14px; cursor: pointer; } #ask-send:disabled { opacity: .5; cursor: default; } #ask-cancel { background: none; border: none; color: var(--text-2); cursor: pointer; } + /* 「译」动作按钮(加法:只在原文视图的纯文字选区出现,见 showAskPop) */ + #ask-translate { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--accent); cursor: pointer; } + + /* ---------- 划词译文气泡 ---------- + 用户明确要求:**只显示译文**,不做「插入会话」「复制」按钮 ⇒ 气泡内部零 button 元素。 + 提示(诚实降级文案)走独立的 #tr-note,绝不混进 #tr-body。 */ + #tr-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: min(360px, calc(100vw - 16px)); max-height: 45vh; overflow: auto; } + #tr-status { font-size: 12px; color: var(--text-2); } + #tr-status:empty { display: none; } + #tr-body { font-size: 13px; white-space: pre-wrap; word-break: break-word; } + #tr-note { font-size: 12px; color: var(--text-2); margin-top: 6px; line-height: 1.5; } + #tr-note:empty { display: none; } /* ---------- 翻译端点设置浮层 ---------- */ /* 阅读器在窄 iframe 里,宽度要能收缩 */ @@ -151,6 +175,8 @@ 论文伴读 + + @@ -171,9 +197,16 @@
-
-
- +
+
+
原文
+
+ +
+
+
中文
+
+

公式

@@ -187,11 +220,19 @@

公式

+
+ +
+
+
+
+
+

翻译端点

选预设供应商,或选「自定义」填任意 OpenAI 兼容端点(通义 / 本地 vLLM 等)。保存前会先测试连接。
@@ -232,6 +273,8 @@

翻译端点

minus: '', plus: '', sigma: '', + columns: '', + split: '', spin: '', }; const iconHtml = (name) => ``; @@ -244,6 +287,9 @@

翻译端点

const err = new Error(body.error || body.reason || ('HTTP ' + r.status)); err.code = body.code; // 机器可读码(如 no-endpoint)——让调用方靠它分支,别去匹配中文文案 err.status = r.status; + // 划词翻译的 502 带上游状态码:客户端据此出 §3.7② 的分档文案(拒绝/限流/端点错误), + // 而不是去 match 服务端的自由文案。 + err.upstreamStatus = body.upstreamStatus; throw err; } return r.json(); @@ -264,7 +310,11 @@

翻译端点

let current = null; // { topic, name } let zhState = { zh: false, dual: false, busy: false }; let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它 -let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文 +let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文(**分屏 split 也是 null**,见 C17) +// 视图模式:四个控件(中/对照/分屏)挂在同一个状态上,互斥(§4.3)。 +// 'split'(模式 B)刻意让 zhVariant 保持 null —— 这样 eqHotEnabled()/公式面板文案/fitMode +// 等既有分支继续按「原文」工作,零改动。 +let viewMode = 'orig'; // 'orig' | 'zh' | 'dual' | 'split' // 每篇论文记住上次用的语言版本:伴读会话的跳转链接原地跳页不重载, // 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立 const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`; @@ -283,6 +333,10 @@

翻译端点

async function openPaper(topic, name, opts = {}) { if (!name) return; current = { topic, name }; + // R-01/N-01:换文献的在飞译文必须**立刻**失效——必须放在本函数第一处 await 之前。 + // 原来它在三次 await(`/api/paper`、`refreshZhState`、`loadFormulas`)之后,落在那几个窗口里 + // 到达的旧响应仍会点亮气泡(t8 复审 N-01:P-A3 实测 seq 2→3 时气泡已显示上一篇译文)。 + invalidateTranslate(); localStorage.setItem('dpr.last', JSON.stringify(current)); curPage = opts.page || 1; // 换论文从第一页(或跳转目标页)开始;变体切换/重译不走这里,页码保持 $('status').textContent = '加载 PDF…'; @@ -298,17 +352,28 @@

翻译端点

pathBtn.title = (meta.pdfPath || '') + '(点击复制)'; pathBtn.style.display = meta.pdfPath ? '' : 'none'; await refreshZhState(); - // 恢复该论文上次的语言版本(译文已不存在时回落原文) + // 恢复该论文上次的视图模式(持久化复用同一个键,取值域扩到 'split';见 §4.3) const saved = localStorage.getItem(variantKey(topic, name)); - zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null; + viewMode = viewModeFromSaved(saved, zhState); + if (saved && viewMode === 'orig') localStorage.setItem(variantKey(topic, name), ''); // 非空但不可用 → 回写空(防脏值) + zhVariant = viewMode === 'zh' ? 'zh' : viewMode === 'dual' ? 'dual' : null; // split → null $('zh-toggle').classList.toggle('active', zhVariant !== null); - if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽 + // 译文版页宽与原文不同(对照双栏≈2×),分屏每列只有一半宽 ⇒ 都先进适宽 + if (zhVariant || viewMode === 'split') { fitMode = true; fitBtn.classList.add('active'); } // 公式索引(MinerU content_list):**必须在 loadPdf 之前到货**——热区是在 paintPage 里 // 按 eqIndex 铺的,先渲染会漏画/残留上一篇的矩形;又必须排在 zhVariant 定稿之后—— // eqHotEnabled()/renderEqPanel() 都按 zhVariant 决定「译文视图关热区」。失败不影响打开文献。 await loadFormulas(); updateDlBtn(); - await loadPdf(pdfUrl(zhVariant)); + updateViewButtons(); + if (viewMode === 'split') { + await openSplit(); // 内部自己拉原文 + -zh.pdf 两个文档 + } else { + // R-02:原地换文献时**无条件**退掉分屏(幂等)——否则新论文有 -zh.pdf、而持久化视图又不是 + // split 时,refreshZhState 的回落守卫不触发,中文列会留着**上一篇**的 PDF(pdfDocZh 也不释放)。 + teardownSplit(); + await loadPdf(pdfUrl(zhVariant)); + } if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询 } @@ -357,25 +422,103 @@

翻译端点

const canRegen = (zhState.zh || zhState.dual) && !zhState.busy; regen.style.display = canRegen ? '' : 'none'; if (!canRegen) disarmRegen(); + // 分屏激活中 -zh.pdf 没了(重翻/外部删除)→ 自动回落原文 + toast + 把持久化写回 ''(§4.2/B8) + if (viewMode === 'split' && !zhState.zh) { + toast('中文版不存在,已退回原文', 4000); + await setViewMode('orig'); + return; + } + updateViewButtons(); +} + +/** 顶栏三个视图控件的状态机(§4.3):文案/active/disabled/title 全在这一处决定。 */ +function updateViewButtons() { + const zt = $('zh-toggle'), dt = $('dual-toggle'), st = $('split-toggle'); + const hasZh = zhState.zh, hasDual = zhState.dual, hasAny = hasZh || hasDual; + const splitOn = viewMode === 'split'; + // 「中」的语义**不变**:原文 ↔ (有 zh ? 'zh' : 'dual');分屏时加闸(避免在 zhVariant===null + // 的误解下误开生成流程),其余情况与改造前逐字一致。 + zt.disabled = splitOn || zhState.busy; + zt.title = splitOn ? '分屏对照中:先关闭分屏' + : zhState.busy ? (zhState.phase || `翻译进行中${zhState.model ? `(${zhState.model})` : ''}…`) + : hasAny ? '切换中文译文' + : '还没有中文版,点击生成(babeldoc,需要几分钟)'; + zt.classList.toggle('active', zhVariant !== null); + + dt.style.display = ''; // D1(队长裁定):按钮恒可见,无产物时 disabled + title 说明原因, + // 把死控件变成解释(与 §6 B1 字面一致,也与 zhonly/dualonly 两态一致) + dt.classList.toggle('active', viewMode === 'dual'); + dt.disabled = zhState.busy || !hasDual; + dt.title = zhState.busy ? (zhState.phase || '翻译进行中…') + : !hasDual ? '还没有中英对照版,先用「中」生成(babeldoc,需要几分钟)' + : viewMode === 'dual' ? '当前:中英对照(模式 A),点击回到原文' + : '用 babeldoc 的中英对照 PDF(模式 A)'; + + st.style.display = ''; // D1:同上 + st.classList.toggle('active', splitOn); + st.disabled = zhState.busy || !hasZh; + st.title = zhState.busy ? (zhState.phase || '翻译进行中…') + : !hasZh ? (hasDual ? '需先生成中文版(模式 B 用 -zh.pdf 与原文并排)' : '还没有中文版,先用「中」生成(babeldoc,需要几分钟)') + : splitOn ? '当前:分屏对照(模式 B),点击回到原文' + : '原文与中文版并排同步滚动(模式 B)'; } $('zh-toggle').onclick = async () => { - if (!current || zhState.busy) return; + if (!current || zhState.busy || viewMode === 'split') return; if (zhState.dual || zhState.zh) { - // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照 - zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null; - localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? ''); - $('zh-toggle').classList.toggle('active', zhVariant !== null); - updateDlBtn(); - // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切 - fitMode = true; fitBtn.classList.add('active'); - renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉) - await loadPdf(pdfUrl(zhVariant)); + // 两态切换:原文 ↔ 纯中文;只存在对照版时退到对照(既有语义一字不动) + await setViewMode(zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : 'orig'); return; } await startGenerate(false); }; +/* ================= 视图模式(模式 A 对照 / 模式 B 分屏)================= + 三个控件共用 viewMode;进入任一非 orig 模式都先退出另一个(互斥)。 */ + +/** 持久化复用既有键:lib/client.js 的改名搬家只搬 `dpr.variant.` 前缀,新增键不会被迁移(C19)。 */ +function persistViewMode() { + localStorage.setItem(variantKey(current.topic, current.name), viewMode === 'orig' ? '' : viewMode); +} + +/** 读回持久化的视图模式(§4.3 的取值表):译文/分屏不存在时回落原文,非空不可用的值由调用方回写空。 */ +function viewModeFromSaved(saved, st) { + if (saved === 'zh' && st.zh) return 'zh'; + if (saved === 'dual' && st.dual) return 'dual'; + if (saved === 'split' && st.zh) return 'split'; // 分屏需要 -zh.pdf + return 'orig'; +} + +/** 视图模式的状态机(守卫 → 赋值 → 持久化 → 三个按钮/下载按钮/公式面板)。 + * 返回 false = 守卫拦下(状态未变)。渲染留给 setViewMode,便于单测状态机(B11/B12/R4)。 */ +function applyViewMode(mode) { + if (!current) return false; + if (mode === 'dual' && !zhState.dual) return false; // 没有 -dual.pdf,不进(按钮已 disabled) + if (mode === 'split' && !zhState.zh) return false; // 没有 -zh.pdf,不进 + if (mode === 'split' && window.innerWidth < SPLIT_MIN_WIDTH) { + toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000); + return false; + } + viewMode = mode; + zhVariant = mode === 'zh' ? 'zh' : mode === 'dual' ? 'dual' : null; // split → null(C17) + persistViewMode(); + // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×、分屏每列半宽),不重算会溢出裁切 + fitMode = true; fitBtn.classList.add('active'); + updateViewButtons(); + updateDlBtn(); + renderEqPanel(); // 译文视图要给出「没有位置信息」的说明(热区同时被 eqHotEnabled 关掉) + return true; +} + +async function setViewMode(mode) { + if (!applyViewMode(mode)) return; + if (mode === 'split') { await openSplit(); return; } + teardownSplit(); + await loadPdf(pdfUrl(zhVariant)); +} +$('dual-toggle').onclick = () => setViewMode(viewMode === 'dual' ? 'orig' : 'dual'); +$('split-toggle').onclick = () => setViewMode(viewMode === 'split' ? 'orig' : 'split'); + // 生成/重翻共用:force=true 时服务端先删旧译文再启动;成功后轮询到完成 async function startGenerate(force) { try { @@ -400,8 +543,9 @@

翻译端点

clearInterval(timer); if (zhState.dual || zhState.zh) { toast('中文版已生成'); - // 重翻完成时用户可能正停在译文视图:原地刷新同一变体,而不是来回切换 - if (zhVariant) await loadPdf(pdfUrl(zhVariant)); + // 重翻完成时用户可能正停在译文视图/分屏:原地刷新,而不是来回切换 + if (viewMode === 'split') await renderSplit(); + else if (zhVariant) await loadPdf(pdfUrl(zhVariant)); else $('zh-toggle').click(); } else toast('生成失败:' + (zhState.error || '未知原因'), 8000); @@ -463,7 +607,8 @@

翻译端点

// 保存成功后是否自动接着生成:点「中译」/「重翻」发现没配 → 弹本表单 → 填完直接开跑 let cfgRetryGenerate = false; -let cfgRetryForce = false; // 待重试的是不是「重翻」(重翻进来时旧译文还在,zh-toggle 点击语义不对,必须带 force) +let cfgRetryForce = false; // 待重试的是不是「重翻」(regen 图标进来时旧译文还在,zh-toggle 点击语义不对,必须带 force) +let cfgRetryTranslate = false; // 待重试的是不是「划词翻译」(点「译」发现没配端点 → 填完自动重试这次翻译) async function openCfgPop(retryGenerate) { cfgRetryGenerate = Boolean(retryGenerate); @@ -495,14 +640,14 @@

翻译端点

pop.style.top = Math.max(8, (window.innerHeight - pop.offsetHeight) / 3) + 'px'; $('cfg-key').focus(); } -function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; cfgRetryForce = false; } +function closeCfgPop() { $('cfg-pop').style.display = 'none'; cfgRetryGenerate = false; cfgRetryForce = false; cfgRetryTranslate = false; } $('cfg-open').onclick = () => openCfgPop(false); $('cfg-cancel').onclick = closeCfgPop; $('cfg-save').onclick = async () => { - // retry/force 先捞出来:closeCfgPop 会把这两个待确认态清零 - const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate, force = cfgRetryForce; + // retry/force/translate 先捞出来:closeCfgPop 会把这三个待确认态清零 + const btn = $('cfg-save'), label = btn.textContent, retry = cfgRetryGenerate, force = cfgRetryForce, retryTr = cfgRetryTranslate; btn.disabled = true; btn.textContent = '测试中…'; try { // 服务端先打一发极小的 chat/completions 验证 url/key/model,通过才落盘 @@ -523,7 +668,8 @@

翻译端点

} closeCfgPop(); await refreshZhState(); - if (retry) startGenerate(force); // 沿用触发来源的语义:「中译」=生成,「重翻」=重翻 + if (retry) startGenerate(force); // 沿用触发来源的语义:zh-toggle=生成,regen=重翻 + else if (retryTr) translateSelection(); // 划词翻译发现没配端点 → 填完自动重试这一次翻译(§3.7①) else toast('翻译端点已保存'); }; @@ -565,22 +711,27 @@

翻译端点

}); })(); -async function loadPdf(url) { +async function loadPdf(url, opts = {}) { await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释) pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象 $('empty').style.display = 'none'; // 翻页控件与模式切换按钮:文档就绪后才出现 $('pager').style.display = ''; $('mode-toggle').style.display = ''; + // 分屏(模式 B)自己安排两列的渲染与适宽:这里只把原文文档装上,不触发单列渲染 + if (opts.deferRender) return; if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → rerender await rerender(); } -/** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。 */ -function makePageNode(i, viewport) { +/** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。 + * `doc` = 'orig' | 'zh':**无条件**写在每个页节点上(分屏时两个文档的页号相同,热区几何 + * 与选区归属都靠它判别;只有分屏才写的话普通模式的选择器会取到 null)。 */ +function makePageNode(i, viewport, doc = 'orig') { const div = document.createElement('div'); div.className = 'page'; div.dataset.page = i; + div.dataset.doc = doc; div.style.width = viewport.width + 'px'; const canvas = document.createElement('canvas'); @@ -625,8 +776,10 @@

翻译端点

paintEqLayer(div, viewport, +div.dataset.page); } -/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */ +/** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 + * 分屏(模式 B)是第三套布局,转到 renderSplit();renderPaged/renderAllPages 的既有语义**不变**。 */ function rerender() { + if (viewMode === 'split') return renderSplit(); return pageMode === 'paged' ? renderPaged() : renderAllPages(); } @@ -709,9 +862,18 @@

翻译端点

const fitBtn = $('zoom-fit'); fitBtn.classList.add('active'); function exitFitMode() { fitMode = false; fitBtn.classList.remove('active'); } +/** 适宽的可用宽度(分屏时按**较窄的那一列**算,保证两列页高一致)。每列 padding 16px×2。 */ +function fitPaneWidth() { + if (viewMode === 'split') { + const avail = [$('pdf-pane').clientWidth - 32, $('pdf-pane-zh').clientWidth - 32].filter((w) => w > 0) + if (!avail.length) return 0 + return Math.min(...avail) + } + return $('pdf-pane').clientWidth - 32 +} function fitToWidth() { if (!pdfDoc) return // resize 可能先于首次 loadPdf 触发(布局换位/条带注入会改容器尺寸) - const paneWidth = $('pdf-pane').clientWidth - 32 + const paneWidth = fitPaneWidth() if (paneWidth <= 0) return // 隐藏期间尺寸为 0,算出的缩放无意义 pdfDoc.getPage(1).then((p) => setZoom(paneWidth / p.getViewport({ scale: 1 }).width)) } @@ -719,13 +881,25 @@

翻译端点

$('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); }; fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); }; window.addEventListener('resize', () => { + if (viewMode === 'split' && window.innerWidth < SPLIT_MIN_WIDTH) { + // 分屏被缩到硬闸门以下:回落原文 + 同一提示(§4.2) + toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000); + setViewMode('orig'); + return; + } if (fitMode) fitToWidth(); // setZoom → rerender,顺带消化 pendingRerender else if (pendingRerender && pdfDoc) { pendingRerender = false; rerender(); } // 隐藏期间欠下的重渲,恢复尺寸后补画 }); +/** 原文列的页节点:分屏时中文列有**同样的 page 号**,不加限定会取到中文列(C18/R7)。 + * 统一收敛到 `#pdf-pages` 之下——四个引文高亮/跳转入口共用。 */ +function origPageNode(n) { + return document.querySelector(`#pdf-pages .page[data-page="${n}"]`); +} + /* ================= 翻页模式:页码控件 + 模式切换 + 键盘 ================= */ function flashPage() { - const div = document.querySelector(`.page[data-page="${curPage}"]`); + const div = origPageNode(curPage); if (!div) return; div.classList.add('flash'); setTimeout(() => div.classList.remove('flash'), 1600); @@ -748,7 +922,7 @@

翻译端点

if (pageMode === 'paged') { if (p === curPage) { flashPage(); return; } curPage = p; - await renderPaged(); + await rerender(); flashPage(); } else { curPage = p; @@ -789,10 +963,10 @@

翻译端点

localStorage.setItem('dpr.pageMode', pageMode); updateModeToggle(); if (pageMode === 'paged') { - await renderPaged(); + await rerender(); // 分屏时转 renderSplit(两列同渲 curPage) flashPage(); } else { - await renderAllPages(); + await rerender(); // 非分屏时等价于 renderAllPages jumpToPage(curPage); } }; @@ -811,7 +985,7 @@

翻译端点

function jumpToPage(n) { if (pageMode === 'paged') { goPage(n); return; } - const div = document.querySelector(`.page[data-page="${n}"]`); + const div = origPageNode(n); // 引文跳转只作用于原文列(分屏时中文列不得被滚到) if (!div) return; div.scrollIntoView({ behavior: 'smooth', block: 'start' }); div.classList.add('flash'); @@ -829,7 +1003,7 @@

翻译端点

document.addEventListener('mousedown', clearHighlights); function highlightSnippet(page, q) { - const spans = [...(document.querySelector(`.page[data-page="${page}"]`)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])]; + const spans = [...(origPageNode(page)?.querySelectorAll('.textLayer span:not(.markedContent)') ?? [])]; if (!spans.length || !q) return false; let hay = ''; const startOf = []; @@ -862,12 +1036,12 @@

翻译端点

if (pageMode === 'paged') { if (!pdfDoc) return; const p = Math.min(p0, pdfDoc.numPages); - if (p !== curPage) { curPage = p; await renderPaged(); } + if (p !== curPage) { curPage = p; await rerender(); } if (!(q && highlightSnippet(p, q))) flashPage(); return; } for (let i = 0; i < 100; i++) { - if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) { + if (pdfDoc && origPageNode(Math.min(p0, pdfDoc.numPages))) { const p = Math.min(p0, pdfDoc.numPages); if (!(q && highlightSnippet(p, q))) jumpToPage(p); return; @@ -962,11 +1136,17 @@

翻译端点

}; } -/** 为已渲染的一页铺热区层(只画描边,不接事件)。 */ +/** 为已渲染的一页铺热区层(只画描边,不接事件)。 + * **只在原文列铺**:分屏(模式 B)时中文列的页号与原文列相同,而 EQ_BY_PAGE 是全局的, + * 照铺会让中文列的点击命中原文几何(C18/R7)。文档作用域从页节点自身读——唯一铺层点的 + * 调用签名被既有断言钉死为 `paintEqLayer(div, viewport, +div.dataset.page)` + * (test/merge-verify-t3.test.mjs 的 checkSolePaintPoint),不能再加第 4 个参数。 */ function paintEqLayer(pageDiv, viewport, pageNo) { const old = pageDiv.querySelector('.eqLayer'); if (old) old.remove(); const items = []; + const doc = pageDiv.dataset.doc || 'orig'; + if (doc !== 'orig') return; // 中文列:不建层、不写 EQ_BY_PAGE if (eqHotEnabled()) { const lay = document.createElement('div'); lay.className = 'eqLayer'; @@ -1003,6 +1183,8 @@

翻译端点

const el = e.target; const pageDiv = el && el.closest ? el.closest('.page[data-page]') : null; if (!pageDiv) return null; + // 中文列(分屏)不得命中:它的页号与原文列相同,几何却属于原文页面(C18/R7) + if (pageDiv.dataset.doc && pageDiv.dataset.doc !== 'orig') return null; const items = EQ_BY_PAGE.get(+pageDiv.dataset.page); if (!items || !items.length) return null; const box = pageDiv.getBoundingClientRect(); @@ -1184,6 +1366,17 @@

翻译端点

$('ask-quote').textContent = o.quote.length > 140 ? o.quote.slice(0, 140) + '…' : o.quote; $('ask-input').value = ''; $('ask-copy').style.display = askSelection && askSelection.kind === 'equation' ? 'inline-block' : 'none'; + // 「译」只在「纯文字选区 + 原文视图 + **原文列** + 长度可翻译」时出现。 + // 分屏(模式 B)时 zhVariant 刻意保持 null(C17),中文列也满足那个条件 —— 但中文列的字符 + // 在 collectSelectionChars 里会被整体跳过(几何只对原文页成立)⇒ chunks 为空、只会弹 + // 「选区已失效」。所以这里按列禁用(R-07:e.target/doc 判别),不给误导性文案。 + $('ask-translate').style.display = + askSelection && askSelection.kind === 'text' && zhVariant === null + && (askSelection.doc ?? 'orig') === 'orig' + && askSelection.text.length <= MAX_SELECTION_CHARS + ? 'inline-block' : 'none'; + hideTrPop(); // 新选区/新引用出现即收起上一个译文气泡(同一锚点必然重叠) + invalidateTranslate(); // R-01:新选区/新引用必须让在飞请求失效,否则旧译文会盖到新锚点上 askPop.style.display = 'block'; // 夹在 iframe 视口内(rightbar 窄列里也要能用) const popW = 336, popH = 200; @@ -1213,7 +1406,22 @@

翻译端点

const node = sel.anchorNode && sel.anchorNode.parentElement; if (!node || !node.closest('.textLayer')) { askPop.style.display = 'none'; return; } const range = sel.getRangeAt(0).getBoundingClientRect(); - askSelection = { text, page: +((node.closest('.page') || {}).dataset?.page || 0) || null, kind: 'text' }; + // `doc` = 选区落在哪一列('orig' | 'zh'):分屏时中文列也有 .page 与文本层,但它的几何 + // 对不上原文的 equation 索引,且不该出现「译」(R-07)。无条件写:普通模式恒为 'orig'。 + const pageEl = node.closest('.page[data-doc]') || node.closest('.page'); + askSelection = { + text, + page: +((pageEl || {}).dataset?.page || 0) || null, + kind: 'text', + doc: (pageEl && pageEl.dataset && pageEl.dataset.doc) || 'orig', + }; + // 选区 Range 必须在**选中时**克隆存下来:用户点「译」时浏览器多半已清掉选区。 + // 防御:DOM stub / 极端环境下 getRangeAt 可能不返回 Range(没有 cloneRange),此时存 null, + // 点「译」时按「页面已重绘,请重新选择」如实提示——绝不静默失败。 + try { + const r0 = sel.getRangeAt(0); + if (r0 && typeof r0.cloneRange === 'function') askSelection.range = r0.cloneRange(); + } catch { /* 选区已失效:保持无 range */ } showAskPop({ quote: text, x: e.clientX, y: range.bottom + 8 }); }, 10); }); @@ -1272,6 +1480,454 @@

翻译端点

}; $('ask-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('ask-send').click(); } }); +/* ===================================================================================== + 划词翻译(公式保留)—— 契约 docs/selection-translate-requirements.md §3.1–§3.3 + · 触发:既有 #ask-pop 里新增的「译」按钮 → 隐藏 #ask-pop、弹独立译文气泡 #tr-pop + · 气泡**只有译文**(#tr-body)+ 状态行 + 一行诚实提示(#tr-note);内部零 button + · 选区切块:display 公式按「字符矩形中心点 ∩ equation 盒」整段丢掉乱码、换成紧凑 LaTeX; + 行内公式的还原在服务端做(客户端只送 chunk,不送任何矩形/bbox) + · 新代码一律放在 $('ask-cancel').onclick **之后**(既有源码切片断言, + test/reader-ux-verification.test.mjs VA9) + ===================================================================================== */ +const MAX_SELECTION_CHARS = 4000; // 冻结:选区超长截断并提示 +const MAX_CHUNKS = 200; // 与服务端同一套上限(§3.2-6) +const MAX_TOTAL_CHARS = 8000; +const TR_CLIENT_TIMEOUT_MS = 45000; // 服务端 30s,客户端 45s 兜底(§3.6) +const trPop = $('tr-pop'); +let trBusy = false; +let trSeq = 0; // 过期保护:换选区/换文献后旧译文不得盖上来(仿 renderToken) + +/** + * 让**在飞**的划词翻译请求失效(R-01)。调用点:`showAskPop`(新选区/新引用)与 `openPaper`(换文献)。 + * 只自增 `trSeq` 会让旧请求的 `finally` 因为 `seq !== trSeq` 而不清 `trBusy`(按钮从此永久失效), + * 所以这里一并把 busy 复位;旧响应回来时 `seq` 不匹配 ⇒ 直接丢弃,绝不会写到新锚点的气泡上。 + */ +function invalidateTranslate() { + trSeq++; + trBusy = false; + // 顺手收起气泡并清掉内容:在飞请求回来时会被 seq 拦下,**不会**再写气泡, + // 所以留着「翻译中…」或上一篇的译文都是误导(N-01 的窗口里尤其明显)。 + hideTrPop(); + $('tr-status').textContent = ''; + $('tr-body').textContent = ''; + $('tr-note').textContent = ''; +} + +function hideTrPop() { trPop.style.display = 'none'; } + +/** 展示译文气泡。用 textContent(译文是模型输出,当不可信数据处理,**绝不 innerHTML**)。 + * 定位照 openCfgPop:先显示再量尺寸,不写死宽高常量。 */ +function showTrPop(status, body, note) { + $('tr-status').textContent = status || ''; + $('tr-body').textContent = body || ''; + $('tr-note').textContent = note || ''; + trPop.style.display = 'block'; + let rect = { left: 16, bottom: 16 }; + try { if (askSelection && askSelection.range) rect = askSelection.range.getBoundingClientRect(); } catch { /* 选区失效用兜底锚点 */ } + trPop.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - trPop.offsetWidth - 8)) + 'px'; + trPop.style.top = Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - trPop.offsetHeight - 8)) + 'px'; +} + +/** 归属判定(§3.3① 冻结规则):**字符矩形中心点落在 equation 盒内**(不用「有任何交叠」—— + * 实测 2/18 例会把下一行正文吞进来);并列取面积最小者(与既有热区点击 hitTestFormula 同规则)。 */ +function formulaBoxByCenter(rect, items) { + if (!items || !items.length) return null; + const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2; + let best = null; + for (const it of items) { + if (cx >= it.x0 && cx <= it.x1 && cy >= it.y0 && cy <= it.y1) { + if (!best || it.w * it.h < best.w * best.h) best = it; + } + } + return best; +} + +/** 当前页几何(paintEqLayer 写进 EQ_BY_PAGE 的 items)。仅在原文视图 + 有 bbox 时可用。 */ +function eqItemsFor(pageNo) { + if (!eqHotEnabled()) return null; + return EQ_BY_PAGE.get(pageNo) || null; +} + +/** 字符序列 → chunk 序列(§3.2-6 冻结切法)。 + * chars: [{ch, page, rect}](rect 为页面局部 px;缺失/退化的字符按纯文本处理) + * 规则:同页 + 同归属 + 无换行的最大连续段;换行是 text 段的结尾字符;公式段整体丢弃乱码、 + * 换成紧凑 LaTeX(`$` 定界在服务端加)。 */ +function buildChunks(chars, itemsByPage) { + const out = []; + let cur = null; + const flush = () => { if (cur) out.push(cur); cur = null; }; + for (const c of chars) { + // 命中物是热区几何 {eq, x0, y0, x1, y1, …}(见 eqGeom);公式本体在 .eq 上 + const hit = c.rect ? formulaBoxByCenter(c.rect, itemsByPage ? itemsByPage(c.page) : null) : null; + const entry = hit ? hit.eq : null; + const kind = entry ? 'formula' : 'text'; + if (c.ch === '\n' && kind === 'text') { + if (cur && cur.kind === 'text') { cur.text += '\n'; flush(); } + else { flush(); out.push({ page: c.page, kind: 'text', text: '\n' }); } + continue; + } + if (cur && cur.page === c.page && cur.kind === kind && (kind === 'text' || cur.eqId === entry.id)) { + if (kind === 'text') cur.text += c.ch; + continue; // formula:同一条公式的后续字符直接丢弃(LaTeX 是原子的) + } + flush(); + cur = kind === 'formula' + ? { page: c.page, kind: 'formula', latex: eqLatex(entry.latex), eqId: entry.id } + : { page: c.page, kind: 'text', text: c.ch }; + } + flush(); + // 规模闸门:段数 ≤200、单段 text ≤4000、总长 ≤8000(超限从尾部截断,并让调用方提示) + const capped = []; + let total = 0; + for (const c of out) { + const payload = c.kind === 'text' ? c.text : c.latex; + if (capped.length >= MAX_CHUNKS) break; + const room = MAX_TOTAL_CHARS - total; + if (room <= 0) break; + let s = payload; + const cap = c.kind === 'text' ? Math.min(MAX_SELECTION_CHARS, room) : room; + if (s.length > cap) s = s.slice(0, cap); + if (!s) continue; + total += s.length; + capped.push(c.kind === 'text' ? { page: c.page, kind: 'text', text: s } : { page: c.page, kind: 'formula', latex: s }); + } + return capped; +} + +/** 选区覆盖的文本节点(按文档序)。手写递归而不是 createTreeWalker: + * 选区只需要「起点与终点之间的文本节点」,而 TreeWalker 在部分 webview/测试桩里缺实现。 */ +function textNodesUnder(node) { + const out = []; + const stack = [node]; + while (stack.length) { + const n = stack.pop(); + if (!n) continue; + if (n.nodeType === 3) { out.push(n); continue; } + const kids = n.childNodes ? [...n.childNodes] : []; + for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); + } + return out; +} + +/** 选区内每个字符 + 它的页面局部矩形(页面局部 = rect 减 pageDiv 的 bounding rect; + * `.page` 上没有 CSS transform,缩放体现在 viewport 尺寸里 ⇒ 与滚动/翻页/缩放都无关, + * 与既有 hitTestFormula 用的是同一套坐标)。 */ +function collectSelectionChars(range) { + const root = range.commonAncestorContainer; + if (!root) return []; + const nodes = textNodesUnder(root).filter((n) => { + if (typeof range.intersectsNode === 'function') { try { return range.intersectsNode(n); } catch { return true; } } + return n === range.startContainer || n === range.endContainer; + }); + const out = []; + for (const node of nodes) { + const data = node.data ?? ''; + let start = 0, end = data.length; + if (node === range.startContainer) start = range.startOffset; + if (node === range.endContainer) end = range.endOffset; + const parent = node.parentElement; + const pageDiv = parent && parent.closest ? parent.closest('.page[data-doc]') : null; + if (!pageDiv) continue; + const doc = pageDiv.dataset.doc || 'orig'; + if (doc !== 'orig') continue; // 分屏时中文列不参与公式归属(同一套几何只对原文成立) + const pageNo = +pageDiv.dataset.page || 0; + const box = pageDiv.getBoundingClientRect(); + const cr = document.createRange(); + for (let i = start; i < end; i++) { + let rect = null; + try { + cr.setStart(node, i); cr.setEnd(node, i + 1); + const r = cr.getBoundingClientRect(); + if (r && r.width > 0 && r.height > 0) { + rect = [r.left - box.left, r.top - box.top, r.right - box.left, r.bottom - box.top]; + } + } catch { rect = null; } // 换行伪影/失效 Range:退化成纯文本字符 + out.push({ ch: data[i], page: pageNo, rect }); + } + } + return out; +} + +/** #tr-note 的诚实降级文案(§3.8 文案表,优先级自上而下)。 + * ⚠️ 与需求表的一处**有意偏离**(已在实现报告里回报队长):该表把 `unavailable` 排在 + * `formulaSkipped>0` 之前,但过短选区(如只选一个 `F`)会同时命中两者,按表顺序会显示 + * 「没能定位到原文公式」——而验收项 A13 要求显示「选区太短,公式未还原」。这里让 + * 「什么都没还原 + 有过短 chunk」先报「选区太短」:它更准确,也不会误导用户以为译文是准的。 */ +function trNote(resp) { + const reason = eqIndex && eqIndex.reason; + if (eqIndex && eqIndex.source === 'none' && reason === 'no-mineru-artifact') { + return '⚠ 该论文未用 MinerU 转录:原文里的公式在文本层不可读,译文可能不准(顶栏 ∑ 公式 → 「用 MinerU 重新转录」)'; + } + if (reason === 'no-equations') return '⚠ MinerU 产物里没有公式块:公式可能不准'; + if (reason === 'bad-artifact') return '⚠ MinerU 产物损坏或不可读,已忽略:公式可能不准'; + if (resp.formulaLost > 0) return `⚠ 有 ${resp.formulaLost} 个公式未原样保留,请对照原文`; + if (resp.formulaSkipped > 0 && resp.formulaApplied === 0) return '⚠ 选区太短,公式未还原'; + if (resp.formulaStatus === 'unavailable' && resp.hasBlockIndex) return '⚠ 没能定位到原文公式,译文可能不准'; + if (resp.formulaStatus === 'partial') return '⚠ 部分公式未能还原,译文可能不准'; + if (resp.formulaSkipped > 0) return '⚠ 选区太短,公式未还原'; + return ''; // applied / formulaTotal===0:不显示任何提示 +} + +/** 失败反馈(§3.7② 表,逐条)。返回 {status, toast}:气泡状态行与 toast 的措辞不同处照表分开。 */ +function trFailure(e) { + if (e && e.clientTimeout) return { status: '翻译超时,请重试', toast: '翻译超时,请重试' }; + if (e && e.code === 'timeout') return { status: '翻译超时,请重试', toast: '翻译超时,请重试' }; + if (e && e.code === 'network') return { status: '翻译失败:无法连接端点', toast: '翻译失败:无法连接端点' }; + const up = e && e.upstreamStatus; + if (up === 401 || up === 403) return { status: '翻译失败:端点拒绝(请检查 API key)', toast: `翻译失败:端点拒绝(HTTP ${up})` }; + if (up === 429) return { status: '翻译失败:请求过于频繁,稍后再试', toast: '翻译失败:请求过于频繁,稍后再试' }; + if (typeof up === 'number' && up >= 500) return { status: '翻译失败:端点错误(HTTP 5xx)', toast: '翻译失败:端点错误(HTTP 5xx)' }; + const m = '翻译失败:' + ((e && e.message) || '未知原因'); + return { status: m, toast: m }; +} + +/** 划词翻译入口(#ask-translate.onclick)。 */ +async function translateSelection() { + // 中文列(分屏,doc==='zh')不参与:几何只对原文页成立(R-07;按钮本身也不会显示) + if (!current || !askSelection || askSelection.kind !== 'text' || zhVariant !== null) return; + if ((askSelection.doc ?? 'orig') !== 'orig') return; + if (trBusy) return; + const range = askSelection.range; + if (!range || !range.startContainer || range.startContainer.isConnected === false) { + toast('页面已重绘,请重新选择', 4000); // R5:绝不缓存矩形,也不静默失败 + return; + } + // 用户点按钮时浏览器可能已把选区清掉:先把存下来的 Range 放回去,rects 才取得到 + const sel = window.getSelection(); + if (sel && sel.isCollapsed && typeof sel.addRange === 'function') { + try { sel.removeAllRanges(); sel.addRange(range); } catch { /* 失效选区:下面按空选区处理 */ } + } + let text = ''; + try { text = String(range.toString() || ''); } catch { text = ''; } + if (!text.trim()) { toast('选区已失效,请重新选择', 4000); return; } + const chars = collectSelectionChars(range); + const chunks = buildChunks(chars, eqItemsFor); + if (!chunks.length) { toast('选区已失效,请重新选择', 4000); return; } + if (chars.length > MAX_SELECTION_CHARS) toast(`选区过长,只翻译前 ${MAX_SELECTION_CHARS} 字符`, 4000); + + askPop.style.display = 'none'; // 同一锚点必然重叠 ⇒ 气泡「变成」译文气泡(§3.1) + showTrPop('翻译中…', '', ''); + trBusy = true; + const seq = ++trSeq; + const ac = new AbortController(); + let clientTimeout = false; + const timer = setTimeout(() => { clientTimeout = true; ac.abort(); }, TR_CLIENT_TIMEOUT_MS); + // 诊断可读性:记录本次送出的 chunk 规模(不含文本内容、不含 key) + const charsSent = chunks.reduce((n, c) => n + (c.kind === 'text' ? c.text.length : c.latex.length), 0); + try { + const resp = await api('/translate-selection', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...current, chunks }), + signal: ac.signal, + }); + if (seq !== trSeq) return; // 过期保护:用户已换选区/换文献 + showTrPop('', resp.translation, trNote(resp)); + } catch (e) { + if (seq !== trSeq) return; + if (clientTimeout) e.clientTimeout = true; + if (e && e.code === 'no-endpoint') { + // 未配置端点:打开既有设置浮层,保存成功后自动重试这次翻译(§3.7①) + showTrPop('未配置翻译端点', '', '在上面的设置里填好端点并保存,会自动重试这次翻译'); + cfgRetryTranslate = true; + openCfgPop(false); + return; + } + const f = trFailure(e); + showTrPop(f.status, '', ''); + toast(f.toast + `(选区 ${charsSent} 字符)`, 8000); + } finally { + clearTimeout(timer); + if (seq === trSeq) trBusy = false; + } +} + +$('ask-translate').onclick = () => translateSelection(); +document.addEventListener('mousedown', (e) => { if (!trPop.contains(e.target)) hideTrPop(); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTrPop(); }); + +/* ===================================================================================== + 分屏对照(模式 B):原文 PDF ↔ -zh.pdf 并排同步滚动 —— §4.2/§4.4 + · 两列各自一个 pdf.js 文档;全局 pdfDoc **保持指向原文**(既有逻辑零改动) + · zhVariant 保持 null ⇒ eqHotEnabled()/公式面板/适宽分支继续按「原文」工作(C17) + · 热区只在原文列铺(paintEqLayer 按 pageDiv.dataset.doc 早退)、只在原文列命中(C18/R7) + ===================================================================================== */ +const SPLIT_MIN_WIDTH = 640; // 冻结:面板太窄不做分屏(§4.2) +let pdfDocZh = null; +let splitSyncing = false; +let splitRaf = 0; +let splitWarned = false; + +const rafFn = (fn) => (typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame(fn) : setTimeout(fn, 0)); +const crafFn = (id) => { if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(id); else clearTimeout(id); }; + +/** 纯函数:滚动位置 → 「顶部页号 + 页内比例」(§4.2 锚点)。 + * pages: [{page, offsetTop, offsetHeight}],按 DOM 序。 */ +function anchorOf(pages, scrollTop) { + if (!pages || !pages.length) return null; + let page = pages[pages.length - 1]; + for (const p of pages) { if (p.offsetTop + p.offsetHeight > scrollTop) { page = p; break; } } + return { page: page.page, ratio: (scrollTop - page.offsetTop) / Math.max(1, page.offsetHeight) }; +} + +/** 纯函数:锚点 → 目标列 scrollTop。页数相同(实测常态 19/19)走同页号;不同则按比例映射, + * 并让调用方给出「页数不一致」的一次性提示。 */ +function syncTargetOf(anchor, targetPages, srcPageCount) { + if (!anchor || !targetPages || !targetPages.length) return null; + const n = targetPages.length; + let mismatch = false; + let idx = targetPages.findIndex((p) => p.page === anchor.page); + if (idx < 0) { + mismatch = true; + idx = Math.min(n - 1, Math.max(0, Math.round((anchor.page - 1) * n / Math.max(1, srcPageCount | 0)))); + } else if (srcPageCount && srcPageCount !== n) { + mismatch = true; + } + const p = targetPages[idx]; + return { top: p.offsetTop + anchor.ratio * p.offsetHeight, mismatch }; +} + +/** 一列的页几何(DOM 序)。 */ +function columnPages(box) { + return [...box.children].filter((c) => c.className === 'page') + .map((d) => ({ page: +d.dataset.page || 1, offsetTop: d.offsetTop, offsetHeight: d.offsetHeight })); +} + +const splitCols = () => [ + { pane: $('pdf-pane'), box: $('pdf-pages') }, + { pane: $('pdf-pane-zh'), box: $('pdf-pages-zh') }, +]; + +/** 滚动同步:主导方 = 用户正在滚的那一列;rAF 节流 + 守卫吞掉回声(§4.2/R7)。 */ +function onSplitScroll(src) { + if (viewMode !== 'split' || splitSyncing) return; + const other = splitCols().find((c) => c.pane !== src.pane); + if (!other) return; + splitSyncing = true; // 跟随引起的 scroll 事件被守卫吞掉 + crafFn(splitRaf); + splitRaf = rafFn(() => { + try { + const srcPages = columnPages(src.box); + const a = anchorOf(srcPages, src.pane.scrollTop); + const t = syncTargetOf(a, columnPages(other.box), srcPages.length); + if (t) { + other.pane.scrollTop = t.top; // behavior 默认 auto:不做平滑动画(会和同步互相打架) + if (t.mismatch && !splitWarned) { splitWarned = true; toast('⚠ 中文版页数与原文不一致,按比例同步', 4000); } + } + } finally { + splitSyncing = false; + } + }); +} + +/** 渲染一列的若干页(滚动=全量、翻页=curPage)。热区由 paintPage 按页节点的 data-doc 决定。 */ +async function renderColumn(doc, docKey, container, pages, token, scale) { + for (const i of pages) { + if (token !== renderToken) return; + const page = await doc.getPage(i); + if (token !== renderToken) return; + const viewport = page.getViewport({ scale }); + const div = makePageNode(i, viewport, docKey); + container.appendChild(div); + await paintPage(page, div, viewport, token); + if (token !== renderToken) return; + } +} + +/** 分屏全量渲染入口:两列同时渲(滚动=各列全量、翻页=两列同渲 curPage)。 */ +async function renderSplit() { + if (viewMode !== 'split') return rerender(); + if (!pdfDoc) return; + const pane = $('pdf-pane'); + if (pane.clientHeight === 0) { pendingRerender = true; return; } // 与既有渲染入口同一条守卫 + const token = ++renderToken; + const scale = zoomScale; + const origBox = $('pdf-pages'), zhBox = $('pdf-pages-zh'); + const range = pane.scrollHeight - pane.clientHeight; + const anchorRatio = range > 0 ? pane.scrollTop / range : 0; + origBox.innerHTML = ''; + zhBox.innerHTML = ''; + clearEqGeometry(); // 旧页几何 + 跨列残留的 hover/hit 描边一起作废 + $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%'; + const paged = pageMode === 'paged'; + origBox.classList.toggle('paged', paged); + zhBox.classList.toggle('paged', paged); + curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages); + updatePager(); + const origList = paged ? [curPage] : Array.from({ length: pdfDoc.numPages }, (_, i) => i + 1); + await renderColumn(pdfDoc, 'orig', origBox, origList, token, scale); + if (token !== renderToken) return; + if (pdfDocZh) { + const zhPage = Math.min(Math.max(1, curPage), pdfDocZh.numPages); + const zhList = paged ? [zhPage] : Array.from({ length: pdfDocZh.numPages }, (_, i) => i + 1); + await renderColumn(pdfDocZh, 'zh', zhBox, zhList, token, scale); + if (token !== renderToken) return; + } + // 锚点恢复:滚动模式按比例回到原位置,再让中文列跟一次 + if (!paged) { + const newRange = pane.scrollHeight - pane.clientHeight; + pane.scrollTop = anchorRatio * Math.max(0, newRange); + splitSyncing = false; + onSplitScroll(splitCols()[0]); + } + observePages(); // 只观察原文列(#pdf-pages .page 的 id 限定必须保持) +} + +/** 进入分屏(或 openPaper 恢复分屏):拉原文 + -zh.pdf 两个文档,两列同渲。 */ +async function openSplit() { + if (!current || !zhState.zh) return; + if (window.innerWidth < SPLIT_MIN_WIDTH) { + toast('面板太窄,请把阅读器拉宽后再用分屏对照', 4000); + if (viewMode === 'split') await setViewMode('orig'); + return; + } + // pdf.js 必须先就绪(与 loadPdf 同一条通道):openPaper 恢复「分屏」时页脚的经典脚本还在解析期, + // 动态 import 尚未完成 —— 直接触 pdfjsLib 会 ReferenceError,表现为 + // 「打开中文版失败:pdfjsLib is not defined,已退回原文」+ 持久化写空(t3 报告 F3,6 次跑 5 次失败)。 + await ensurePdfjs(); + $('split-wrap').classList.add('on'); + $('pdf-pane-zh').style.display = ''; + splitWarned = false; + $('empty').style.display = 'none'; + $('pager').style.display = ''; + $('mode-toggle').style.display = ''; + if (pdfDocZh) { try { await pdfDocZh.destroy?.(); } catch { /* 旧文档已释放 */ } pdfDocZh = null; } + try { + pdfDocZh = await pdfjsLib.getDocument({ url: pdfUrl('zh') }).promise; + } catch (e) { + // -zh.pdf 拿不到(被删/损坏):回落原文 + 如实提示,不静默失败 + await setViewMode('orig'); + toast('打开中文版失败:' + (e && e.message ? e.message : e) + ',已退回原文', 6000); + return; + } + await loadPdf(pdfUrl(null), { deferRender: true }); // 原文列(pdfDoc 恒指原文),本函数自己安排渲染 + // 分屏适宽:按较窄的那一列算一次(等价于 fitToWidth,但不让它触发一次多余的单列渲染) + const w = fitPaneWidth(); + if (w > 0) { + const p1 = await pdfDoc.getPage(1); + zoomScale = Math.min(4.5, Math.max(0.6, w / p1.getViewport({ scale: 1 }).width)); + } + await renderSplit(); + attachSplitScroll(); +} + +/** 退出分屏:拆掉中文列、释放中文文档、同步监听。 */ +function teardownSplit() { + $('split-wrap').classList.remove('on'); + $('pdf-pane-zh').style.display = 'none'; + $('pdf-pages-zh').innerHTML = ''; + $('pdf-pages-zh').classList.remove('paged'); + splitCols().forEach((c) => { c.pane.onscroll = null; }); + splitSyncing = false; + crafFn(splitRaf); + if (pdfDocZh) { try { pdfDocZh.destroy?.(); } catch { /* 已释放 */ } } + pdfDocZh = null; +} + +/** 两列的滚动监听(各自一个,**不**共用一个滚动容器——R7 明令)。 */ +function attachSplitScroll() { + splitCols().forEach((col) => { col.pane.onscroll = () => onSplitScroll(col); }); +} + /* ================= 启动:URL 参数 > localStorage > 第一篇 ================= */ async function bootstrap() { const params = new URLSearchParams(location.search); diff --git a/src/formulas.ts b/src/formulas.ts index 6f39066..38cb9ed 100644 --- a/src/formulas.ts +++ b/src/formulas.ts @@ -55,6 +55,73 @@ export interface FormulaIndex { warning?: string } +/** + * `text` 块索引:**行内公式**(`$…$`)嵌在 `type:"text"` 块的 `text` 字段里,没有自己的 + * bbox/equation 块(docs/selection-translate-requirements.md §2.1 实测:Dong 28/154、 + * Hansen 22/112 个 text 块含 `$`)。划词翻译的服务端行内还原(§3.4)必须能拿到这些块。 + * + * 只收「含 `$` 的 text 块」:全量 text 块会把 138KB 级的产物整篇搬进内存(§7 R15), + * 而还原只需要候选块。`text` 一律过 `compactLatex`(§2.5 实测:整块投影安全, + * `$F _ { \theta }$` → `$F_\theta$`,且片段外正文逐字节不变)。 + */ +export interface TextBlockEntry { + /** 页码(1 起,= page_idx + 1) */ + page: number + pageIdx: number + /** 页内序号(1 起,阅读序) */ + index: number + /** [x0,y0,x1,y1],归一化坐标(null = 该块没有可用 bbox) */ + bbox: [number, number, number, number] | null + /** 紧凑投影后的块文本(`$…$` 片段已压紧) */ + text: string +} + +export interface TextBlockIndex { + source: 'mineru' | 'none' + blocks: TextBlockEntry[] + reason?: FormulaReason +} + +/** 从 `.mineru.json` 的原始文本建 text 块索引(只收含 `$` 的块)。 */ +export function buildTextBlockIndex(raw: string | null): TextBlockIndex { + if (raw === null) return { source: 'none', blocks: [], reason: 'no-mineru-artifact' } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return { source: 'none', blocks: [], reason: 'bad-artifact' } + } + if (!isRecord(parsed)) return { source: 'none', blocks: [], reason: 'bad-artifact' } + const list = parsed['contentList'] + if (!Array.isArray(list)) return { source: 'none', blocks: [], reason: 'bad-artifact' } + + // page_idx 缺失的块沿用上一个有页码的块(与 buildFormulaIndex / transcribe 的投影口径一致) + const blocks: TextBlockEntry[] = [] + const perPage = new Map() + let lastPageIdx = 0 + for (const b of list) { + if (!isRecord(b)) continue + if (b['type'] !== 'text') continue + const pi = b['page_idx'] + if (typeof pi === 'number' && Number.isFinite(pi) && pi >= 0) lastPageIdx = Math.trunc(pi) + const text = b['text'] + if (typeof text !== 'string' || !text.includes('$')) continue + const index = (perPage.get(lastPageIdx) ?? 0) + 1 + perPage.set(lastPageIdx, index) + blocks.push({ + page: lastPageIdx + 1, + pageIdx: lastPageIdx, + index, + bbox: toBbox(b['bbox']), + text: compactLatex(text), + }) + } + // 注意:blocks 为空**不是**错误——产物可能根本没有含 `$` 的 text 块(行内还原直接 + // 走 unavailable)。reason 只在「读不到 contentList」时给,语义与 FormulaReason 一致。 + return { source: 'mineru', blocks } +} + /** 供测试与调用方复用的紧凑化:既有投影两步 + 算子名统一(见下)。 */ export function compactLatex(rawLatex: string): string { return unifyOperatorNames(normalizeMathBraces(compressMathSpaces(rawLatex))) @@ -253,32 +320,62 @@ export function buildFormulaIndex(raw: string | null, opts: { producer?: unknown // ── 读盘 + 记忆化(.mineru.json 实测 146KB/31 页,不能让每次渲染都读一遍)───────── interface CacheEntry { key: string; value: FormulaIndex } let cache: CacheEntry | null = null +// 同一个 `.mineru.json` 现在有两个索引(equation 的 FormulaIndex 与行内公式用的 +// TextBlockIndex)。原始文本单读一层缓存:两个索引共用,避免 138KB 文件读两遍/解析两遍 +// 的放大(§7 R15)。key 同样是 `mtimeMs:size`——改名/重转录后自然失效。 +interface RawCacheEntry { key: string; raw: string | null } +let rawCache: RawCacheEntry | null = null +let textCache: { key: string; value: TextBlockIndex } | null = null -/** 读 `` 并建索引。文件缺失/损坏一律返回可用的空索引,**不抛异常**。 */ -export async function readFormulaIndex(ref: PaperRef): Promise { +/** + * 读 `.mineru.json` 原始文本(缺失 → null)。**不抛异常**:权限等异常也降级成 null, + * 由调用方决定 reason(读取失败按 bad-artifact 记)。 + */ +async function readMineruRaw(ref: PaperRef): Promise<{ key: string; raw: string | null; failed?: string }> { let key = `${ref.mineruJsonPath}:absent` try { const st = await stat(ref.mineruJsonPath) key = `${ref.mineruJsonPath}:${st.mtimeMs}:${st.size}` - if (cache && cache.key === key) return cache.value + if (rawCache && rawCache.key === key) return { key, raw: rawCache.raw } const raw = await readFile(ref.mineruJsonPath, 'utf8') - const value = buildFormulaIndex(raw) - cache = { key, value } - return value + rawCache = { key, raw } + return { key, raw } } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'ENOENT' || code === 'EISDIR') { - const value = buildFormulaIndex(null) - if (cache?.key === key) return cache.value - cache = { key, value } - return value + if (!(rawCache && rawCache.key === key)) rawCache = { key, raw: null } + return { key, raw: null } } - // 权限等异常也不许把阅读器打崩:降级成 bad-artifact - return EMPTY('bad-artifact', `读取 mineru.json 失败:${code ?? 'unknown'}`) + return { key, raw: null, failed: code ?? 'unknown' } } } +/** 读 `` 并建索引。文件缺失/损坏一律返回可用的空索引,**不抛异常**。 */ +export async function readFormulaIndex(ref: PaperRef): Promise { + const { key, raw, failed } = await readMineruRaw(ref) + if (failed !== undefined) return EMPTY('bad-artifact', `读取 mineru.json 失败:${failed}`) + if (cache && cache.key === key) return cache.value + const value = buildFormulaIndex(raw) + cache = { key, value } + return value +} + +/** + * 读同一份产物并建 **text 块索引**(行内公式还原用,服务端内部;不经 `/api/formulas` + * 下发——§7 R14/R15 明确要求响应面不变)。与 `readFormulaIndex` 共用原始文本缓存。 + */ +export async function readTextBlockIndex(ref: PaperRef): Promise { + const { key, raw, failed } = await readMineruRaw(ref) + if (failed !== undefined) return { source: 'none', blocks: [], reason: 'bad-artifact' } + if (textCache && textCache.key === key) return textCache.value + const value = buildTextBlockIndex(raw) + textCache = { key, value } + return value +} + /** 仅供测试:清空记忆化。 */ export function resetFormulaCache(): void { cache = null + rawCache = null + textCache = null } diff --git a/src/host.ts b/src/host.ts index 2171253..4be617f 100644 --- a/src/host.ts +++ b/src/host.ts @@ -33,7 +33,18 @@ import { resolveTopicDir, validateName, } from './library-manage.ts' -import { readFormulaIndex } from './formulas.ts' +import { readFormulaIndex, readTextBlockIndex } from './formulas.ts' +import { + MAX_CHUNKS, + MAX_LATEX_CHARS, + MAX_TEXT_CHARS, + MAX_TOTAL_CHARS, + SelectionTranslateError, + assembleSource, + formulaStatusOf, + translateSelection, + type TranslateChunk, +} from './translate-selection.ts' import { MineruError, sanitizeDetail } from './mineru.ts' import { readTranscript, transcribePaper, ScannedPdfError, ShortTextError, type SourceArg } from './transcribe.ts' import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from './translate.ts' @@ -834,6 +845,93 @@ export function registerRoutes(ctx: Context, config: PluginConfig) { return } + /* ── 划词翻译(公式保留,契约 docs/selection-translate-requirements.md §3.9)───── + 客户端只送「按页 + 按 display 归属切好的 chunk」;行内公式的还原(选区文本 ↔ MinerU + text 块对齐)在服务端做,所以请求体里**没有任何矩形/bbox/pageSize**。 + 本接口只读(不写盘)⇒ 定位沿用 /api/formulas 的读路径语义,不做写路径边界。 + API key 只存在于本进程:请求体只有 model(客户端从 /api/zh 拿到的 apiKeyHint 仅用于展示), + 响应体与错误文案一律不带 key。 */ + if (sub === '/api/translate-selection' && req.method === 'POST') { + const body = (await readBody(req)) as { topic?: unknown; name?: unknown; path?: unknown; chunks?: unknown } + const bad = (msg: string, code = 'bad-request') => json(res, 400, { error: msg, code }) + + if (!Array.isArray(body.chunks)) return bad('chunks 必须是数组') + if (body.chunks.length < 1 || body.chunks.length > MAX_CHUNKS) { + return bad(`chunks 数量必须在 1~${MAX_CHUNKS} 之间(收到 ${body.chunks.length})`, 'too-large') + } + const chunks: TranslateChunk[] = [] + let total = 0 + for (const raw of body.chunks) { + if (typeof raw !== 'object' || raw === null) return bad('chunks 的每一项必须是对象') + const c = raw as { page?: unknown; kind?: unknown; text?: unknown; latex?: unknown } + const page = c.page + if (typeof page !== 'number' || !Number.isInteger(page) || page < 1) return bad('chunk.page 必须是 ≥1 的整数') + const text = typeof c.text === 'string' ? c.text : '' + const latex = typeof c.latex === 'string' ? c.latex : '' + if (c.kind === 'formula') { + if (!latex) return bad('formula chunk 的 latex 不能为空') + if (latex.length > MAX_LATEX_CHARS) return bad(`latex 超过 ${MAX_LATEX_CHARS} 字符`, 'too-large') + total += latex.length + chunks.push({ page, kind: 'formula', latex }) + continue + } + if (c.kind !== 'text') return bad("chunk.kind 必须为 'text' 或 'formula'") + if (text.length > MAX_TEXT_CHARS) return bad(`text 超过 ${MAX_TEXT_CHARS} 字符`, 'too-large') + total += text.length + chunks.push({ page, kind: 'text', text }) + } + if (total === 0) return bad('选区为空') + if (total > MAX_TOTAL_CHARS) return bad(`选区总长超过 ${MAX_TOTAL_CHARS} 字符`, 'too-large') + + let ref: PaperRef + try { + const args: { path?: string; topic?: string; name?: string } = {} + if (typeof body.path === 'string' && body.path) args.path = body.path + else { + if (typeof body.topic !== 'string' || typeof body.name !== 'string' || !body.topic || !body.name) { + return bad('必须提供 topic + name(或 path)') + } + args.topic = body.topic + args.name = body.name + } + ref = resolvePaper(dataDir, args) + } catch { + throw notFound('文献不存在(划词翻译需要先能定位到该文献)') + } + + const { endpoint, source } = await resolveTranslateEndpoint(config.translate) + if (source === 'none') { + json(res, 503, { error: '未配置翻译端点:在阅读器里点 ⚙ 填写(OpenAI 兼容端点即可)', code: 'no-endpoint' }) + return + } + const index = await readTextBlockIndex(ref) + const asm = assembleSource(chunks, index) + try { + const r = await translateSelection(endpoint, asm.source) + json(res, 200, { + translation: r.translation, + model: r.model, + cached: r.cached, + formulaTotal: r.formulaTotal, + formulaKept: r.formulaKept, + formulaLost: r.formulaLost, + formulaApplied: asm.applied, + formulaSkipped: asm.skipped, + formulaStatus: formulaStatusOf(asm), + hasBlockIndex: asm.hasBlockIndex, + ms: r.ms, + }) + } catch (err) { + if (!(err instanceof SelectionTranslateError)) throw err + json(res, err.status, { + error: err.message, + code: err.code, + ...(err.upstreamStatus !== null ? { upstreamStatus: err.upstreamStatus } : {}), + }) + } + return + } + // 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它—— // 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。 // n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。 diff --git a/src/translate-config.ts b/src/translate-config.ts index 05b07dc..eaa4edb 100644 --- a/src/translate-config.ts +++ b/src/translate-config.ts @@ -124,6 +124,9 @@ export async function testTranslateEndpoint(ep: TranslateEndpoint): Promise<{ ok // Node 的 fetch 把真实原因(ECONNREFUSED / 证书错误 / 超时)塞在 cause 里, // 只报 "fetch failed" 对用户毫无帮助 const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '' - return { ok: false, detail: (err instanceof Error ? err.message : String(err)) + cause } + // ⚠️ t7(t4 评审 R-04 的同类项):这条 detail 会进响应体、再上屏(设置浮层)。 + // fetch 抛出的原因串可能带上 baseUrl(含凭据/查询参数)或回显 Authorization —— + // 与同函数上面两条分支一样先过 sanitizeDetail;可读原因(ECONNREFUSED 等)不受影响。 + return { ok: false, detail: sanitizeDetail((err instanceof Error ? err.message : String(err)) + cause, apiKey) } } } diff --git a/src/translate-selection.ts b/src/translate-selection.ts new file mode 100644 index 0000000..ed70a0a --- /dev/null +++ b/src/translate-selection.ts @@ -0,0 +1,852 @@ +// translate-selection.ts — 划词翻译(公式保留)的服务端核心。 +// +// 契约:docs/selection-translate-requirements.md §3.3–§3.8(冻结 v1)。本模块只做**服务端** +// 的部分,且全部纯函数化(`fetchImpl` 可注入 ⇒ 单测不需要网络、不产生费用): +// ① `alignSelectionToBlock`:行内公式还原(文本层退化文本 ↔ MinerU text 块文本对齐,§3.4) +// ② `assembleSource`:把客户端的 chunk 序列组装成「公式已是单 $ 定界紧凑 LaTeX」的源文本 +// ③ `buildRequestBody`:写死的 system 提示词 + §3.6 的请求体 +// ④ `translateSelection`:调用端点(含唯一的 temperature 重试)+ 响应清洗 + 内存 LRU +// ⑤ `verifyFormulas`:把「译文原样保留公式」从口头要求变成可判定断言(§3.6-4) +// +// ⚠️ 为什么行内还原在服务端(§3.4 / C8):客户端拿不到块文本;`compactLatex` 是 TS 侧函数, +// 阅读器是单文件内联脚本、没有打包器,复制一份必然与 src/formulas.ts 漂移;而放在这里 +// 可以用 `node --test` 直接构造成本极低的攻击用例。 +// +// ⚠️ 日志红线(§3.7③):本模块**只**允许打印 `{model, status, ms, chars}` —— +// 绝不把端点对象或配置对象送进日志,API key 不进日志、不进响应、不进错误文案。 + +import { createHash } from 'node:crypto' +import { compactLatex, type TextBlockIndex } from './formulas.ts' +import { sanitizeDetail } from './mineru.ts' +import type { TranslateEndpoint } from './translate.ts' + +/* ── 冻结参数表(§3.4;调参必须回改需求文档)────────────────────────────── */ +/** 最短可对齐归一化长度 */ +export const MIN_NORM = 4 +/** 种子长度上下限 */ +export const SEED_MAX = 12 +export const SEED_MIN = 4 +/** 跳过预算系数:`max(4, ceil(0.4*|S|))` */ +export const SKIP_RATIO = 0.4 +/** 接受阈值 */ +export const MIN_SCORE = 0.75 +/** 跨度长度比 `[0.6, 1.35]` */ +export const RATIO_LO = 0.6 +export const RATIO_HI = 1.35 +/** 同一 seed 的候选出现数 */ +export const SEED_TRIES = 3 + +/* ── 规模上限(§3.9)────────────────────────────────────────────────────── */ +export const MAX_CHUNKS = 200 +export const MAX_TEXT_CHARS = 4000 +export const MAX_LATEX_CHARS = 2000 +export const MAX_TOTAL_CHARS = 8000 +/** 服务端上游超时(§3.6) */ +export const UPSTREAM_TIMEOUT_MS = 30_000 +/** LRU 上限(§3.7④) */ +export const CACHE_LIMIT = 200 + +/* ── 提示词契约(§3.6,逐字冻结;改动必须回改需求文档并重跑 A6/A7)────────── */ +export const SELECTION_SYSTEM_PROMPT = `You are a professional, authentic machine translation engine for academic papers. +Translate the user's input into Simplified Chinese. + +Hard rules: +1. Any content wrapped in $...$ is a LaTeX formula. Keep every formula EXACTLY as + given: do not translate it, do not rewrite or simplify it, do not change spaces, + braces, or backslashes, do not drop it, do not add any. +2. Output the translation ONLY. No preamble, no explanation, no notes, no headings, + no Markdown code fences, no quotes around the whole output. +3. Preserve the paragraph and line structure of the input. Never add content that is + not in the input. +4. Translate terminology by field convention (policy -> 策略, world model -> 世界模型). + Keep model names, dataset names, method names and abbreviations (LCVN-WM, DiT, CLIP) + in the original form. +5. If the input is already Chinese, return it as is.` + +/* ── 错误类型(错误码表 §3.9)───────────────────────────────────────────── */ +export type SelectionErrorCode = 'no-endpoint' | 'bad-request' | 'too-large' | 'upstream' | 'network' | 'timeout' + +export class SelectionTranslateError extends Error { + constructor( + readonly code: SelectionErrorCode, + readonly status: number, + message: string, + /** 上游 HTTP 状态码(仅 code==='upstream' 时有意义):客户端据此出 §3.7② 的分档文案 */ + readonly upstreamStatus: number | null = null, + ) { + super(message) + this.name = 'SelectionTranslateError' + } +} + +/* ── 文本清洗(§3.3②)──────────────────────────────────────────────────── */ + +/** 文本层乱码字符类(§3.3② 的冻结清单):C0/C1(\t 与 \n 是空白,另行折叠)、私用区、 + * U+FFFD、走音符号类。**只用于 `$…$` 之外的文本层内容**——见下面的 LaTeX 例外。 */ +function isTextJunk(cp: number): boolean { + if (cp <= 0x1f && cp !== 0x09 && cp !== 0x0a && cp !== 0x0b && cp !== 0x0c && cp !== 0x0d) return true + if (cp >= 0x7f && cp <= 0x9f) return true + if (cp >= 0xe000 && cp <= 0xf8ff) return true + if (cp === 0xfffd) return true + if (cp === 0x02c6 || cp === 0x02dc || cp === 0xb4 || cp === 0x005e || cp === 0x0060) return true + return cp >= 0x300 && cp <= 0x36f +} + +const isLatexJunk = (cp: number): boolean => + (cp <= 0x1f && cp !== 0x0a) || (cp >= 0x7f && cp <= 0x9f) || (cp >= 0xe000 && cp <= 0xf8ff) || cp === 0xfffd + +function dropJunk(s: string, junk: (cp: number) => boolean): string { + let out = '' + for (const ch of s) { + if (junk(ch.codePointAt(0) ?? 0)) continue + out += ch + } + return out +} + +/** 空白折叠:连续空白(\t/\r/空格…)折叠成一个空格,**换行是结构**只清它两侧的空格。 */ +function collapse(s: string): string { + return s.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{2,}/g, '\n').trim() +} + +/** + * 收尾清洗:删除 C0/C1 控制字符、私用区(E000-F8FF)、U+FFFD、走音符号类 + * (`\u02c6 \u02dc \u00b4 \u005e \u0060 \u0300-\u036f`),并把连续空白折叠成一个空格。 + * + * **两条实现约束(都是实测/契约必需的,见实现报告)**: + * ① `\n` 是结构:§3.5 ④「页之间插一个 `\n`」+ §3.6 system 第 3 条「保持行结构」, + * 所以只折叠它两侧的空格与连续空行。 + * ② 本函数**只能作用在 `$…$` 之外**:清单里的 `\u005e`(`^`)在 LaTeX 里是上标运算符, + * 整段组装文本过一遍会把 `v^\top` 变成 `v\top`、`x^2` 变成 `x2`(实测)——那既毁公式, + * 又会让「公式原样保留」的校验失去意义。`assembleSource` 因此对每个 piece 分类清洗: + * `$…$` 内只删控制字符/私用区(`isLatexJunk`),外面才用本函数。 + */ +export function stripJunk(s: string): string { + return collapse(dropJunk(s, isTextJunk)) +} + +/** 一个组装 piece 的清洗:`$…$` 内是 MinerU 的 LaTeX(逐字保留),外面是文本层内容(清乱码)。 */ +function cleanPiece(s: string): string { + return s.split(/(\$[^$]*\$)/) + .map((part) => (part.startsWith('$') && part.endsWith('$') && part.length >= 2 + ? '$' + dropJunk(part.slice(1, -1), isLatexJunk) + '$' + : dropJunk(part, isTextJunk))) + .join('') +} + +/** + * 中和文本层原文里的**字面 `$`**(R-03):换成全角 `$`(U+FF04)。 + * 组装文本的 `$…$` 因此**只**可能是真公式(formula chunk 或块跨度里的 LaTeX 片段), + * `verifyFormulas` 的逐 token 校验才是可判定的;否则 `$5` 这种字面符会与别处的 `$` 配成 + * 假片段,把真公式挤掉(A7 空转)。模型看到的仍是同一个货币符号的常见全角写法。 + * 用于**未对齐分支**(那一段是文本层原文,里面不可能有 MinerU 的 LaTeX)。 + */ +function neutralizeDollars(s: string): string { + return s.includes('$') ? s.replace(/\$/g, '\uff04') : s +} + +/** + * 一个 `$…$` 片段**看起来是 LaTeX**吗(N-02 的判据)。 + * 真公式来自 MinerU 的 LaTeX 投影:含命令/上下标/花括号(`\ _ ^ { } &`),或者整体**不含空白** + * (紧凑投影后的 `$x_t$`/`$F$` 这类纯符号公式)。正文里两个**字面** `$` 之间通常是自然语言 + * (`$5 up to $`)——含空白且没有 LaTeX 字符 ⇒ 判为非公式。 + * + * ⚠️ 已知边界:紧凑的成对字面 `$`(如正文写成 `$USD$`、`$5$`)与 `$F$` 这类纯符号公式形态相同, + * 无法区分,会按公式计入 token。本机两份语料(154+112 个 text 块、两条真实文本层)中不存在 + * 这类形态;遇到时行为与旧版一致(计入并校验),不会更糟。 + */ +export function isLatexLikeFragment(fragment: string): boolean { + const inner = fragment.startsWith('$') && fragment.endsWith('$') ? fragment.slice(1, -1) : fragment + if (/[\\_^{}&]/.test(inner)) return true + return !/\s/.test(inner) +} + +/** + * 中和**块跨度**里的成对字面 `$`(N-02):保留看起来是 LaTeX 的 `$…$` 片段(那是真公式), + * 其余 `$` 一律换成全角 `$`。这样组装文本里的每个 `$…$` 都可判定为真公式, + * `formulaTokens`/`verifyFormulas` 不会再把 `$5 up to $` 当成公式(假警告)或塞进提示词。 + */ +function neutralizeNonLatexPairs(s: string): string { + if (!s.includes('$')) return s + return s.split(/(\$[^$]*\$)/) + .map((part) => { + if (part.startsWith('$') && part.endsWith('$') && part.length >= 2 && isLatexLikeFragment(part)) return part + return part.replace(/\$/g, '\uff04') + }) + .join('') +} + +/** 对齐用的归一化:小写 + 只留 `[a-z0-9]` 与 CJK,其余(空白/标点/数学符号/控制字符)丢弃。 */ +export function normForAlign(raw: string): { A: string; idx: number[] } { + const chars: string[] = [] + const idx: number[] = [] + for (let i = 0; i < raw.length; i++) { + const c = raw[i]!.toLowerCase() + if (/[a-z0-9\u4e00-\u9fff]/.test(c)) { + chars.push(c) + idx.push(i) + } + } + return { A: chars.join(''), idx } +} + +/* ── 行内对齐(§3.4,原型 .probe/align-sim4.mjs 的冻结版)────────────────── + ⚠️ t6 修复(t3 报告 F1/F2,见 docs/selection-translate-verification.md §6.1/§6.2): + 冻结版有两个缺陷,都是「静默送错文本」: + F1 跨度端点丢词:公式的 LaTeX 比文本层退化形态长得多(`\mathbf`/`\left`/`\right`/命令名), + 双向行走的 skip 预算被这些「LaTeX 噪声」吃掉,于是在**选区侧**跳过公式前的正文词, + `bMin` 落在 `$` 上 → 组装文本少一个词(实测 `where …` 的 `where` 丢失)。 + F2 张冠李戴:`score ≥ 0.75` 对短选区没有判别力(逐字母匹配 5/8 就 0.83),于是「本段不在 + 候选集里」时会被**别的段落**接走(`, while a learner encoder` → `expert and learner encoders`)。 + 修复(三条,均为「绝对门槛」,宁可拒绝还原并如实提示,也不许静默送错文本): + ① **块侧跳过只允许发生在 `$…$` 片段内且不消耗预算**(LaTeX 命令/括号本来就无法被文本层 + 表示 ⇒ 它们是结构性差异,不是「多抓了内容」)。正文里的块侧跳字 = 张冠李戴 ⇒ 该候选作废。 + 这同时让行走能回到选区两端(F1 的根因)。 + ② **端点回填**:行走仍够不到时(选区侧未配对的前缀/后缀),把它按同样的规则映射回块侧, + 因此「选区里有、块里也有」的词一定进跨度(F1 §12.1 的处方)。 + ③ **覆盖门槛**:跨度必须解释选区的**每一个字母**(未配对的字母只能是数字/符号,如公式编号 + `(4)` 的 `4`),且跨度内不得出现「块侧多出来的散文内容」(prose slack,如选区是 + `Instruction:` 而块里是 `instructions`)⇒ 否则拒绝(返回原样 + 走 §3.8 的诚实文案)。 + 另:跨度长度比 `ratio` 改用**内容口径**(`$…$` 内的 `\命令名` 不算内容)——旧口径把 + `\mathbf`s_t=\left[…\right]` 的 15 个命令名字符算成选区内容,回填 `where` 后 ratio 反而 + 从 1.30 涨到 1.46 被误拒。阈值 `[0.6,1.35]`、`score≥0.75`、种子/预算参数**全部未动**。 */ + +const WORD_CH = /[A-Za-z0-9]/ + +/** `$…$` 片段范围(raw 下标)。 */ +function fragmentRanges(raw: string): Array<[number, number]> { + const out: Array<[number, number]> = [] + const re = /\$[^$]*\$/g + let m: RegExpExecArray | null + while ((m = re.exec(raw)) !== null) out.push([m.index, m.index + m[0].length]) + return out +} + +/** 归一化下标 → 是否落在 `$…$` 片段内部(`$` 定界符本身不算内部)。 */ +function fragmentFlags(raw: string, Bidx: number[]): boolean[] { + const ranges = fragmentRanges(raw) + return Bidx.map((ri) => ranges.some(([f0, f1]) => ri > f0 && ri < f1)) +} + +/** + * 「内容」口径的归一化:`$…$` 内先删掉 LaTeX 命令名(`\mathbf`/`\left`/`\theta`…)与花括号/定界符, + * 只保留真正会被文本层呈现出来的字母数字。用于跨度长度比 `ratio`——文本层永远表示不出命令名, + * 把它们算进跨度长度等于用「LaTeX 写得多长」去衡量「有没有多抓一行」。 + */ +export function contentNormForRatio(raw: string): string { + const ranges = fragmentRanges(raw) + let out = '' + for (let i = 0; i < raw.length; i++) { + const inFrag = ranges.some(([f0, f1]) => i > f0 && i < f1) + if (inFrag && raw[i] === '\\') { + i++ + while (i + 1 < raw.length && /[A-Za-z]/.test(raw[i + 1]!)) i++ + continue + } + out += raw[i] + } + return normForAlign(out).A +} + +interface SeedHit { score: number; pairs: Array<[number, number]> } + +/** 种子 + 双向行走;块侧跳过必须整段落在 `$…$` 片段内(且免费),选区侧跳过消耗预算。 */ +function alignSeeds(S: string, B: string, inFrag: boolean[], skipRatio = SKIP_RATIO): SeedHit[] { + const k = Math.min(SEED_MAX, Math.max(SEED_MIN, Math.floor(S.length / 4))) + const seeds = [...new Set([0, Math.max(0, Math.floor((S.length - k) / 2)), Math.max(0, S.length - k)])] + const budget0 = Math.max(4, Math.ceil(skipRatio * S.length)) + const walk = (Sx: string, Bx: string, frag: boolean[], ss: number, at: number): Array<[number, number]> => { + const pairs: Array<[number, number]> = [] + let i = ss + let j = at + let budget = budget0 + while (i < Sx.length && j < Bx.length) { + if (Sx[i] === Bx[j]) { + pairs.push([i, j]) + i++ + j++ + continue + } + const nS = Bx.indexOf(Sx[i]!, j) // 块侧:跳到该字符的下一次出现 + let freeB = false + if (nS >= 0) { + freeB = true + for (let t = j; t < nS; t++) { + if (!frag[t]) { freeB = false; break } // 跳过范围里含正文 → 张冠李戴,候选作废 + } + } + const nB = Sx.indexOf(Bx[j]!, i) // 选区侧多出的字符(文本层公式字形)→ 跳选区侧 + const costS = nB < 0 ? Infinity : nB - i + if (freeB) { j = nS; continue } // 片段内跳过:结构性差异,免费 + if (costS === Infinity || costS > budget) break + budget -= costS + i += costS + } + return pairs + } + const rev = (s: string) => [...s].reverse().join('') + const Sr = rev(S) + const Br = rev(B) + const fragR = [...inFrag].reverse() + const out: SeedHit[] = [] + for (const ss of seeds) { + const seed = S.slice(ss, ss + k) + if (seed.length < 4) continue + let at = B.indexOf(seed) + let tries = 0 + while (at >= 0 && tries < SEED_TRIES) { + const R = walk(S, B, inFrag, ss, at) + const L = walk(Sr, Br, fragR, S.length - 1 - ss, B.length - 1 - at).map( + ([a, b]) => [S.length - 1 - a, B.length - 1 - b] as [number, number], + ) + const pairs = R.concat(L) + .sort((a, b) => a[0] - b[0]) + .filter((p, i, arr) => i === 0 || p[0] !== arr[i - 1]![0]) + out.push({ score: pairs.length / S.length, pairs }) + at = B.indexOf(seed, at + 1) + tries++ + } + } + return out +} + +/** ① 词边界外扩:端点不再是词字符为止(防 v1 实测的 `…solving the follo` 截断)。 */ +function expandWords(raw: string, a: number, b: number): [number, number] { + while (a > 0 && WORD_CH.test(raw[a - 1]!) && WORD_CH.test(raw[a] ?? '')) a-- + while (b < raw.length && WORD_CH.test(raw[b] ?? '') && WORD_CH.test(raw[b - 1] ?? '')) b++ + return [a, b] +} + +/** ② 片段边界外扩:端点落在 `$…$` 内部时扩到片段边界(防切出半个公式)。 */ +function expandFragments(raw: string, a: number, b: number): [number, number] { + for (const [f0, f1] of fragmentRanges(raw)) { + if (a > f0 && a < f1) a = f0 + if (b > f0 && b < f1) b = f1 + } + return [a, b] +} + +/** + * 端点回填(F1 的处方,§12.1):选区侧**未配对的前缀/后缀**映射回块侧。 + * 规则与行走一致:块侧跳过必须整段在 `$…$` 片段内,且总跳过量受同一预算约束; + * 只要有一字符映射不到,就整体放弃回填(绝不让端点落在猜出来的位置上)。 + * + * ⚠️ **防御性代码:当前没有任何用例能区分它**(t4 评审 R-08 的独立变异 M16:把本函数关掉, + * 全量套件 0 红、真实 Dong 语料 0 差异;t7 又专门构造了「前缀 + N 个重复字形 + 公式」 + * (N=5/20/40/80)与「公式在前、正文词在后 + 重复字形」等形态,仍然构造不出差异)。 + * 原因是可论证的:规则 ① 让块侧跳过「只在 `$…$` 内且免费」之后,反向行走用**独立的** + * 预算从锚点一路回到选区开头;凡是回填能映射的前缀(在块里紧邻、只需片段内跳过), + * 反向行走同样能配对;而反向行走配不上的前缀,其间的块侧字符必含正文 —— 回填的 + * 片段内检查也会同样失败。**F1 的真正修复是规则 ① + 覆盖门槛**(两者都有可区分的用例)。 + * 保留本函数是纵深防御:万一将来放宽规则 ①,端点回填仍在(且不改变任何已接受的结果)。 + */ +function backfillEnds( + S: string, B: string, inFrag: boolean[], p: number, q: number, + bMin: number, bMax: number, budget: number, +): { lo: number | null; hi: number | null; covered: number } { + const out = { lo: null as number | null, hi: null as number | null, covered: 0 } + if (p > 0 && bMin > 0) { + let j = bMin - 1 + let rest = budget + let earliest = -1 + let ok = true + for (let i = p - 1; i >= 0; i--) { + const at = B.lastIndexOf(S[i]!, j) + if (at < 0 || j - at > rest) { ok = false; break } + for (let t = at + 1; t <= j; t++) { if (!inFrag[t]) { ok = false; break } } + if (!ok) break + rest -= j - at + j = at - 1 + earliest = at + } + if (ok) { out.lo = earliest; out.covered += p } + } + if (q < S.length - 1 && bMax + 1 < B.length) { + let j = bMax + 1 + let rest = budget + let latest = -1 + let ok = true + for (let i = q + 1; i < S.length; i++) { + const at = B.indexOf(S[i]!, j) + if (at < 0 || at - j > rest) { ok = false; break } + for (let t = j; t < at; t++) { if (!inFrag[t]) { ok = false; break } } + if (!ok) break + rest -= at - j + j = at + 1 + latest = at + } + if (ok) { out.hi = latest; out.covered += S.length - 1 - q } + } + return out +} + +/** 选区原文最后一段词(小写;忽略行尾标点/连字符)。用于端点补词门槛。 */ +function trailingWordOf(chunkText: string): string { + const t = chunkText.replace(/[^A-Za-z0-9]+$/, '') + const m = /[A-Za-z0-9]+$/.exec(t) + return m ? m[0].toLowerCase() : '' +} + +export interface AlignCandidate { + text: string + score: number + ratio: number + ok: boolean + /** 选区侧始终未被解释的字母(数字/符号允许,如公式编号)→ 非空即拒绝 */ + unpairedLetters: string + /** + * 端点「补词」越界(非空即拒绝):跨度末尾的词比选区末尾的词**更长**(expandWords 补全了词尾), + * 而选区原文并没有换行连字符 —— 例如选区 `Instruction:`(图注)被块里的 `instructions` 接走 + * (t3 报告 §6.2 的同类现象)。带连字符的换行断词(`… with com-` ↔ `compact`)**必须放行**, + * 那是文本层的正常断行,t3 报告 §2.5 明确把它记为假阳性而不是缺陷。 + */ + completedTail: string +} + +/** 单个(块, 种子)候选:求跨度 → 端点回填 → 三次修正 → `$` 校验 → 绝对门槛 + 长度保险。 */ +function candidateFor(S: string, raw: string, B: string, Bidx: number[], inFrag: boolean[], hit: SeedHit, chunkText: string): AlignCandidate | null { + if (!hit.pairs.length) return null + let bMin = Infinity + let bMax = -1 + for (const [, b] of hit.pairs) { + if (b < bMin) bMin = b + if (b > bMax) bMax = b + } + const paired = new Set(hit.pairs.map((p) => p[0])) + let p = 0 + while (p < S.length && !paired.has(p)) p++ + let q = S.length - 1 + while (q >= 0 && !paired.has(q)) q-- + const bf = backfillEnds(S, B, inFrag, p, q, bMin, bMax, Math.max(4, Math.ceil(SKIP_RATIO * S.length))) + const loIdx = bf.lo ?? bMin + const hiIdx = bf.hi ?? bMax + let [a, b] = [Bidx[loIdx]!, Bidx[hiIdx]! + 1] + ;[a, b] = expandWords(raw, a, b) + ;[a, b] = expandFragments(raw, a, b) + const text = raw.slice(a, b) + const dollars = (text.match(/\$/g) ?? []).length + // ⚠️ **防御性代码:当前没有用例能区分它**(t4 评审 R-08 的变异 M19:关掉本行,全量套件 0 红、 + // 真实 Dong 语料 0 差异;t7 又用「块里含字面 `$`(`the cost is $5 and the value $x_t$`,`$` 计数为奇数)」 + // 试图触发,拒绝都发生在别的闸门(completedTail/no-match)之后)。 + // 按构造即可论证:块内 `$` 计数恒偶(t1 实测),端点在片段内时 expandFragments 会把端点贴到 + // 片段边界 ⇒ 跨度内含的片段必成对。保留它是纵深防御(`$` 是原子的,半公式比不还原更糟)。 + if (dollars % 2 !== 0) return null + + // ③ 覆盖门槛(绝对):跨度必须解释选区的**每一个字母**。 + // 数字/符号允许留在外面(公式编号 `(4)` 会以裸数字出现在选区里),字母不行: + // 选区里有字母而块里没有 ⇒ 本段不在候选块里(t3 报告 F2 的 `, while a learner encoder`)。 + // 块侧不可能出现「跨度内部被吞掉的散文」:行走的块侧跳过只允许发生在 `$…$` 片段内(规则 ①), + // 所以内部多出来的块侧字符必然是 LaTeX 命令/括号,不是别人的句子。 + let unpairedLetters = '' + for (let i = 0; i < S.length; i++) { + if (paired.has(i)) continue + if (p > 0 && i < p && bf.lo !== null) continue + if (q < S.length - 1 && i > q && bf.hi !== null) continue + if (/[a-z]/.test(S[i]!)) unpairedLetters += S[i] + } + // ④ 端点补词门槛:跨度末尾的词比**选区原文**末尾的词更长(expandWords 补了词尾)时, + // 选区原文必须带换行连字符 —— 否则就是「被别处的词接走」(`Instruction:` → `instructions`)。 + let completedTail = '' + const selWord = trailingWordOf(chunkText) + if (selWord && b > 0 && WORD_CH.test(raw[b - 1]!)) { + let spanWord = '' + for (let k = b - 1; k >= 0 && WORD_CH.test(raw[k]!); k--) spanWord = raw[k]! + spanWord + if (spanWord.toLowerCase() !== selWord && !/[-\u2010\u2011\u2012\u2013\u2014\u2212]$/.test(chunkText.trimEnd())) { + completedTail = spanWord + } + } + + const ratio = contentNormForRatio(text).length / S.length + const ok = ratio >= RATIO_LO && ratio <= RATIO_HI && unpairedLetters === '' && completedTail === '' + return { text, score: hit.score, ratio, ok, unpairedLetters, completedTail } +} + +/** 候选择优规则(原型同款):先 `ok`,再 `score`。 */ +function better(a: AlignCandidate | null, b: AlignCandidate | null): AlignCandidate | null { + if (!a) return b + if (!b) return a + if (b.ok !== a.ok) return b.ok ? b : a + return b.score > a.score ? b : a +} + +export interface AlignResult { + /** 还原后的文本;对齐失败时**原样返回** chunkText(§3.4-9) */ + text: string + applied: 0 | 1 + score: number | null + ratio?: number + /** `|nf| < 4`:不对齐(§3.4-2) */ + skipped: boolean + /** 拒绝原因(排障/测试用):`short`=过短 / `no-match`=没有候选 / `gate`=绝对门槛没过 */ + reason?: 'short' | 'no-match' | 'gate' + /** 诊断(测试与排障用):选区侧未被解释的字母 / 端点补词越界 */ + unpairedLetters?: string + completedTail?: string +} + +/** + * 一行选区文本 ↔ 一个 MinerU text 块 → 还原后的文本。 + * 纯函数;块文本在内部过一遍 `compactLatex`(幂等:块索引里已经是紧凑形态)。 + * + * 交付侧语义(t6 修复后):`applied=1` ⟺ 跨度覆盖了选区**每一个字母**、没有块侧多出来的散文、 + * 长度比在 `[0.6,1.35]`、`score ≥ 0.75`。任一条不满足 ⇒ `applied=0` 且**原样返回** chunkText, + * 由 §3.8 的文案表如实提示(绝不静默换掉用户的句子)。 + */ +export function alignSelectionToBlock(chunkText: string, blockText: string): AlignResult { + const { A: S } = normForAlign(chunkText) + if (S.length < MIN_NORM) return { text: chunkText, applied: 0, score: null, skipped: true, reason: 'short' } + const raw = compactLatex(blockText) + const { A: B, idx: Bidx } = normForAlign(raw) + if (!B.length) return { text: chunkText, applied: 0, score: null, skipped: false, reason: 'no-match' } + const inFrag = fragmentFlags(raw, Bidx) + let best: AlignCandidate | null = null + for (const hit of alignSeeds(S, B, inFrag)) { + best = better(best, candidateFor(S, raw, B, Bidx, inFrag, hit, chunkText)) + } + if (!best || !best.ok || best.score < MIN_SCORE) { + return { + text: chunkText, applied: 0, skipped: false, reason: best ? 'gate' : 'no-match', + score: best ? best.score : null, + ...(best ? { ratio: best.ratio, unpairedLetters: best.unpairedLetters, completedTail: best.completedTail } : {}), + } + } + return { text: best.text, applied: 1, score: best.score, ratio: best.ratio, skipped: false, unpairedLetters: '', completedTail: '' } +} + +/* ── 组装(§3.2 客户端切块 → §3.3/§3.4 服务端组装)─────────────────────── */ + +export interface TranslateChunk { + page: number + kind: 'text' | 'formula' + text?: string + latex?: string +} + +/** + * 从一个**已中和**的 piece 里按出现顺序抽出 `$…$` 片段 = 真公式(N-02)。 + * 这里刻意不再做一次「看起来像 LaTeX」的过滤:唯一那道闸门是 `neutralizeNonLatexPairs` + * (它已把非 LaTeX 的成对 `$` 换成全角),再加一层过滤会变成**没有变异能区分的冗余代码** + * (t9 的 M-N02b/M-N02c 实测:删掉过滤,全部用例仍绿)。单一闸门 = 单一事实源。 + */ +function latexFragments(s: string): string[] { + return [...s.matchAll(/\$[^$]*\$/g)].map((m) => m[0]) +} + +export interface AssembleResult { + /** 发给模型的源文本:公式已是单 `$` 定界的紧凑 LaTeX,乱码字符已被清洗 */ + source: string + /** text chunk 数(用于 formulaStatus 判定) */ + textChunkCount: number + /** 成功还原的 text chunk 数 */ + applied: number + /** 因过短而未尝试对齐的 text chunk 数 */ + skipped: number + /** 产物可读、行内还原有块可查(bare 产物也算 true) */ + hasBlockIndex: boolean + /** + * **组装期统计的公式 token 列表**(N-02):按组装顺序收集的、可以确信是真公式的 `$…$`—— + * formula chunk 原样发的串 + 块跨度里**看起来是 LaTeX** 的片段。`verifyFormulas` 优先吃这个列表, + * 而不是回头解析最终字符串;正文里的成对字面 `$`(`$5 up to $`)既不入列表、也已被中和。 + */ + formulaTokens: string[] +} + +/** + * 组装源文本(§3.3②/§3.4/§3.5)。 + * - `formula` chunk → `$` + 紧凑 LaTeX + `$`(**该段乱码字符不进入请求体**) + * - `text` chunk → 在**该页所有候选块**里取最高分(跨块/跨页各自命中自己的页) + * - 页码变化处插一个 `\n`(§3.5 ④) + * - 清洗按 piece 分类做:`$…$` 内逐字保留 LaTeX,外面清文本层乱码(见 `stripJunk` 的说明) + * + * 行尾换行必须保留:客户端把换行作为 text 段的**结尾字符**发过来(§3.2-6),而替换用的块跨度 + * 不含换行;不补回去,两行正文会被粘成一行(system 第 3 条要求保持行结构)。 + */ +export function assembleSource(chunks: readonly TranslateChunk[], index: TextBlockIndex | null): AssembleResult { + const parts: string[] = [] + const tokens: string[] = [] + let textChunkCount = 0 + let applied = 0 + let skipped = 0 + let lastPage: number | null = null + for (const c of chunks) { + if (lastPage !== null && c.page !== lastPage) parts.push('\n') + lastPage = c.page + if (c.kind === 'formula') { + const piece = '$' + (c.latex ?? '') + '$' + parts.push(piece) + tokens.push(piece) // 客户端按 equation bbox 切出来的 ⇒ 一定是真公式 + continue + } + textChunkCount++ + const chunkText = c.text ?? '' + const eol = /[ \t]*\r?\n[ \t]*$/.test(chunkText) ? '\n' : '' + let best: AlignCandidate | null = null + let wasSkipped = false + for (const blk of index?.blocks ?? []) { + if (blk.page !== c.page) continue + const r = alignSelectionToBlock(chunkText, blk.text) + if (r.skipped) { + wasSkipped = true + continue + } + best = better(best, r.applied === 1 + ? { text: r.text + eol, score: r.score ?? 0, ratio: r.ratio ?? 1, ok: true, unpairedLetters: '', completedTail: '' } + : null) + } + if (wasSkipped) skipped++ + if (best) { + applied++ + // 块跨度:里面的 `$…$` 是块文本的 LaTeX 片段;但块正文里也可能印了**字面** `$`(`$5 up to $`)。 + // 只保留「看起来是 LaTeX」的片段,其余 `$` 中和成全角(N-02);token 在**组装期**收集。 + const span = neutralizeNonLatexPairs(best.text) + parts.push(span) + for (const frag of latexFragments(span)) tokens.push(frag) + } else { + // 未对齐 → 这段就是文本层原文,里面的 `$` 只可能是**字面字符**(`$5` 之类), + // 不是定界符。必须中和掉:否则它会与别处的 `$` 配成假片段, + // 让 `formulaTokens`/`verifyFormulas` 把真公式漏掉(R-03 实测:源 `cost is $5 and $x_t$` + // 只识别出一个 token `$5 and $`,`$x_t$` 丢失时 formulaLost 仍为 0 ⇒ A7 失效)。 + parts.push(neutralizeDollars(chunkText)) + } + } + return { + source: collapse(parts.map(cleanPiece).join('')), + textChunkCount, + applied, + skipped, + hasBlockIndex: index?.source === 'mineru', + formulaTokens: tokens, + } +} + +/** `formulaStatus` 判定(§3.9 表)。 */ +export function formulaStatusOf(r: Pick): string { + if (!r.hasBlockIndex) return 'no-artifact' + if (r.textChunkCount === 0) return 'applied' + if (r.applied === 0) return 'unavailable' + if (r.applied < r.textChunkCount) return 'partial' + return 'applied' +} + +/* ── 提示词与请求体(§3.6)────────────────────────────────────────────── */ + +export function clampMaxTokens(chars: number): number { + return Math.min(4096, Math.max(256, Math.ceil(1.5 * chars))) +} + +/** 推理型模型关思考(与 src/translate.ts:264 同一判据、同一理由:自定义端点不加)。 */ +export function wantsThinkingOff(baseUrl: string): boolean { + return /bigmodel|kimi|moonshot|deepseek/i.test(baseUrl) +} + +/** 请求体:**只有** model/messages/max_tokens/temperature(命中域名时才多 thinking)。 */ +export function buildRequestBody(model: string, source: string, baseUrl = ''): Record { + const body: Record = { + model, + messages: [ + { role: 'system', content: SELECTION_SYSTEM_PROMPT }, + { role: 'user', content: source }, + ], + max_tokens: clampMaxTokens(source.length), + temperature: 0, + } + if (wantsThinkingOff(baseUrl)) body['thinking'] = { type: 'disabled' } + return body +} + +/* ── 响应清洗与公式保全校验(§3.6-2/3/4)──────────────────────────────── */ + +/** 整体被 ``` 包裹时剥掉围栏与语言标注。 */ +export function stripFence(s: string): string { + const t = s.trim() + const m = /^```[A-Za-z0-9_+-]*[ \t]*\r?\n([\s\S]*?)\r?\n?```$/.exec(t) + return m ? m[1]!.trim() : t +} + +/** 剥掉行首的 `译文:` / `翻译:` / `Translation:`。 */ +export function stripPrefix(s: string): string { + return s.replace(/^\s*(?:译文|翻译|Translation)\s*[::]\s*/, '') +} + +/** + * 源文本里的 `$…$` token(按出现顺序)。 + * **权威来源是 `assembleSource` 在组装期收集的 `formulaTokens` 列表**(N-02)——组装文本里的 + * 每个 `$…$` 都在组装期过了唯一那道闸门(`neutralizeNonLatexPairs`:字面 `$` 中和为全角), + * 所以这里再解析一遍得到的列表与那个列表**逐项相同**(测试 `N-02` 直接断言这条等价性)。 + * 保留本函数是为了兼容「只拿到源文本」的调用方(如 host 路由,不在本轮 inScope)与单测。 + */ +export function formulaTokens(source: string): string[] { + return [...source.matchAll(/\$[^$]*\$/g)].map((m) => m[0]) +} + +export interface FormulaVerify { + total: number + kept: number + lost: number + missing: string[] +} + +/** 逐 token 原样 `includes` 校验(§3.6-4)。v1 不做自动回插,只回报缺失数。 */ +export function verifyFormulas(source: string, translation: string, tokens?: readonly string[]): FormulaVerify { + // tokens 优先:调用方(路由/组装方)应传 `assembleSource(...).formulaTokens`,避免回头解析字符串 + const list = tokens ?? formulaTokens(source) + const missing = list.filter((t) => !translation.includes(t)) + return { total: list.length, kept: list.length - missing.length, lost: missing.length, missing } +} + +/* ── 缓存(§3.7④):进程内存 LRU,不落盘 ───────────────────────────────── */ + +/** 键 = `sha256(model + '\0' + assembledText)`;**不含 key**(不做任何密钥派生)。 */ +export function selectionCacheKey(model: string, source: string): string { + return createHash('sha256').update(model + '\0' + source, 'utf8').digest('hex') +} + +/** 极简 LRU:Map 的插入序即最近使用序(命中先删再插)。零依赖。 */ +export class TranslateSelectionCache { + private map = new Map() + + constructor(private readonly limit: number = CACHE_LIMIT) {} + + get(key: string): string | undefined { + const hit = this.map.get(key) + if (hit === undefined) return undefined + this.map.delete(key) + this.map.set(key, hit) + return hit + } + + set(key: string, value: string): void { + if (this.map.has(key)) this.map.delete(key) + this.map.set(key, value) + while (this.map.size > this.limit) { + const oldest = this.map.keys().next().value + if (oldest === undefined) break + this.map.delete(oldest) + } + } + + get size(): number { return this.map.size } + + clear(): void { this.map.clear() } +} + +/** 进程级默认缓存(服务端路由用它;测试可注入自己的实例)。 */ +export const selectionCache = new TranslateSelectionCache() + +/* ── 端点调用(§3.6 / §3.7)──────────────────────────────────────────── */ + +export type FetchLike = (url: string, init: Record) => Promise + +export interface TranslateSelectionDeps { + fetchImpl?: FetchLike + timeoutMs?: number + cache?: TranslateSelectionCache + /** 便于测试与排障留痕;默认 no-op,只打印白名单字段 */ + log?: (info: { model: string; status: number; ms: number; chars: number }) => void +} + +export interface TranslateSelectionResult { + translation: string + model: string + cached: boolean + ms: number + formulaTotal: number + formulaKept: number + formulaLost: number +} + +function networkDetail(err: unknown): string { + const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '' + return (err instanceof Error ? err.message : String(err)) + cause +} + +/** + * 调端点翻译一段源文本。 + * - body/URL/头与 §3.6 写死一致;**不**发 response_format/reasoning/tools/top_p/stream + * - 唯一的重试:HTTP 400 且响应体匹配 `/temperature/i` → 去掉 `temperature` 重发一次 + * - 上游错误详情一律过 `sanitizeDetail(text, apiKey)`;本函数不打印任何 key + */ +export async function translateSelection( + endpoint: TranslateEndpoint, + source: string, + deps: TranslateSelectionDeps = {}, +): Promise { + const baseUrl = endpoint.baseUrl ?? '' + const apiKey = endpoint.apiKey ?? '' + const model = endpoint.model ?? '' + if (!baseUrl || !apiKey || !model) { + throw new SelectionTranslateError('no-endpoint', 503, '未配置翻译端点:在阅读器里点 ⚙ 填写(OpenAI 兼容端点即可)') + } + const cache = deps.cache ?? selectionCache + const key = selectionCacheKey(model, source) + const hit = cache.get(key) + if (hit !== undefined) { + const v = verifyFormulas(source, hit) + return { translation: hit, model, cached: true, ms: 0, formulaTotal: v.total, formulaKept: v.kept, formulaLost: v.lost } + } + + const fetchImpl = (deps.fetchImpl ?? (globalThis.fetch as FetchLike)) + const url = baseUrl.replace(/\/+$/, '') + '/chat/completions' + const headers = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` } + const started = Date.now() + const send = async (body: Record): Promise => { + try { + return await fetchImpl(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(deps.timeoutMs ?? UPSTREAM_TIMEOUT_MS), + }) + } catch (err) { + // 超时 vs 连不上:两者文案不同(§3.7②) + const name = err instanceof Error ? err.name : '' + if (name === 'TimeoutError' || name === 'AbortError') { + throw new SelectionTranslateError('timeout', 504, '翻译超时,请重试') + } + // 网络异常文本同样必须过 sanitizeDetail(R-04):fetch 抛出的原因串可能带上 + // baseUrl(含凭据/查询参数)或回显 Authorization,而这条消息会原样进响应体到前端。 + throw new SelectionTranslateError('network', 502, `无法连接端点:${sanitizeDetail(networkDetail(err), apiKey)}`) + } + } + + let res = await send(buildRequestBody(model, source, baseUrl)) + let resText: string | null = null + if (res.status === 400) { + const probe = await res.text().catch(() => '') + // 唯一的白名单重试:严格端点不认 temperature + if (/temperature/i.test(probe)) { + const body = buildRequestBody(model, source, baseUrl) + delete body['temperature'] + res = await send(body) + } else { + resText = probe + } + } + if (!res.ok) { + const text = (resText ?? await res.text().catch(() => '')).slice(0, 200) + throw new SelectionTranslateError( + 'upstream', 502, + `上游返回 HTTP ${res.status}${text ? ':' + sanitizeDetail(text, apiKey) : ''}`, + res.status, + ) + } + const rawText = resText ?? await res.text().catch(() => '') + let data: unknown + try { + data = JSON.parse(rawText) + } catch { + throw new SelectionTranslateError('upstream', 502, `端点响应不是合法 JSON:${sanitizeDetail(rawText.slice(0, 200), apiKey)}`, res.status) + } + const content = (data as { choices?: Array<{ message?: { content?: unknown } }> })?.choices?.[0]?.message?.content + if (typeof content !== 'string' || content.trim() === '') { + throw new SelectionTranslateError('upstream', 502, '端点没有返回补全内容(choices[0].message.content 为空)', res.status) + } + const translation = stripPrefix(stripFence(content)) + const ms = Date.now() - started + cache.set(key, translation) + const v = verifyFormulas(source, translation) + // 日志白名单:只有 model/status/ms/chars(§3.7③)——key 与 baseUrl 永不出现 + const log = deps.log ?? ((info) => console.log('[dsh-paper-reader] translate-selection', JSON.stringify(info))) + log({ model, status: 200, ms, chars: source.length }) + return { translation, model, cached: false, ms, formulaTotal: v.total, formulaKept: v.kept, formulaLost: v.lost } +} diff --git a/test/selection-translate-verification.test.mjs b/test/selection-translate-verification.test.mjs new file mode 100644 index 0000000..356c5ef --- /dev/null +++ b/test/selection-translate-verification.test.mjs @@ -0,0 +1,214 @@ +// test/selection-translate-verification.test.mjs — t3 独立验证的**补充复现测试**(审查方产出) +// +// 与 test/selection-translate.test.mjs(实现方自测)的分工: +// · 这里只固定「评审自己构造并亲自复现过」的事实:真实 .mineru.json 夹具、真实 PDF 的页数、 +// 以及 t3 报告里两个缺陷的**最小复现**(缺陷用例显式 skip,附带原因;修复后应把它们启用)。 +// · 不 import 网络、不起浏览器、不写任何文献库文件;只用本机真实产物(缺失则 skip)。 +// +// 复现命令:npm run build && node --test test/selection-translate-verification.test.mjs + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO = join(dirname(fileURLToPath(import.meta.url)), '..') +const dist = (m) => join(REPO, 'dist', m) + +const { assembleSource, alignSelectionToBlock, stripJunk, formulaStatusOf, normForAlign } = await import(dist('translate-selection.js')) +const { compactLatex, buildTextBlockIndex, buildFormulaIndex } = await import(dist('formulas.js')) + +const LIB = join(homedir(), '.dsh-paper-reader', 'data', '默认专题') +const DONG = 'Dong et al. - 2026 - Language-Conditioned World Modeling for Visual Navigation' +const HAS_DONG = existsSync(join(LIB, DONG + '.mineru.json')) +const stripWs = (s) => s.replace(/\s+/g, '') + +/* ══ 1. 真实产物夹具:独立算出「该页含 $ 的 text 块」并按阅读序取用 ══════════ */ +const dongRaw = HAS_DONG ? readFileSync(join(LIB, DONG + '.mineru.json'), 'utf8') : null + +/** 本脚本自己的块表(按 contentList 顺序 + page_idx 缺失沿用上一块),与实现方的索引无关 */ +function myBlocks(pageNo) { + const parsed = JSON.parse(dongRaw) + const out = [] + let last = 0 + for (const b of parsed.contentList) { + if (typeof b.page_idx === 'number') last = b.page_idx + if (b.type !== 'text' || typeof b.text !== 'string' || !b.text.includes('$')) continue + if (last + 1 === pageNo) out.push(compactLatex(b.text)) + } + return out +} +const page5Blocks = HAS_DONG ? myBlocks(5) : [] +const page6Blocks = HAS_DONG ? myBlocks(6) : [] + +/* ══ 2. 组装链路:真机里实测到的三个选区形态(V1 亲自复现过,见 docs/selection-translate-verification.md) ══ */ +test('V1 行内单公式:/Conditions on navigation action ˆat/ → 公式被替换为紧凑 LaTeX(真实块夹具)', { skip: !HAS_DONG }, () => { + const r = assembleSource([{ page: 5, kind: 'text', text: 'conditions on navigation action ˆat' }], buildTextBlockIndex(dongRaw)) + assert.equal(r.source, 'conditions on navigation action $\\hat{a}_t$') + assert.equal(r.applied, 1) +}) + +test('V2 行内多公式(跨两行、同一块):4 个公式按序替换,正文逐字来自块(真实块夹具)', { skip: !HAS_DONG }, () => { + const text = 'Given current observations encoded as VAE latents,LCVN-WM Fθ conditions on navigation action ˆatwhere t denotes timestep, instruction I, and timeshift ts to predict the next state ˆst+1:' + const r = assembleSource([{ page: 5, kind: 'text', text }], buildTextBlockIndex(dongRaw)) + assert.equal( + r.source, + 'Given current observations encoded as VAE latents, LCVN-WM $F_\\theta$ conditions on navigation action $\\hat{a}_t$ where t denotes timestep, instruction I, and time shift $t_s$ to predict the next state $\\widehat s_{t+1}\\mathrm{:}$', + ) + assert.deepEqual(r.source.match(/\$[^$]*\$/g), page5Blocks[0].match(/\$[^$]*\$/g)) +}) + +test('V3 display 公式:formula chunk → `$紧凑LaTeX$`,乱码字符不进请求体', { skip: !HAS_DONG }, () => { + const idx = buildFormulaIndex(dongRaw) + const eq = idx.equations.find((e) => e.page === 6 && e.latex.includes('tag{4}') === false && e.latex.includes('\\begin{array}')) + assert.ok(eq, '第 6 页应有一个 array 型 display 公式') + const latex = eq.latex.replace(/^\s*\$\$?/, '').replace(/\$\$?\s*$/, '').trim() + const garbled = 'ˆsπt+1 ∼ Fθ (ˆsπt+1 | st, ˆat, Iclip, ts), (4)' // 真实文本层乱码(含 \u0000 等) + const r = assembleSource([{ page: 6, kind: 'formula', latex }, { page: 6, kind: 'text', text: ' (4)' }], buildTextBlockIndex(dongRaw)) + assert.ok(r.source.startsWith('$' + latex + '$'), 'display 公式原样包 $…$') + assert.ok(!r.source.includes('ˆsπt+1'), '乱码字形不得进入请求体') +}) + +test('V4 token 与 .mineru.json 原文只差空白(压紧过程不篡改内容)', { skip: !HAS_DONG }, () => { + const r = assembleSource([{ page: 5, kind: 'text', text: 'Given current observations encoded as VAE latents,LCVN-WM Fθ conditions' }], buildTextBlockIndex(dongRaw)) + const toks = r.source.match(/\$[^$]*\$/g) ?? [] + assert.ok(toks.length >= 1) + const rawBlock = page5Blocks[0] + for (const t of toks) assert.ok(stripWs(rawBlock).includes(stripWs(t.slice(1, -1))), `token ${t} 应能在块原文里找到(去空白后)`) +}) + +test('V5 组装文本不含 C0/C1、私用区、U+FFFD、走音符号(真实乱码夹具)', { skip: !HAS_DONG }, () => { + const r = assembleSource([{ page: 5, kind: 'text', text: 'si=Encθ(oi)\u02c6st+1\u0000\u0010\u0011 \ufffd \ue000\ue001 where t denotes timestep' }], buildTextBlockIndex(dongRaw)) + const bad = [...r.source].filter((c) => { + const n = c.codePointAt(0) + return (n <= 0x1f && n !== 0x0a) || (n >= 0x7f && n <= 0x9f) || (n >= 0xe000 && n <= 0xf8ff) || n === 0xfffd || (n >= 0x300 && n <= 0x36f) + }) + assert.deepEqual(bad, []) +}) + +test('V6 清洗边界:stripJunk 会吃掉 `^`(所以 LaTeX piece 必须绕开它),formula chunk 逐字保留', () => { + // 这就是 assembleSource 必须「按 piece 分类清洗」的原因:整段过 stripJunk 会把 v^\top 毁成 v\top。 + assert.equal(stripJunk('a\u0000b^2 c'), 'ab2 c') + const r = assembleSource([{ page: 1, kind: 'formula', latex: 'v^\\top x^2' }], null) + assert.equal(r.source, '$v^\\top x^2$') +}) + +test('V7 无 MinerU 产物:原样返回 + formulaStatus=no-artifact(不假装还原)', () => { + const r = assembleSource([{ page: 1, kind: 'text', text: 'E = mc2 and πθ(a|s)' }], buildTextBlockIndex(null)) + assert.equal(r.source, 'E = mc2 and πθ(a|s)') + assert.equal(r.applied, 0) + assert.equal(formulaStatusOf(r), 'no-artifact') +}) + +test('V8 极短选区(nf < 4)不对齐:原样返回且 skipped 计数', () => { + const r = assembleSource([{ page: 1, kind: 'text', text: 'πθ' }], buildTextBlockIndex(JSON.stringify({ contentList: [{ type: 'text', page_idx: 0, text: 'we use $\\pi_\\theta$ here' }] }))) + assert.equal(r.source, 'πθ') + assert.equal(r.applied, 0) + assert.equal(r.skipped, 1) + assert.equal(formulaStatusOf(r), 'unavailable') +}) + +test('V9 跨页:两个 page 的 text chunk 各自成段,页间插 \\n(A14 的组装侧)', { skip: !HAS_DONG }, () => { + const a = assembleSource([ + { page: 5, kind: 'text', text: 'conditions on navigation action ˆat' }, + { page: 6, kind: 'text', text: 'where n denotes total trajectory length' }, + ], buildTextBlockIndex(dongRaw)) + assert.ok(a.source.includes('$\\hat{a}_t$'), '第 5 页的公式被还原') + assert.ok(a.source.includes('where n denotes total trajectory length'), '第 6 页的正文保留') + assert.equal(a.source.split('\n').length, 2, '页边界恰好一个 \\n') +}) + +test('V10 归一化只保留 [a-z0-9]+CJK(对口径的独立复算)', () => { + assert.equal(normForAlign('F_\\theta$ (ˆa_t)').A, 'fthetaat') +}) + +/* ══ 3. 分屏的两个纯函数(从 reader 内联脚本里原样抽出后求值,不改源码) ══════ */ +const READER = readFileSync(join(REPO, 'reader', 'index.html'), 'utf8') +const fnSrc = (name) => { + const i = READER.indexOf(`function ${name}(`) + assert.ok(i > 0, `reader/index.html 里应有 function ${name}`) + let d = 0 + for (let j = READER.indexOf('{', i); j < READER.length; j++) { + if (READER[j] === '{') d++ + else if (READER[j] === '}') { d--; if (d === 0) return READER.slice(i, j + 1) } + } + throw new Error('unbalanced') +} +const mkFn = (name) => new Function(`${fnSrc(name)}; return ${name}`)() + +test('V11 anchorOf:顶部页号 + 页内比例(独立复算)', () => { + const anchorOf = mkFn('anchorOf') + const pages = [{ page: 1, offsetTop: 0, offsetHeight: 100 }, { page: 2, offsetTop: 100, offsetHeight: 200 }] + assert.deepEqual(anchorOf(pages, 0), { page: 1, ratio: 0 }) + assert.deepEqual(anchorOf(pages, 150), { page: 2, ratio: 0.25 }) + assert.deepEqual(anchorOf(pages, 100), { page: 2, ratio: 0 }) + assert.deepEqual(anchorOf(pages, 999), { page: 2, ratio: 4.495 }) + assert.equal(anchorOf([], 0), null) +}) + +test('V12 syncTargetOf:页数相同走同页号;页数不同按比例映射并置 mismatch(独立复算)', () => { + const syncTargetOf = mkFn('syncTargetOf') + const target = [{ page: 1, offsetTop: 0, offsetHeight: 120 }, { page: 2, offsetTop: 120, offsetHeight: 80 }] + assert.deepEqual(syncTargetOf({ page: 2, ratio: 0.5 }, target, 2), { top: 160, mismatch: false }) + const m = syncTargetOf({ page: 1, ratio: 0.5 }, target, 3) + assert.equal(m.mismatch, true) + assert.equal(m.top, 60) +}) + +/* ══ 4. 真实 PDF 的变体页数(分屏同步的前提;B9 的夹具口径) ═══════════════ */ +test('V13 本机真实产物:原文 / -zh / -dual 页数与页面尺寸(-dual 恰 2× 页宽)', { skip: !existsSync(join(LIB, DONG + '-zh.pdf')) }, async () => { + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs') + const stat = async (p) => { + const doc = await getDocument({ data: new Uint8Array(readFileSync(p)) }).promise + const vp = (await doc.getPage(1)).getViewport({ scale: 1 }) + return { pages: doc.numPages, w: vp.width, h: vp.height } + } + const o = await stat(join(LIB, DONG + '.pdf')) + const z = await stat(join(LIB, DONG + '-zh.pdf')) + assert.deepEqual([o.pages, o.w, o.h], [19, 612, 792]) + assert.deepEqual([z.pages, z.w, z.h], [19, 612, 792]) + if (existsSync(join(LIB, DONG + '-dual.pdf'))) { + const d = await stat(join(LIB, DONG + '-dual.pdf')) + assert.deepEqual([d.pages, d.w], [19, 1224]) + } +}) + +/* ══ 5. 原 t3 缺陷(attempt 2 复验:修复后已启用,全部通过) ══════════════ + attempt 1 时这两条以 skip 记录「应有行为」;修复 commit(src/translate-selection.ts 12:59、 + reader/index.html 12:58)后它们转为**必须通过**的回归用例:任何一次回退都会让全量测试变红。 */ + +test('R2-F1 行内对齐不得丢掉跨度前的正文词(原 t3 F1,已修复)', { skip: !HAS_DONG }, () => { + const block = page6Blocks.find((b) => b.startsWith('where $\\mathbf{s}_t')) + assert.ok(block, '第 6 页应有以 `where $\\mathbf{s}_t` 开头的 text 块') + const r = alignSelectionToBlock('where st = [st, st−1, ..., st−k] denotes the sequence', block) + assert.equal(r.applied, 1) + assert.ok(r.text.startsWith('where '), `还原结果必须以块里的 "where" 开头,实际=${r.text}`) + assert.ok(r.text.includes('denotes the sequence'), `尾部正文也必须保留,实际=${r.text}`) + assert.ok(r.text.includes('$\\mathbf{s}_t='), `公式仍要还原成紧凑 LaTeX,实际=${r.text}`) +}) + +test('R2-F1b 带 display 公式编号前缀的同一形态(原 t3 S6 真机形态)', { skip: !HAS_DONG }, () => { + const block = page6Blocks.find((b) => b.startsWith('where $\\mathbf{s}_t')) + const r = alignSelectionToBlock(' (4)where st = [st, st−1, ..., st−k] denotes the sequence', block) + assert.equal(r.applied, 1) + assert.ok(r.text.startsWith('where '), `实际=${r.text}`) +}) + +test('R2-F2 选区所属段落不在候选集里时不得被别的段落接走(原 t3 F2,已修复)', { skip: !HAS_DONG }, () => { + // 该段落(During training, an expert encoder … while a learner encoder …)的块在**第 5 页**(跨页段落), + // 第 6 页候选块里没有它 ⇒ 必须原样返回(applied=0),而不是吐出别段落的 "expert and learner encoders"。 + const r = assembleSource([{ page: 6, kind: 'text', text: ', while a learner encoder' }], buildTextBlockIndex(dongRaw)) + assert.equal(r.source, ', while a learner encoder') + assert.equal(r.applied, 0) +}) + +test('R2-F2b 短碎片不得被错配(原 t3 F2 的同型反例,已修复)', { skip: !HAS_DONG }, () => { + const idx = buildTextBlockIndex(dongRaw) + for (const frag of [', while a learner encoder', 'During', 'training,', 'forcing (DF) (Chen et al., 2024; Song et al., 2025) to']) { + const r = assembleSource([{ page: 6, kind: 'text', text: frag }], idx) + assert.equal(r.source, frag, `短/跨页碎片 ${JSON.stringify(frag)} 必须原样返回`) + assert.equal(r.applied, 0) + } +}) diff --git a/test/selection-translate.test.mjs b/test/selection-translate.test.mjs new file mode 100644 index 0000000..360d66a --- /dev/null +++ b/test/selection-translate.test.mjs @@ -0,0 +1,1959 @@ +// test/selection-translate.test.mjs — 划词翻译(公式保留)+ 中英文分屏对照 的零依赖用例。 +// +// 契约:docs/selection-translate-requirements.md(冻结 v1)§3.3–§3.9(服务端)与 §4(分屏)。 +// 只 import dist/*.js(跑前先 `npm run build`)+ vm 里跑**真实** reader 脚本。 +// +// 覆盖(验收清单 A*/B* 里标「自动」的条目): +// · 两条公式还原路径:**display 的 bbox 中心点相交**(客户端 formulaBoxByCenter/buildChunks) +// 与**行内的文本对齐**(服务端 alignSelectionToBlock/assembleSource) +// · 多公式、部分重叠、对齐失败、无产物降级、跨页 +// · 提示词逐字节契约、请求体字段集合、公式保全校验、缓存、失败分档 +// · UI 结构性断言:译文气泡存在且零按钮、.eqLayer 仍 pointer-events:none、 +// data-doc 作用域(中文列无热区/不污染 EQ_BY_PAGE)、模式切换的存储键 +// +// 全部零网络(上游用本机 mock HTTP),不触碰真实文献库(真实样本只**只读**读取)。 + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' + +const ROOT = dirname(fileURLToPath(import.meta.url)) +const REPO = join(ROOT, '..') +const dist = (m) => join(REPO, 'dist', m) + +const { + buildFormulaIndex, buildTextBlockIndex, compactLatex, resetFormulaCache, +} = await import(dist('formulas.js')) +const { + CACHE_LIMIT, MIN_NORM, SELECTION_SYSTEM_PROMPT, + SelectionTranslateError, TranslateSelectionCache, + alignSelectionToBlock, assembleSource, buildRequestBody, clampMaxTokens, formulaStatusOf, + formulaTokens, isLatexLikeFragment, normForAlign, selectionCache, selectionCacheKey, + stripFence, stripJunk, stripPrefix, translateSelection, verifyFormulas, +} = await import(dist('translate-selection.js')) +const { registerRoutes } = await import(dist('host.js')) + +const READER_HTML = readFileSync(join(REPO, 'reader', 'index.html'), 'utf8') +const SCRIPT_SRC = READER_HTML.match(/