Skip to content

feat: 划词翻译(含公式保留)与中英文分屏对照 - #7

Merged
Veryyoung957 merged 9 commits into
GGboya:mainfrom
Veryyoung957:feat/selection-translate
Oct 12, 2026
Merged

Veryyoung957 merged 9 commits into
GGboya:mainfrom
Veryyoung957:feat/selection-translate

Conversation

@Veryyoung957

@Veryyoung957 Veryyoung957 commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

背景

读论文时有两件高频的事,目前都做不好:

  1. 一段话看不懂——想就地翻译,但复制出去会丢掉公式(PDF 文本层里公式是乱码),
    译出来的句子恰好缺了最关键的那几个符号。
  2. 想中英对照着读——而不是在原文与整篇译文之间来回切。

这个 PR 加两件事:划词翻译(气泡显示译文,公式保留)与中英文分屏对照(两种模式)。

公式保留:两条路径,机制不同

这是本 PR 最需要说明的部分,因为做错了不会有显眼症状——气泡里照样显示一段通顺的
译文,只是它译的是错的文本。

(1) display 公式(独立成行):MinerU 的 equation 块有自己的 bbox,按
「字符矩形中心点落进 bbox」归属。刻意不用「任何交叠」——实测那样会在 2/18 例
把下一行正文一起吞进来。

(2) 行内公式(正文里穿插的 $…$):没有自己的 bbox,嵌在 text 块的 text
字段里,因此走的是完全不同的机制——候选块定位 + 双向行走对齐(种子、块侧/选区侧
跳过、词边界与片段边界外扩、长度保险、$ 配对校验)。

对齐不过门槛时拒绝还原并如实提示,而不是降格选一个跨段候选。取舍是明确的:
「译错句子且用户看不出来」比「公式仍是乱码但用户看得到诚实提示」严重得多。

坐标口径:MinerU 的 bbox 是页面归一化 ×1000 且 x、y 各自归一,所以映射必须分别用
viewport.width/1000 与 viewport.height/1000——用同一个比例尺会在非正方形页面上错位。

诚实提示机制

气泡里只有纯译文,外加一行状态:无 MinerU 产物 / 对齐失败 / 公式未原样保留(逐 token
校验)等。「公式未保留」这个提示的全部价值就在于它可信——所以本 PR 专门处理了会让它
说谎的情况:

公式保全提示的可信度:选区所在段落里若正文真的印了 $(如金额),旧版会把
$5 up to $ 当成公式 token,导致译文漏掉它时给出假的「有 N 个公式未原样保留」提示;
已修(两个分支都把「不像 LaTeX」的字面 $ 中和为全角 $,token 改为组装期统计)。
判据边界:紧凑的成对字面 $($USD$)与纯符号公式 $F$ 形态相同、无法区分,会按
公式计入;本机两份语料(154+112 个 text 块、两条真实文本层)中不存在该形态。

中英文分屏对照(两种模式,可切换)

  • 模式 A:用 babeldoc 生成的 -dual.pdf。顺带修好一个既有问题——#zh-toggle 的三态
    循环在同时存在 -zh/-dual 时永远进不到 dual,现在有了显式的「对照」入口。
  • 模式 B:原文 PDF ↔ -zh.pdf 并排同步滚动(rAF 节流,同页码 + 页内比例;页数不同
    时按比例映射),窄面板有宽度闸门。

两种模式复用既有的 zhVariant/zhState 状态机,不另写一套;中文列不铺公式热区层、
不污染原文的几何索引。

前提与代价(README 已如实写明)

功能 前提
划词翻译 需先配置翻译端点(复用为 babeldoc 配置的那个;未配置时点「译」自动弹设置表单,保存后自动重试)
公式保留 需要该论文用 MinerU 转录过;无产物时降级为普通翻译并如实提示——这是设计,不是缺陷
模式 A 需要 -dual.pdf(通常要先跑 babeldoc;无产物时按钮置灰)
模式 B 需要 -zh.pdf(同上)

测试

npm install && npm test

477 tests / 465 pass / 0 fail / 12 skip,零新增依赖、离线可跑;需要真实服务的用例在
不可达时显式 skip 并 exit 0,不静默通过。

质量闸门:本功能经一轮独立验证(真机 headless Chrome + CDP、真鼠标拖拽/点击、真 host +
假上游抓「送去翻译的文本」、真实 PDF + 真实 .mineru.json)与两轮对抗评审(自建对抗探针、
真实语料差分、变异测试——删掉任一护栏对应用例必须变红)。评审发现并已修复的主要缺陷包括:
过期响应盖到新选区(气泡静默显示上一段选区的译文)、行内对齐丢词、跨段张冠李戴、刷新后无法
恢复分屏、字面 $ 击穿公式保全校验、网络异常文本未脱敏。

分支与合入顺序

  • 本分支已**重排到 main(含已合入的 MinerU 解析、公式检索/引用、文献管理整合)**之上,因此 base 选 main 即可,不再是堆叠 PR。
  • 重排时只重放了本功能自己的提交;版本 1.5.0(main 为 1.4.0)。

已知限制(如实声明)

  • 未对真实 LLM 端点发过任何请求:翻译请求的验证走的是本地假上游,抓的是「送去翻译的
    文本」;真实端点上的模型行为未验证。
  • $USD$ 判据边界:紧凑的成对字面 $ 与纯符号公式形态相同,会被按公式计入(见上)。
  • RATIO_LO 是「宁可不还原」的质量旋钮,可达但没有回归护栏;它是取舍不是正确性漏洞。
  • 半公式:MinerU 把公式拆成两半时,本版按不可对齐处理(拒绝还原 + 提示)。
  • 真机项:真实鼠标手感、引文跳转、页码输入、适宽按钮、改名搬家、跨页选区可达性——留给使用者确认。
  • 性能与跨平台未测。
  • 低危构造性反例:「组装期 token 列表 ≡ 字符串解析」在非标准调用方传入带乱码/带内层 $
    的 latex 时不成立;真实产物上不可达、生产路径不受影响(已在文档中如实记录)。

需要更详细的需求 / 验证 / 评审记录可以随时提供。

公式在气泡里是渲染出来的(vendored KaTeX)

译文里的 $…$ / $$…$$ 会渲染成数学,而不是把 LaTeX 当纯文本显示——既然保留公式的目的是"能读",摆一堆 \mathbf{s}_t=\left[s_t,\dots\right] 就没意义了。

  • 不引 CDN:KaTeX 0.16.22 随包分发在 reader/vendor/katex/(含 MIT 全文与来源/校验值)。论文伴读是离线场景,读者页面不许任何运行时外部请求。
  • 这不算新增依赖:它是静态资源,不进 package.json/lockfile、不参与安装解析(依赖集合与 node_modules 不变)。
  • 体积:reader/ 净增 568 KB(只带 woff2;全格式要多 817 KB)。
  • 安全:译文是模型输出,按不可信处理——文本段一律 textContent;数学段交给 KaTeX 往新建元素里渲染(trust:false、throwOnError:false),没有任何路径把模型输出拼进 innerHTML,并有静态守卫钉住。
  • 降级:某个公式渲染失败时,该段退回显示原始 LaTeX 并在状态行点名提示,绝不留白。

分屏对照的「同步滚动」可选项(v1.5.0 后续)

模式 B(原文 ↔ -zh.pdf 双列)的同步滚动可以关掉——关掉后两列各自独立滚动,方便对照着读不同位置。

  • 两处入口:分屏控件旁的小按钮 + 设置面板里的卡片,两处互相同步(同源 storage 事件,无回声)。
  • 记忆范围全局(所有论文共用一个偏好),键 dpr.syncScroll;默认开(= 原先的行为)。
  • 重新打开时对齐回当前页(基准 = 最后滚过的那一列,保留页内比例)。
  • 进分屏时的一次性对齐刻意不看开关——否则「关着同步进分屏」会直接停在两页上。
  • 模式 A(babeldoc -dual.pdf)不涉及:它是一个 PDF 里并排嵌好的,没有两个滚动条;开关在其下不可见。

已声明的低危项(不影响本次交付):同步关着、两列各自独立时,重渲会向两列程序化写入 scrollTop,而滚动处理器在守卫之前就记录了「基准列」,于是重渲后再打开同步,动的是原文列而不是中文列(用户明写的「重新打开对齐回当前页」仍满足)。这是既有缺陷,F1 修好之后才第一次有用户可见后果;修法约 2 行(让程序化写入也纳入 splitSyncing 守卫)。

Veryyoung957 added a commit to Veryyoung957/dsh-paper-reader that referenced this pull request Oct 12, 2026
CI run 38193897047:ubuntu ✅ / macOS ❌(1 条整文件)/ windows ❌(3 条)。逐条对着日志原文定位:

**macOS:`not ok 14 - test/merge-verify-t3-library.test.mjs` 是 `testTimeoutFailure`(60001ms),而且日志里该
文件的 V1b–V6 **全部 `ok`** ⇒ 不是用例失败,是**用例跑完后进程不退出**。根因:V4 里的假 babeldoc 是
「先写 pidfile 再 `exec sleep 120`」的后台子进程,测试结束时用 pidfile 里的 pid 去 SIGKILL —— 但
spawn→shell→落盘要几毫秒,**macOS 上这个竞态让 kill 打空**,`sleep` 子进程留下来把 event loop 撑到 60s 超时。
**本机复现**:把假脚本改成晚 0.5s 写 pidfile → 本机同样「7 个用例全 ok 但文件 60s 超时」
(`testTimeoutFailure`,与 CI 逐字一致)✓。修法三件套:
① **等 pidfile 出现再 kill**(轮询最多 5s,显式断言 pidfile 确实出现了 —— 这也是 busy 断言的前提);
② 假脚本的 `sleep 120` → **`sleep 30`** 作兜底(万一 kill 仍打空,文件也会在 30s 内自然退出,< 60s 超时);
③ 加**文件级 `test.after` 兜底**:任何用例失败都不许把翻译子进程留在那里。
修后同一竞态注入下:**7/7 pass、4.05s**(不再超时)✓。

**windows V3(`not ok 177`)**:断言「精确 stem:不得连带 Attention Is All You Need / Attention2」失败,diff 里
全是 `...\t\Attention*.pdf`。根因:快照 key 是**绝对路径**,Windows 上是反斜杠,而过滤器
`/\/Attention([.-]|$)/` **写死了正斜杠** → 永不命中 → 本文件的产物没被过滤掉。改成按**分隔符无关的最后一段**
判断(`String(p).split(/[\\/]/).pop()`),并补三行**跨平台回归护栏**(win32 反斜杠形态必须被认出、
`Attention Is All You Need` / `Attention2` 不得被误过滤)——把「改回写死 '/'」这种变异在任何平台都判红。

**windows V5(`not ok 179`,`code: ENOENT`)**:`mkdir '...\\weird\x01name'` —— **不是**那条 409 期望值的问题
(该期望值在 macOS/ubuntu 上都绿,且依据是 `src/host.ts:688-698` 的**可选** confirmName 栅栏,结论不变、不改回)。
真实原因是 **Win32 路径层禁止 0x00-0x1F 落盘**,控制字符名的历史专题在那类文件系统上根本造不出来。改成
**实测文件系统能力**(`fs-probe.canStoreControlName()`,建在自己的子目录里并即时清理):能存就照原样跑,
不能存就记一条 diagnostic 跳过那半段,**其余断言(`.legacy` 历史专题、`.trash` 保留名、批量创建/删除对称、
畸形 JSON 4xx)在两种情况下都照跑**。

**windows V6(`not ok 180`)**:`chmodSync(DATA/ro, 0o555)` 想在 rename 阶段注入失败,但 **Windows 的 chmod
只映射「只读位」、不阻止 rename** → 删除真的成功了(200)→ 「移动失败必须报错」必红。改成按**实测能力**选注入
(`fs-probe.chmodBlocksRename()`):POSIX 上照旧走 chmod(保留「中途失败」这条真实路径,断言不变);chmod
挡不住 rename 的平台改用**平台中立**注入(把 `.trash` 写成普通文件 → bundle 的 mkdir 必然 ENOTDIR 失败),
并**新增** `code === 'trash-move-failed'` 断言(只加不减)。

**变异验证**(注入→跑→还原,diff 已核;本轮全在副本/备份里做,不用 git checkout):
| 变异 | 期望 | 实测 |
| V3 过滤器退回只认正斜杠 | 红 | **红**(`isAttention 必须认 win32 反斜杠绝对路径`)✓ |
| V5 探针强制 false(模拟 Win32 能力) | 绿(半段跳过、其余照跑) | **绿** ✓ |
| V6 探针强制 false(模拟 Windows chmod) | 绿(平台中立注入生效) | **绿** ✓ |
| V6 缺陷态:平台中立注入不发生 | 红 | **红**(删除假装成功)✓ |
| V4 复现 pidfile 竞态(macOS 根因) | 修后绿 | **绿**(4.05s,不再 60s 超时)✓ |

未放宽任何断言、未新增 `t.skip`(改的是「按实测能力选分支」,两种能力下断言都真跑);LF 全绿
444/423/0/21。
Veryyoung957 added a commit to Veryyoung957/dsh-paper-reader that referenced this pull request Oct 12, 2026
`docs/pr7-render-diagnosis.md`(t20)实测排序:P0 100% / P1 94.6% / P2 45%(构造)/ P3 0%。
三条都修了(P3 被诊断证伪 ⇒ **没动提示词**,见下)。每条都有可区分用例 + 变异验证。

**P0(100%,主因):vendored KaTeX 资源在子目录 ⇒ `/paper-reader/vendor/*` 路由只认扁平名 ⇒ 全 400**
路由是 `^[\w.-]+$`(不含 `/`)且只放行 `.mjs/.js/.css/.map`,而我 t18 把 KaTeX 放在
`reader/vendor/katex/…`、CSS 里还有相对 `url(fonts/…)` ⇒ 三条 URL 全 `400 bad asset name`
⇒ `window.katex` 永不存在 ⇒ **每个数学段都降级**(与模型、与公式内容无关)。
修法(诊断给的两条里选「拉平」,最安全:不动路由的路径校验、不新增 `..`/编码变形穿越面):
· `reader/vendor/katex/{katex.min.js,katex.min.css,fonts/*.woff2,LICENSE,NOTICE.md}`
  → **拉平**成 `reader/vendor/{katex.min.js,katex.min.css,KaTeX_*.woff2,katex.LICENSE,katex.NOTICE.md}`
  (`git mv`,24 个文件;CSS 里 60 处 `url(fonts/X.woff2)` 改写为同目录 `url(X.woff2)`)。
· `src/host.ts`:MIME 表补 `.woff2/.woff/.ttf`(不给对 MIME 会被浏览器拒);并修一处真 bug——
  原先**给所有类型**追加 `; charset=utf-8`(连 `font/woff2` 也加)⇒ 改成只有文本类才加 charset。
· **补上 t18 的盲区**:新增 `test/reader-vendor-http.test.mjs`,用发行产物 `dist/host.js` 起真 http
  server,把**页面引的**(含动态 import 的 pdf.js)与**CSS 里引的每个 woff2**逐个 GET,断言 200 +
  精确 MIME + 内容非空;另加穿越负向用例(`%2e%2e%2f`/`..%2f..%2f`/子目录 ⇒ 4xx,绝不泄漏)。
  守卫 R3 也加严:KaTeX 资源 URL 必须与路由白名单同形(带子目录即红)。
  变异:放回子目录 → 红;去掉 `.woff2` MIME → 红;二进制又加 charset → 红。

**P1(94.6%):展示公式被包成行内 `$…$`,而 `\tag{}` 只允许展示模式**
MinerU 的展示公式几乎都带 `\tag`(Dong 16/18、Hou 19/19),服务端 `assembleSource` 统一包成行内
⇒ 按 `displayMode:false` 渲染必报 `\tag works only in display equations`。
修法:新增 `resolveMathMode(tex, display)` —— 先按切分结果探测,**失败再按另一种模式探测**;
只有真的行内渲染不了才升级成块级(**零误伤**:普通行内公式不会变块级);两种都失败 → null → 降级。
元素形态(span/div + `.tr-math-block`)按**解析出的模式**建;降级仍按**源定界符**原样显示。
变异:去掉「换模式重试」→ K8(18 个真实 equation 的行内形态)与 K9 红。

**P2(45% 构造 / 0% 真实):切分只认 `$`/`$$`**
· 补 `\(…\)`(行内)/`\[…\]`(独立成行)两个分支(未闭合/不像数学则原样当文本);
· `$…$` 加「像不像数学」判据 `looksLikeMath()`(与 `src/translate-selection.ts` 的 `isLatexLikeFragment`
  同判据,另加两道收窄:全大写标识符、`NAME:…` 形态)⇒ **金额/代码里的 `$` 不再被吃掉**:
  `价格 $5 到 $10`、`export PATH=$HOME/bin:$PATH`、落单 `$`、中文标点里的 `$` 一律当文本。
· **不建议**自动识别裸 LaTeX(诊断也这么建议):误伤风险大于收益,未做。
变异:去掉判据 → K7 红;去掉 `\(…\)`/`\[…\]` → K2 红。

**P3(0/9,诊断证伪):模型改写公式** ⇒ **未改** `src/translate-selection.ts` 的提示词/`formulaLost`
(本环境实测逐字保留 9/9、`formulaLost` 0;唯一失败原因是 P1 的模式错配)。渲染层也没有为它"硬补"。

**硬约束保持**:文本段 `textContent`、数学段交 KaTeX 渲染进新建元素、`trust:false`/`throwOnError:false`/
`strict:'ignore'`;失败仍降级为原始 LaTeX + `#tr-note` 提示、绝不留白;守卫 R1/R2/R3/R4 只**加严**
(R2 扩展到 `resolveMathMode`、R3 增加 URL 形状断言),未放宽。

**测试**:新增 K7(反例,8 组)、K8(Dong 18 个真实 equation 的**行内**形态 18/18 渲染成功、带 `\tag`
的必须升级为块级)、K9(零误伤 + `\tag` 才升级)、`test/reader-vendor-http.test.mjs`(3 例真 HTTP)。
本机 LF:**461 用例 / 449 pass / 0 fail / 12 skip**(基线 455/443 ⇒ 只增不减),零新增 t.skip。
@Veryyoung957
Veryyoung957 marked this pull request as ready for review October 12, 2026 03:06
在阅读器里选中文字后于气泡中显示译文;选区含公式时,用 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 而非静默通过)。经独立验证与两轮对抗评审
(含变异测试:删掉任一护栏对应用例必须变红)通过。
这两份是整合轮独立验证(t3)的复现用例,此前一直留在工作区未跟踪,但
`node --test "test/*.test.mjs"` 会把它们计入总数——不提交会让「提交里的
测试」少于「实测数字」,文档中的计数随之失真。

它们补的是既有用例没覆盖的部分:
- merge-verify-t3:用真实 pdf.js(pdfjs-dist)对真实形状页面调
  getViewport({scale}),期望矩形由用例自己独立复算,覆盖**翻页模式 ×
  公式热区**这条全新渲染路径(既有用例用手写 viewport stub)。
- merge-verify-t3-library:快照是逐文件 sha256 内容级(库外哨兵与文献库
  都要字节级零变化),且对**规范化路由名**打同一套穿越矩阵——既有用例只
  覆盖别名。

两份都不依赖真实文献库或环境变量,离线可跑。
重排(`git rebase --onto origin/main(e966de0) ce2fbd6`,只重放本分支 2 个提交)后,main 带上了我们
最后几轮加的跨平台守卫,本功能的测试(写在守卫之前)被逐条抓出并**照 main 同款写法**修好:

- **C1**(动态 import 必须走 pathToFileURL):10 处裸路径 `await import(dist('…'))`
  (merge-verify-t3-library ×3、merge-verify-t3 ×1、selection-translate-verification ×2、
  selection-translate ×4)全部包成 `pathToFileURL(...).href`,并补齐 `node:url` 导入。
- **C2**(symlinkSync 必须显式传 type):merge-verify-t3-library 两处补 `'dir'`/`'file'`。
- **C5/C4**(禁止手写按 LF 写死的 `<script>` 提取):merge-verify-t3 与 selection-translate 改用
  `test/reader-script.mjs` 的 `normalizeEol` + `extractModuleScript`(Windows 检出是 CRLF,旧写法会
  `null[1]` 崩掉整个文件)。
- **C3 README 版本标签**(已存在的主守卫):从「硬编码 1.4.0」改成**以 package.json 为单一事实源**
  (不再每 bump 一次就要改测试;仍拦 README 落后于包版本的漂移)。README_EN 的 `Current version` 行
  同步到 v1.5.0(README.md 侧在重排冲突里已同步)。
- **V5 期望值对齐现行契约**(`merge-verify-t3-library`):`delete-topic` 的 `confirmName` 是**可选**栅栏
  (host.ts:688-698)——没给时照走前置,非空专题一律 409 `topic-not-empty`(我们的加固),旧写法期望的
  400 `confirm-mismatch` 是重排前的老契约;同时给三条被拒路径各补一条「一个文件都不能动」不变量。

冲突与解法(重排时):
- `package.json`:版本两侧(main 1.4.0 / 本分支 1.6.0)冲突 → 取 **1.5.0**(main 之上的一个 minor)。
- `README.md` / `README_EN.md`:维护者在 e966de0 按用户视角重构过 README,与本分支的旧结构冲突 →
  **以维护者的新结构为准**(保留 核心能力/安装/怎么用/进阶/文献管理细则/参与开发/架构/License 的分节与
  措辞),只把本功能的内容**插进**新结构:核心能力两条 bullet、新章节「划词翻译与中英分屏对照(v1.5.0)」
  (插在「文献管理细则」与「参与开发」之间)、架构清单补 `translate-selection.ts`、钉版示例同步到 1.5.0;
  版本号全部 → v1.5.0。
- `reader/index.html`、`src/host.ts` 自动合并无冲突。

本机(LF):typecheck 0 / build 0 / `npm test` = **444 用例 / 423 pass / 0 fail / 21 skip**
(重排前基线 385/373);未新增 t.skip;本功能既有断言未放宽(见上:只补不变量、只把老契约的期望值
按现行契约改正)。守卫 C1–C5 全绿。
CI run 38193897047:ubuntu ✅ / macOS ❌(1 条整文件)/ windows ❌(3 条)。逐条对着日志原文定位:

**macOS:`not ok 14 - test/merge-verify-t3-library.test.mjs` 是 `testTimeoutFailure`(60001ms),而且日志里该
文件的 V1b–V6 **全部 `ok`** ⇒ 不是用例失败,是**用例跑完后进程不退出**。根因:V4 里的假 babeldoc 是
「先写 pidfile 再 `exec sleep 120`」的后台子进程,测试结束时用 pidfile 里的 pid 去 SIGKILL —— 但
spawn→shell→落盘要几毫秒,**macOS 上这个竞态让 kill 打空**,`sleep` 子进程留下来把 event loop 撑到 60s 超时。
**本机复现**:把假脚本改成晚 0.5s 写 pidfile → 本机同样「7 个用例全 ok 但文件 60s 超时」
(`testTimeoutFailure`,与 CI 逐字一致)✓。修法三件套:
① **等 pidfile 出现再 kill**(轮询最多 5s,显式断言 pidfile 确实出现了 —— 这也是 busy 断言的前提);
② 假脚本的 `sleep 120` → **`sleep 30`** 作兜底(万一 kill 仍打空,文件也会在 30s 内自然退出,< 60s 超时);
③ 加**文件级 `test.after` 兜底**:任何用例失败都不许把翻译子进程留在那里。
修后同一竞态注入下:**7/7 pass、4.05s**(不再超时)✓。

**windows V3(`not ok 177`)**:断言「精确 stem:不得连带 Attention Is All You Need / Attention2」失败,diff 里
全是 `...\t\Attention*.pdf`。根因:快照 key 是**绝对路径**,Windows 上是反斜杠,而过滤器
`/\/Attention([.-]|$)/` **写死了正斜杠** → 永不命中 → 本文件的产物没被过滤掉。改成按**分隔符无关的最后一段**
判断(`String(p).split(/[\\/]/).pop()`),并补三行**跨平台回归护栏**(win32 反斜杠形态必须被认出、
`Attention Is All You Need` / `Attention2` 不得被误过滤)——把「改回写死 '/'」这种变异在任何平台都判红。

**windows V5(`not ok 179`,`code: ENOENT`)**:`mkdir '...\\weird\x01name'` —— **不是**那条 409 期望值的问题
(该期望值在 macOS/ubuntu 上都绿,且依据是 `src/host.ts:688-698` 的**可选** confirmName 栅栏,结论不变、不改回)。
真实原因是 **Win32 路径层禁止 0x00-0x1F 落盘**,控制字符名的历史专题在那类文件系统上根本造不出来。改成
**实测文件系统能力**(`fs-probe.canStoreControlName()`,建在自己的子目录里并即时清理):能存就照原样跑,
不能存就记一条 diagnostic 跳过那半段,**其余断言(`.legacy` 历史专题、`.trash` 保留名、批量创建/删除对称、
畸形 JSON 4xx)在两种情况下都照跑**。

**windows V6(`not ok 180`)**:`chmodSync(DATA/ro, 0o555)` 想在 rename 阶段注入失败,但 **Windows 的 chmod
只映射「只读位」、不阻止 rename** → 删除真的成功了(200)→ 「移动失败必须报错」必红。改成按**实测能力**选注入
(`fs-probe.chmodBlocksRename()`):POSIX 上照旧走 chmod(保留「中途失败」这条真实路径,断言不变);chmod
挡不住 rename 的平台改用**平台中立**注入(把 `.trash` 写成普通文件 → bundle 的 mkdir 必然 ENOTDIR 失败),
并**新增** `code === 'trash-move-failed'` 断言(只加不减)。

**变异验证**(注入→跑→还原,diff 已核;本轮全在副本/备份里做,不用 git checkout):
| 变异 | 期望 | 实测 |
| V3 过滤器退回只认正斜杠 | 红 | **红**(`isAttention 必须认 win32 反斜杠绝对路径`)✓ |
| V5 探针强制 false(模拟 Win32 能力) | 绿(半段跳过、其余照跑) | **绿** ✓ |
| V6 探针强制 false(模拟 Windows chmod) | 绿(平台中立注入生效) | **绿** ✓ |
| V6 缺陷态:平台中立注入不发生 | 红 | **红**(删除假装成功)✓ |
| V4 复现 pidfile 竞态(macOS 根因) | 修后绿 | **绿**(4.05s,不再 60s 超时)✓ |

未放宽任何断言、未新增 `t.skip`(改的是「按实测能力选分支」,两种能力下断言都真跑);LF 全绿
444/423/0/21。
用户实测:译文里本该保留的公式只显示成纯 LaTeX 文本。本提交让气泡把 `$…$` / `$$…$$` 渲染成数学。

**渲染与安全(两条硬约束)**
- 译文按 `$` 定界符切成「文本段 / 数学段」:**文本段一律 `textContent`**(模型输出不可信,
  `reader/index.html:1486` 那条「绝不 innerHTML」不许被推翻);数学段交给 KaTeX 渲染进**我们新建的**
  元素(`trust:false` → 不产原始 HTML、`strict:'ignore'`、`throwOnError:false`),KaTeX 自己建 DOM 节点,
  LaTeX 只作为**文本参数**传入。守卫 `test/reader-render-guard.test.mjs` R1 静态钉死渲染路径无 `innerHTML`
  (另有行为级证据:`trRenderBody` 期间 stub 上的 innerHTML 写入计数必须为 0)。
- **渲染失败必须降级**:先用 `renderToString` + `throwOnError:true` 探一次(非法 LaTeX 在这里抛),失败就把
  该段退回**原始 LaTeX 文本**(可读可复制、加 `.tr-math-fallback`),并在 `#tr-note` 如实提示
  「第 N 个公式未能渲染(已按原样显示 LaTeX)」;**绝不空白、绝不半个公式**。

**KaTeX 版本/来源/许可证**:0.16.22,`https://registry.npmjs.org/katex/-/katex-0.16.22.tgz`
(sha256 e9e0d167…),上游 https://github.com/KaTeX/KaTeX,MIT(全文随目录)。见
`reader/vendor/katex/NOTICE.md`(含版本、URL、校验值、包含/省略清单、更新方式)。
**为什么不算新增依赖**(可直接写进 PR):它是**随包分发的静态资源**,没有进入 `package.json` / lockfile,
不参与安装或解析,`node_modules` 与依赖集合都不变;选 vendored 而非 `npm i katex` 是因为论文伴读是
**离线场景** —— 读者页面不允许任何运行时 CDN 请求,渲染器与字体必须随插件一起分发、版本与字体一一对应。

**reader 资源体积(前后)**:`reader/` 1,825,573 B / 3 文件 → **2,393,760 B / 27 文件**(净增 568,187 B):
`vendor/katex/` 562,566 B(`katex.min.js` 276,701 + `katex.min.css` 23,352 + `fonts/*.woff2` ×20 = 259,792
+ LICENSE/NOTICE),`reader/index.html` 101,455 → 107,076 B(+5,621)。字体**刻意只带 woff2**(CSS 的
`@font-face` 首选源;Electron/Chromium 与现代浏览器都支持),省下 816,780 B(woff+ttf 全部格式 1,076,572 B)。

**测试**:新增 `test/katex-bubble-render.test.mjs`(7 例,独立自带最小 DOM stub,真实 KaTeX 现场加载):
· **K1 论文 18/18 equation 真渲染**:夹具逐字取自本机 Dong 那篇(Language-Conditioned World Modeling
  for Visual Navigation)`.mineru.json` 的**全部 18 个 equation 块**(含下标/上标/分式/`\left[…\right]`/
  `\mathbf`/`\hat`/`\tag`/`array` 矩阵),要求 0 失败、0 留白、无 `katex-error`;
· K2 切段器(行内/独立/多行/`\$` 字面美元号/未闭合/空公式/行内不跨行);K3 + K3b 降级(非法 LaTeX 显示
  原文 + 点名第几个失败;混合时只有失败的那个降级);K4 渲染参数契约(打桩 KaTeX 核对 trust/strict/
  throwOnError 两个方向)+ 两路都不写 innerHTML;K5 气泡零 button、`#tr-note` 纯文本;K6 离线资源自检。
新增 `test/reader-render-guard.test.mjs`(4 例静态守卫:R1 无 innerHTML、R2 参数契约+探测必须
throwOnError:true、R3 本地 vendored 且 CSS 引用的 woff2 全在、R4 NOTICE 版本与 JS 一致 + LICENSE 是 MIT)。
`test/selection-translate.test.mjs`:DOM stub 对齐真实语义(`textContent` 读=后代拼接/写=清空子节点、
`compatMode`、`createElementNS`、`createDocumentFragment` 展平、innerHTML 写入计数),harness 注入**真实
vendored KaTeX**,E11 的正文断言按新契约改写并**加严**(公式必须渲染成 `.tr-math`、KaTeX 必须真建了 DOM、
文本段逐字保留、渲染期间 innerHTML 写入为 0)。既有断言未放宽、未新增 t.skip。

**变异验证**(注入→跑→还原,命中用例已记录):删掉降级分支 → K3/K3b/K5 红;文本段改 `innerHTML` →
K3/K4/**R1**/R2 红;`trust:true` → K4/R2 红;探测改 `throwOnError:false`(降级空转)→ K3/K3b/K4/K5/R2 红;
拿掉 vendored CSS 引用 → R3 红。还原后 11/11 绿。

本机:typecheck 0 / build 0 / `npm test` 0 = **455 用例 / 443 pass / 0 fail / 12 skip**(+11 例)。
…6 唯一红点)

CI run 38195117216:ubuntu ✓ / macOS ✓ / **windows ✗**,全文只有 1 条 `not ok`:
`not ok 178 - V4 规范化路由名上的 409/503:运行中会话、无法确认的会话状态、翻译中`
(`ERR_ASSERTION` expected true / actual false),**日志原文**指向:

    error: '假 babeldoc 必须已启动并写下 pid(否则上面的 busy 断言无从谈起)'
    at test/merge-verify-t3-library.test.mjs:385

也就是说红点是**t17 为修 macOS 竞态而加的那条断言**在 Win32 上恒假 —— 不是 409/503 的语义错,
更不是 t17 预判的「进不了翻译中」:日志里 V4 前面的 `started.started === true` 与
`zhStatus(ref).busy === true` **都通过了**(Win32 上产品找不到引擎 → 停在自动安装阶段 → busy 为真)。

**根因(实测证实/证伪各一半)**:
· 证实:Win32 上产品按 venv 约定只找 `<dataDir>/../.venv-pdf2zh/Scripts/babeldoc.exe`(真实 PE),
  我们造的 `bin/babeldoc`(sh 脚本)既不在它的搜索位、也执行不了 ⇒ **永远不会有 pidfile、也不会有
  `sleep` 子进程**(这也正是 Win32 从未复现 macOS 那个 60s 超时的原因)。
· 证伪:不是「进不了翻译中」——busy 断言在 Win32 上是**过**的(产品处于「正在检查翻译引擎…」的
  自动安装阶段)。
至于**首选方案①(平台中立地注入「翻译中」)**:唯一的注入点 `findBabeldoc` 在 `src/translate.ts`,
而该文件携带用户未提交的 NO_PROXY 修复(本任务 Out-of-scope)⇒ 不给产品加 seam。

**修法(实测能力分支,等价断言,不跳过)**:
    const canRunFakeEngine = spawnSync(binPath, ['--version'], { encoding: 'utf8' }).status === 0
· `true`(POSIX):**原样保留** t17 的清理与断言(轮询 pidfile ≤5s → kill → 断言 pidfile 存在);
  macOS 竞态修复一字未动。
· `false`(Win32):记一条 diagnostic,改为验**同一条「翻译中」语义**:`zhStatus().busy` 此刻为真、
  **再次删除仍 409 `translation-busy`**、被拒之后 `busy` 仍为真(没被误清)、文献本体仍在。
  没有 t.skip,两平台验的是同一件事("翻译中被拒" 的四条路由断言两边都照跑)。

**变异验证(注入→跑→还原)**:
· **缺陷态复现**(模拟 Win32 能力 + 引擎不写 pidfile,退回 t17 的无条件断言)→ **红**,且错误文案与
  CI 日志**逐字一致**:`假 babeldoc 必须已启动并写下 pid(否则上面的 busy 断言无从谈起)` ✓
· 同一状态下跑本次修法 → **绿**(7/7,3.5s)✓
· 「引擎起完即退」(表面在跑、其实没进翻译中)→ else 分支的等价断言**变红**:
  `等价断言:被拒之后仍是「翻译中」(没被误清)` ✓ ⇒ 等价断言有载荷,不是摆设。
未放宽/删除任何既有断言(POSIX 期望值一字未改),未新增 t.skip。

本机:typecheck 0 / build 0 / `npm test` 0 = **455 用例 / 443 pass / 0 fail / 12 skip**。
部署后结构校验器(`DPR_EXPECT_VERSION=1.5.0 node .probe/deploy-check.mjs <stage>`)改前 **60 PASS / 2 FAIL**:

    FAIL  整合标记缺失:README_EN 公式引用章节(README_EN.md 里没有 /Click to cite a formula/)
    FAIL  zh/en 不对等:公式鼠标引用(zh=true en=false)

根因是 t16 重排时**只搬了内容、没搬特性命名**:英文 MinerU 章节的 bullet 以 `- **Formulas**:` 起头、
正文里只写了 "click a formula and its LaTeX is cited straight into the chat" —— 行为描述在,但校验器
(以及 zh 侧「**公式引用**」这个正式命名)要的那句没有 ⇒ 被判成「英文缺这一节」。

**改法(不改维护者的分节与次序,只动 MinerU 章节里那一条 bullet + 走查发现的一处小缺口)**:
· `README_EN.md`:该 bullet 改为 `- **Click to cite a formula**: …`,并按**实现实际行为**把点击语义写准
  ——`reader/index.html:1198-1220`:点公式只是把 LaTeX 放进 `askSelection{kind:'equation'}` 并
  `showAskPop()` **打开提问气泡**,由用户发送,**不是自动发问** ⇒ 英文改为
  "clicking a formula in the PDF puts its LaTeX into the ask bubble (send it and the formula is cited
  with your question)";顺带把面板名与实现对齐(`lib/client.js:187` 的 EN 面板名是 `Formulas`)。
· `README_EN.md` 第 4 步:`▣ toggles single-page mode` → `▣ toggles single-page mode ↔ scroll`
  (与 zh「▣ 单页翻页 ↔ 滚动」对齐;走查时发现的唯一其它措辞缺口,非功能缺口)。
· `README.md`:同一条 bullet 的点击语义与实现对齐(原文「点 PDF 上的公式即引用进对话提问」会被读成
  自动发送):改为「**点 PDF 上的公式会把该公式的 LaTeX 带进提问气泡**(发送即随问题引用进对话)」;
  引导词保持 `**公式引用**`(文档守卫 `C2` 要求同行同时出现「公式引用」与「LaTeX」,改回原名后守卫全绿)。

**验证**:改后校验器 **62 PASS / 0 FAIL**(两条 FAIL 消失:`整合标记在位:README_EN 公式引用章节` ✅ /
`zh/en 对等:公式鼠标引用` ✅);`npm run typecheck` 0 / `npm run build` 0 / `npm test` 0 =
**455 用例 / 443 pass / 0 fail / 12 skip**(总数不变,未新增/删除用例);文档守卫全绿(含 `C2 两份 README`、
`C3 README 版本标签与 package.json 同步`、`merge-readme-parity`)。

**全量对等性走查结论**(11 个章节逐节比对:行数、bullet/表格/代码块数量**全部一一相等**):
· 只有中文有:无**功能**缺口;仅「公式引用」这一处*正式命名*曾只存在于中文侧(本次已补齐英文的
  `Click to cite a formula`)。
· 只有英文有:无。
· 两边都有:三栏工作台 / 带页码伴读 / 教练式带读 / 中文版 / 文献管理(含批量清空)/ 划词翻译(含 5 行前提表)/
  中英分屏对照(含 3 行模式表)/ MinerU 解析(含 cloud·local 表 + 三步指引 + 实测声明)/ 嵌入语义检索
  (含隐私提示与非目标)/ 安装(桌面端 + CLI 两条路径 + 钉版)/ 怎么用 6 步 / 文献管理细则 6 条 /
  参与开发 / 架构清单(`translate-selection.ts` 两边都在)/ License。
`docs/pr7-render-diagnosis.md`(t20)实测排序:P0 100% / P1 94.6% / P2 45%(构造)/ P3 0%。
三条都修了(P3 被诊断证伪 ⇒ **没动提示词**,见下)。每条都有可区分用例 + 变异验证。

**P0(100%,主因):vendored KaTeX 资源在子目录 ⇒ `/paper-reader/vendor/*` 路由只认扁平名 ⇒ 全 400**
路由是 `^[\w.-]+$`(不含 `/`)且只放行 `.mjs/.js/.css/.map`,而我 t18 把 KaTeX 放在
`reader/vendor/katex/…`、CSS 里还有相对 `url(fonts/…)` ⇒ 三条 URL 全 `400 bad asset name`
⇒ `window.katex` 永不存在 ⇒ **每个数学段都降级**(与模型、与公式内容无关)。
修法(诊断给的两条里选「拉平」,最安全:不动路由的路径校验、不新增 `..`/编码变形穿越面):
· `reader/vendor/katex/{katex.min.js,katex.min.css,fonts/*.woff2,LICENSE,NOTICE.md}`
  → **拉平**成 `reader/vendor/{katex.min.js,katex.min.css,KaTeX_*.woff2,katex.LICENSE,katex.NOTICE.md}`
  (`git mv`,24 个文件;CSS 里 60 处 `url(fonts/X.woff2)` 改写为同目录 `url(X.woff2)`)。
· `src/host.ts`:MIME 表补 `.woff2/.woff/.ttf`(不给对 MIME 会被浏览器拒);并修一处真 bug——
  原先**给所有类型**追加 `; charset=utf-8`(连 `font/woff2` 也加)⇒ 改成只有文本类才加 charset。
· **补上 t18 的盲区**:新增 `test/reader-vendor-http.test.mjs`,用发行产物 `dist/host.js` 起真 http
  server,把**页面引的**(含动态 import 的 pdf.js)与**CSS 里引的每个 woff2**逐个 GET,断言 200 +
  精确 MIME + 内容非空;另加穿越负向用例(`%2e%2e%2f`/`..%2f..%2f`/子目录 ⇒ 4xx,绝不泄漏)。
  守卫 R3 也加严:KaTeX 资源 URL 必须与路由白名单同形(带子目录即红)。
  变异:放回子目录 → 红;去掉 `.woff2` MIME → 红;二进制又加 charset → 红。

**P1(94.6%):展示公式被包成行内 `$…$`,而 `\tag{}` 只允许展示模式**
MinerU 的展示公式几乎都带 `\tag`(Dong 16/18、Hou 19/19),服务端 `assembleSource` 统一包成行内
⇒ 按 `displayMode:false` 渲染必报 `\tag works only in display equations`。
修法:新增 `resolveMathMode(tex, display)` —— 先按切分结果探测,**失败再按另一种模式探测**;
只有真的行内渲染不了才升级成块级(**零误伤**:普通行内公式不会变块级);两种都失败 → null → 降级。
元素形态(span/div + `.tr-math-block`)按**解析出的模式**建;降级仍按**源定界符**原样显示。
变异:去掉「换模式重试」→ K8(18 个真实 equation 的行内形态)与 K9 红。

**P2(45% 构造 / 0% 真实):切分只认 `$`/`$$`**
· 补 `\(…\)`(行内)/`\[…\]`(独立成行)两个分支(未闭合/不像数学则原样当文本);
· `$…$` 加「像不像数学」判据 `looksLikeMath()`(与 `src/translate-selection.ts` 的 `isLatexLikeFragment`
  同判据,另加两道收窄:全大写标识符、`NAME:…` 形态)⇒ **金额/代码里的 `$` 不再被吃掉**:
  `价格 $5 到 $10`、`export PATH=$HOME/bin:$PATH`、落单 `$`、中文标点里的 `$` 一律当文本。
· **不建议**自动识别裸 LaTeX(诊断也这么建议):误伤风险大于收益,未做。
变异:去掉判据 → K7 红;去掉 `\(…\)`/`\[…\]` → K2 红。

**P3(0/9,诊断证伪):模型改写公式** ⇒ **未改** `src/translate-selection.ts` 的提示词/`formulaLost`
(本环境实测逐字保留 9/9、`formulaLost` 0;唯一失败原因是 P1 的模式错配)。渲染层也没有为它"硬补"。

**硬约束保持**:文本段 `textContent`、数学段交 KaTeX 渲染进新建元素、`trust:false`/`throwOnError:false`/
`strict:'ignore'`;失败仍降级为原始 LaTeX + `#tr-note` 提示、绝不留白;守卫 R1/R2/R3/R4 只**加严**
(R2 扩展到 `resolveMathMode`、R3 增加 URL 形状断言),未放宽。

**测试**:新增 K7(反例,8 组)、K8(Dong 18 个真实 equation 的**行内**形态 18/18 渲染成功、带 `\tag`
的必须升级为块级)、K9(零误伤 + `\tag` 才升级)、`test/reader-vendor-http.test.mjs`(3 例真 HTTP)。
本机 LF:**461 用例 / 449 pass / 0 fail / 12 skip**(基线 455/443 ⇒ 只增不减),零新增 t.skip。
用户已定:开关在**分屏控件与设置面板两处都放**、记忆范围**全局**、**默认开**。
只涉及模式 B(原文 ↔ -zh.pdf 双列);模式 A 用 babeldoc 的 -dual.pdf,
一个 PDF 里并排嵌好,没有两个滚动条,故不涉及(开关在其下不可见、点击早退、不写偏好)。

- 全局键 `dpr.syncScroll`('1'/'0',缺省=开):不带 topic/name、不在
  `dpr.variant.`/`dpr.sessionN.` 前缀内 ⇒ 改名搬家不会搬丢它。
- 两处入口靠同源 storage 事件双向同步,两侧都不回写(无回声)。
- 关掉 ⇒ 两列各自独立滚动;重新打开 ⇒ 对齐回当前页(基准=最后滚过的那一列,
  保留页内比例)。进分屏时的一次性对齐**刻意不看开关**(否则"关着同步进分屏"
  会直接停在两页上)。
- 修评审 F1:同步关着时重渲(#zoom-in/out、dppx 变化、resize→fitToWidth)
  会把两列一起拉到同页、丢掉中文列的阅读位置 ⇒ 改为清空前各自捕获锚点、
  仅在"位置可比"时各回各的;并把开关判定放进 rAF 回调体内,避免在飞回调再拉列。

回归:LF 与 CRLF 检出均 477 tests / 465 pass / 0 fail / 12 skip(+7,零新增 skip)。
@Veryyoung957
Veryyoung957 force-pushed the feat/selection-translate branch from 2250296 to fdc7054 Compare October 12, 2026 11:27
@Veryyoung957
Veryyoung957 merged commit 54b2a22 into GGboya:main Oct 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant