Skip to content

流畅阅读(FluentRead)的文档翻译是怎么实现的

Published: at 08:00 AM

翻译一段话很容易,翻译一份 PDF 很难。

难不在调模型,而在翻完之后文件还是不是那个文件:Markdown 的标题层级还在不在,字幕的时间轴对不对得上,Word 的页眉页脚有没有被当成正文一起翻掉,PDF 的译文能不能落在原文那个位置上。

流畅阅读(FluentRead) 是个浏览器翻译扩展,它的文档翻译模块用大约 102KB、7 个文件,覆盖了 13 种扩展名、11 种格式。读完这一块代码,最值得记下来的不是它支持了多少格式,而是它把这件事收敛成了一个极其干净的模型。

1. 这个模块长什么样

FluentRead 是按功能切片的,src/features/ 下面有 19 个 feature:划词翻译、全文翻译、图片翻译、视频字幕、术语表、写作助手…… document-translation 是其中之一 2

它内部严格分三层,边界写在每个文件的头部注释里:

文件职责明确不做
coredocument.ts (28KB)纯领域模型、文本格式解析、无损回填不读 File、不碰二进制、不发请求
corepreview.ts (11KB)生成安全的预览 HTML不操作真实 DOM
servicesbinary.ts (32KB)PDF/ePub/DOCX 解析与导出不调翻译服务
servicestranslation.ts (10KB)批量翻译编排不解析文件、不绑 provider
uipdfPreview.ts (14KB)Canvas 光栅化不决定片段与文件结构
uipresentation.ts (5.5KB)纯展示派生规则不建 DOM、不解析
public.ts (1.3KB)汇总公共 API不暴露内部私有函数

值得注意的是 public.ts 的注释写得很直白:调用方应当通过公共契约注入翻译器和 PDF rasterizer,不要绕过 services 层直接耦合 JSZip、pdf-lib 或 pdfjs。这种”防君子也防小人”的边界声明,在一个只有 7 个文件的模块里算得上克制。

提醒一句:如果你之前 clone 过 FluentRead,注意本地那份可能是 fork(dalian-ai/FluentRead)的旧版 0.0.28 17,那时还没有 src/ 目录(是 WXT 的 entrypoints/ 结构 15),跟上游 main 已经不是一个代码库了。

2. 转轴:两条流,而不是一个数组

整个设计的支点,是 core/document.ts 里的 ParsedDocument

export interface ParsedDocument {
  parts: readonly DocumentPart[]; // 原文骨架
  segments: readonly DocumentSegment[]; // 待翻译片段
  // ...
}

type DocumentPart = LiteralPart | SegmentPart;

interface LiteralPart {
  kind: "literal";
  value: string; // 原样输出
}

interface SegmentPart {
  kind: "segment";
  segmentIndex: number; // 指向 segments[]
  source: string;
  prefix: string; // 前导空白
  suffix: string; // 尾随空白
}

parts 是原文的完整骨架,按出现顺序排列;literal 承载一切不该翻译的东西——HTML 标签、字幕时间戳、ASS 的 Dialogue: 前缀、Markdown 围栏里的代码、行尾的换行符。segment 只是一个占位符,真正的文本在 segments[] 里。

回填时遍历 parts

if (part.kind === "literal") {
  output.push(part.value);
  continue;
}
const translation = translations[part.segmentIndex] ?? part.source;

就这么简单。“保留原格式”不是一个需要为每种格式单独实现的功能,而是这个数据结构的自然结果。 格式之间的差异,被压缩成了”怎么切”这一件事。

翻译结果按 segmentIndex 落位,所以整条链路对”翻译服务”是无知的——它只看到一个 string[]

3. 切分:11 种格式,各自的讲究

格式怎么切为什么这么切
TXT按行
Markdown按行,但代码围栏整体跳过;行内代码、图片、链接、裸 URL 保护为 literal让受保护的语法保持原位不被拆散
HTML手写 tokenizer,标签整体保护见下
SRT / VTT整条 cue 作为一个翻译单元时间戳不送去翻译,模型才能保留 <i> 这类行内标签
ASS数到第 9 个逗号才是对白正文ASS 的 Dialogue 行是逗号分隔的定长字段
LRC时间标签存为 bilingualPrefix双语模式下第二行重复时间轴,产物仍是可播放的 LRC
JSON递归只取字符串叶节点,记下 JSON path键名、数字、布尔、嵌套结构都不动

几个值得单独说的:

HTML 的 tokenizer 是手写的。 没用 DOMParser,因为这里要的是”字符串级切分”而不是 DOM 树。findNextHtmlToken() 里有个细节:扫描标签时会跟踪引号状态,href="a > b" 里的 > 不会被误判成标签结束。head/script/style/pre/code/textarea 六个标签被整体保护——翻译 <script> 里的内容显然是灾难。

Markdown 有个 bilingualGroup 一行文字里如果夹了行内代码或链接,切分后会变成多个 part。双语模式下如果各自插入译文,一行引用会被拆得七零八落。所以给每个 part 打上行号 bilingualGroup,渲染时按源行重新聚合,输出一条完整的双语行。

JSON 走的是完全不同的路径。 它不产生 parts,而是产生 jsonEntries(path + segmentIndex),回填时在深拷贝setAtPath,原始对象始终不变。双语模式下塞进同一个字符串:

`${entry.prefix}${original.trim()}\n${translation}${entry.suffix}`;

字幕的”整条 cue 作为单元”是个有取舍的决定。 好处是模型能看到完整上下文、能保留行内标签;代价是一段长对白无法再切细,会占满一批的字符预算。

4. 二进制三件套

PDF、ePub、DOCX 交给 services/binary.ts,产出统一塞进 ParsedDocument.binary(一个可辨识联合)。

4.1 PDF:从字符原子重建段落

pdf.js 12getTextContent() 给出的是一堆字符原子(每个字或片段带自己的坐标变换矩阵),不是文本行。FluentRead 用三级几何聚类把它还原成段落:

  1. atom → line:按 y 坐标排序,同行的判定是 |Δy| ≤ max(2, 行高×0.42, 原子高×0.42)。同时丢掉旋转角 > 0.12 弧度的文本(竖排/斜排直接放弃)。合成行时还会按间距补空格——但如果前一个原子以连字符结尾、下一个以小写字母开头,就不补空格直接拼接,处理英文换行断词:
if (/[-‐‑]$/u.test(value) && /^[a-z]/u.test(line.text))
  return `${value.slice(0, -1)}${line.text}`;
  1. line → block:这是最费笔墨的一段。判定两块是否属于同一段落,综合了间距、水平重叠率、左边缘对齐、字号比、以及”上一行以句号结尾且当前行明显缩进”(新段落)。标题行(高度 ≥ 正文行高中位数 × 1.32)不参与合并。

  2. 每个 block 记下 x/y/宽/高/字号/行高中位数/行数/字体/字重/对齐方式——这套坐标就是后面重绘 PDF 的唯一依据

4.2 ePub:zip + OPF spine

JSZip 14 打开 → 校验 mimetype 必须是 application/epub+zip → 读 META-INF/container.xml 拿 OPF 路径 → 解析 OPF 的 manifest(id → path)与 spine(itemref 顺序)→ 按 spine 顺序逐章读取,每章当作 HTML 丢给 parseDocument()

有 fallback:如果 spine 里找不到 XHTML,就按 manifest 顺序兜底。章节标题从 <title> 里取,取不到用”第 N 章”。

4.3 DOCX:按 <w:p> 切段

const DOCX_PARAGRAPH_PATTERN = /<w:p\b[^>]*>[\s\S]*?<\/w:p>/gu;

扫描 word/(document|header\d+|footer\d+|footnotes|endnotes).xml,按段落切。每段还会识别角色

if (/header/iu.test(path)) return "header";
if (/(?:footnotes|endnotes)/iu.test(path)) return "note";
const style = paragraph.match(/<w:pStyle\b[^>]*\bw:val="([^"]+)"/iu)?.[1] || "";
if (/title/iu.test(style)) return "title";
if (/heading|标题/iu.test(style)) return "heading";
if (/<w:numPr\b/iu.test(paragraph)) return "list-item";
return "paragraph";

页眉页脚、脚注尾注被单独分区,UI 上可以分开校对——这是很多翻译工具会偷懒的地方(直接把页眉当正文翻一遍)。

5. PDF 是最难的那个:不是替换文字,是重画一页

文本类格式可以”把译文塞回原位”,PDF 不行——它里面通常没有可以按 offset 替换的文本对象。FluentRead 的解法很硬:把整页光栅化,擦掉原文,按原坐标重新画一遍译文(实现见 ui/pdfPreview.ts)。

四步:

① 光栅化原页。 用 pdf.js 渲染到 canvas,缩放取 clamp(1.45, 1440/页宽, 2.4)

② 采样取色。 这一步是全篇最有意思的地方。要在别人的版式上写字,得先知道背景是什么色、原文是什么色:

这套”中位数 + 距离阈值 + 取最强分位”的组合,效果就是自动还原原文墨色——深色背景的 PDF 也不会糊成一团黑。

③ 擦除原文块。 用背景色 fillRect 把文字块盖掉(外扩一点 padding)。注释里写明了为什么要先擦完所有块再统一绘制

// 步骤 2:先统一擦除全部原文字块,避免重叠块把已绘制的译文再次遮住。

④ 按坐标写译文。 clip() 裁到原块矩形内再 fillText,这样多栏排版、图文混排都不会串位。字号自适应是个收缩循环:

while (fontSize >= 3.5) {
  context.font = `${block.fontWeight} ${fontSize}px ${family}`;
  lines = wrapCanvasText(context, translation, maxWidth);
  if (lines.length * lineHeight <= maxHeight * 1.02) break;
  fontSize -= Math.max(0.35, fontSize * 0.045); // 每次缩 4.5%
}

中文字体按原块 fontFamily 里有没有 serif 切两套(Noto Serif CJK SC / Noto Sans CJK SC)。

最后是导出:pdf-lib 13 新建 PDF,双语模式下一页放两幅图——左边 embedPage 原页、右边 embedPng 译页,中间留 clamp(8, 页宽×2.5%, 24) 的间隙。

代价要说清楚:产出的 PDF 是位图,文字不可选中、不可搜索、放大到一定程度会糊。这是”PDF 里没有可编辑文本”这个前提决定的,不是实现偷懒。另外扫描版 PDF(无文本层)会直接报错,明确不做 OCR

6. 翻译编排:只认接口,不认服务商

services/translation.ts 定义了 DocumentTranslationGateway

export interface DocumentTranslationGateway {
    getGlossaryOptions?(): {...};
    waitUntilReady(): PromiseLike<unknown> | unknown;
    getDefaultService(): string;
    supportsBatch(service: string): boolean;
    translateText(source, context, options): Promise<string>;
    translateTextBatch(sources, context, options): Promise<string[]>;
}

整个目录里没有任何一处 import 具体的翻译 provider。 翻译服务、术语表、用户设置全由上层注入。好处不只是可测试——FluentRead 有十几个翻译服务(划词、全文、字幕都在用),文档翻译只要注入同一个客户端就能全部复用。

调度上的几个决定:

失败处理有个细节:Promise.all 首个 worker 失败就 reject,但其余在途请求还会稍后结束。所以用一个 stopped 标志位——失败后不再上报过期进度、也不再认领新片段,避免”已经报错了进度条还在涨”。

7. 「可续读」:这次重构真正改了什么

9 月 5 日的提交 feat(document): redesign translation as a resumable reading workspace 9 把文档翻译从”一次性动作”改成了”可长期维护的阅读工作区”。代码里对应两处,都很小但很关键。

其一,initialTranslations —— 空白驱动。

segments.forEach(({ id }) => {
  translations[id] = options.initialTranslations?.[id] || "";
});
const pending = segments.filter(({ id }) => !translations[id].trim());

已有译文直接落位(包括用户手工校订过的),pending 只装空白的。你在校订区改过的段落,重跑时不会被覆盖。 这让”翻到一半关掉,明天接着翻”和”只重翻我没改过的那几段”都成立。

其二,createDocumentFileLoadGuard() —— 代次而非 abort。

PDF/ePub 的解析是 async 循环,没有真正干净的中止点。与其假装能取消,不如用代次计数器:

let generation = 0;
return {
  begin() {
    const requestGeneration = ++generation;
    return { isCurrent: () => requestGeneration === generation };
  },
  invalidate() {
    generation += 1;
  },
};

新文件打开或页面重置时代次 +1,旧解析仍可自然跑完(不浪费、不泄漏),但 isCurrent() 返回 false,不允许把状态写进新文档。这是个很实用的模式:凡是你无法真正中止的异步流程,都可以用”所有权代次”代替取消。

另外 8 月底还有两个提交值得一提:0652882 10 审计重构回归并清理死代码,bf32cef 11 给源文件补了职责注释——所以现在每个文件头都有那段”文件职责 / 主要内容 / 模块边界”,读起来省很多事。

8. 回填与导出:每种格式的双语策略

同一个 renderDocument(document, translations, mode) 入口,modebilingualtranslated,各格式的表现不同:

格式双语模式纯译模式
HTML原文 + <br> + <span data-fluent-read-document-translation> 译文直接替换(escape 后)
Markdown原文行 + 下一行 > 译文(引用块)替换
ASS原文 + \N + 译文(ASS 的换行码)替换
JSON同一字符串内 原文\n译文替换
ePub逐章重新渲染写回 zip
DOCX原段后追加一个粉色译文段落#E83B6B原地替换 <w:t>
PDF左右并排两页单页译版

DOCX 的双语策略特意选了”追加段落”而不是”段内追加文字”——这样原文段落的所有格式属性(w:pPr、编号、样式)都不用碰,代价是多一个段落。

ePub 导出有个容易踩的规范细节:mimetype 必须用 STORE(不压缩)且是 zip 的第一个条目 16,否则很多阅读器不认:

zip.file("mimetype", "application/epub+zip", { compression: "STORE" });

9. 安全与限额

10. 能抄走的四个设计

不是抄代码,是抄判断。

一、把”不可变结构”和”可变内容”分成两条流。 一旦这么分,“保留原格式”就不用为每种格式单独实现了。任何”处理完还得还原”的任务——代码格式化后保留注释、模板渲染后保留手写修改、i18n 提取后回填——都是同一个形状。

二、feature 只依赖注入的接口,不依赖具体实现。 文档翻译模块不知道自己用的是 Google 还是 DeepL,只知道有个 translateTextBatch。这让 11 种格式的复杂逻辑可以脱离网络、脱离扩展环境单测。

三、无法真正中止的操作,用所有权代次代替取消。 强行 abort 一个 async 循环只会留下半初始化状态。让旧任务自然跑完但不许它提交,是更省心的做法。

四、在别人的版式上写字,先采样再擦除。 “取中位数当背景、取最强分位当前景、先擦完再统一画”这三步,是”往不可编辑的画布上写字”这类问题的通用解法。


最后说回开头那句话。翻译一段话容易,翻译一份 PDF 难——难在你要为每种格式回答同一个问题:哪些东西不能动

FluentRead 的回答是:先把不能动的全挑出来放进 literal,剩下的才叫”内容”。

参考