很多人以为 PDF 翻译是"把文字抽出来翻译再塞回去"。真要这么干,版式必崩。实际上,专业 PDF 翻译引擎的核心目标只有一个:不改任何坐标,只替换字符串。要做到这一点,得先理解 PDF 到底怎么存文字。
一、先搞懂:PDF 里的文字不是"文本",是"绘制指令"
PDF 不是 Word 那种"文字+样式"的结构化文档,它是内容流(Content Stream)+ 资源(字体/图片)+ 页面树。文字在 PDF 里是一串绘制操作符,比如:
BT % Begin Text
/F1 12 Tf % 设置字体 F1,字号 12
1 0 0 1 100 700 Tm % 文本矩阵:定位到 (100, 700)
(Hello World) Tj % 显示文字
ET % End Text
关键就在这条 Tm(文本矩阵):它用 6 个数 (a b c d e f) 定义了文字的绝对位置、缩放、旋转 。(Hello World) 只是个字符串参数。
所以"保版式"的本质动作极其简单:保持 Tm 等坐标指令一字不动,只把 Tj/TJ 里的源语言字符串替换成翻译后的字符串。 表格、图片、公式的位置全由坐标指令决定,文字字符串一换,它们天然"纹丝不动"。这就是为什么整篇机翻会乱------多数普通工具是"抽文本→重排版→生成新 PDF",坐标全丢了;而保版式工具走的是"原地改串"。
二、解析层:提取文字块 + 几何信息
翻译前要先"看懂"PDF。主流用 pdfminer.six(细粒度解析)或 PyMuPDF(fitz,速度快)。以 PyMuPDF 为例:
python
doc = fitz.open("paper.pdf")
page = doc[0]
blocks = page.get_text("dict")["blocks"]
# 每个 span 带: text, bbox(x0,y0,x1,y1), font, size, color
这一步拿到的是带 bbox 的文字块树(block → line → span)。bbox 就是后面回填时定位用的"锚点"。表格在 PDF 里本质是绝对坐标的线条 + 文字,解析层只要不碰线条绘制指令,只标记文字 span,回填时就只动文字。
三、回填层:字体与编码是真正的坑
字符串换好了,但还有两个雷:
- 字体覆盖 :源文档可能只嵌了 Latin 字体,目标语言(如中文、阿拉伯文)字形缺失,渲染出来是空白或方块。解决方式是映射目标字体 + 子集化(subsetting) ------只把用到的字形嵌入,生成
ABCDEE+MyFont这样的子集字体,并通过 ToUnicode CMap 把字符编码正确映射到 Unicode,保证翻译后还能被选中、搜索。 - 编码改写 :直接改 content stream 的
Tj字符串时,字符需按目标字体编码重写,否则显示错乱。
也就是说,回填不是"字符串替换"就完事,而是重建一段干净的文本绘制指令 ,沿用原 Tm 坐标,换字体引用和字符串。
四、长语言缩放适配:用字宽度量动态压缩
德文、俄文、芬兰文往往比英文长 30%~50%。直接替换,文字会溢出单元格或重叠。工程做法是先量再缩:
python
w_src = sum(font.char_widths[ch] for ch in src) * size # 源文字渲染宽度
w_dst = sum(font.char_widths[ch] for ch in dst) * size # 翻译后宽度
if w_dst > w_src * 0.95:
scale = (w_src * 0.95) / w_dst
# 方案 A:等比缩小字号(改 Tf 的 size)
# 方案 B:用 TJ 数组插入负字距(kerning)压缩间距
# 方案 C:换行重排(仅在允许时)
核心依据是字体度量(font metrics / glyph widths) ------每个字形在 1/1000 em 下的标准宽度。算出渲染宽度后,要么缩小字号,要么用 TJ 操作符在字符间插入负间距做字距压缩,把总长压回原单元格。这就是"换字不换版"还能不挤的核心算法。
五、扫描件 OCR:从位图里"捞"出文字和坐标
上面的前提是 PDF 有可选文字。如果是扫描件 (文字是图片像素),content stream 里根本没有 Tj 字符串,只有一张图。这时候要上 OCR:
- 预处理:二值化、降噪、倾斜校正(deskew),提升识别率;
- 版面分析(Layout Analysis):用检测模型(DB / EAST / PSENet,或 LayoutLM 这类深度模型)把页面切成文本块、表格区、图片区,并给出每个文字块的 bbox;
- 文字识别:传统+LSTM 的 Tesseract,或 PaddleOCR(DB 检测 + CRNN 识别)、EasyOCR。识别网络本质是 CNN 提取特征 → RNN/Transformer 建模序列 → CTC/Attention 解出字符。
OCR 出来的不是纯文本,而是**"文字 + 坐标"**对。有了坐标,后面就能复用第三、四节的回填逻辑------只不过源"字符串"是从图片里识别来的。
⚠️ 注意:OCR 精度天花板取决于原图清晰度。模糊扫描件,再强的模型也救不回 100%。
六、图片内文字的 AI 抹除与重绘
最难的是图片里的文字也要翻译(带字幕的图表、漫画、海报)。这不是改 PDF 指令能解决的------文字是像素。完整管线是:
1. 文字检测(Text Detection):DB/EAST 框出图片里文字区域 → 得到 mask
2. OCR 识别:读出原文字
3. 翻译:得到目标语言文本
4. 图像修复(Inpainting / 抹除):
用修复模型,以 mask 为遮罩,把原文字区域"擦掉"并补全背景纹理。
传统用 PatchMatch / 扩散,现代用 LaMa(傅里叶卷积)或
Stable Diffusion Inpainting(潜空间扩散 + mask 引导)。
输出 = 一张"干净、无字"的背景图。
5. 重绘(Render):在干净背景上,用目标语言字体把翻译文字
渲染回原位置(对齐原文本框,必要时等比缩放字号)。
开源里的 manga-image-translator 就是这套管线的代表:检测 → OCR → 翻译 → inpainting → 重绘,一气呵成。"AI 抹除再生成新图"说的就是第 4 步的 inpainting------它不是 PS 橡皮擦,而是用生成模型"脑补"出被文字遮挡的背景,让结果看起来像图片本来就没有那行字。
工程上更轻量的做法(多数 PDF 翻译器采用):不真改图片,而是在原图上方叠加一个透明文字层,把翻译文字渲染上去、原文字用白色块盖住。好处是快、可逆;坏处是放大后能看到"盖戳"痕迹。真·inpainting 重绘更干净但更慢、更吃算力。
七、工程架构总览
┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ 解析层 │ → │ 翻译层 │ → │ 渲染层 │ → │ 图像层 │
│ pdfminer/ │ │ DeepL/ │ │ 重写 │ │ OCR + │
│ PyMuPDF │ │ 大模型API │ │ content │ │ Inpainting │
│ 提取 bbox+ │ │ 文本块 │ │ stream │ │ 检测/识别/ │
│ 文字树 │ │ 级翻译 │ │ 保留坐标 │ │ 抹除/重绘 │
└─────────────┘ └──────────┘ └──────────┘ └──────────────┘
坐标锚点 语义对齐 换字不换版 图片文字兜底
四层各司其职:解析层负责"不动",翻译层负责"准",渲染层负责"缩",图像层负责"图里的字"。漏掉任何一层,都会出现你见过的格式灾难。
八、落到现实:一个已产品化的实现长什么样
理论讲完,看一个真实封装好的在线服务更直观------pdftranslator.org 这类"保版式 PDF 翻译"产品,底层跑的正是上面这四层管线,可以拿它来反向验证本文的判断标准:
- 翻译完表格不动、图片位置不跑 → 说明渲染层做的是原地改写 content stream(第三节),而非抽文本重排;
- 德文/俄文等长语言翻译后不溢出 → 说明渲染层接入了字宽度量 + 动态缩放(第四节);
- 扫描件也能翻 → 说明图像层前置了 OCR 管线(第五节);
- 图片内文字能直接出翻译图 → 对应的是 检测 → inpainting 抹除 → 重绘 链路(第六节)。
也就是说,一款翻译器翻完版式纹丝不动,就证明它在"换字不换坐标"而不是重排版;而它把四层管线封装成"上传即翻"的在线服务,恰好是本文架构图从论文到工程的一次落地。对一个想读懂 PDF 翻译引擎的人,这种已产品化的实现是最好的对照样本。
九、总结
PDF 翻译的"换字不换版"不是玄学,而是一套精密工程:
- 保版式 = 改
Tj字符串、保Tm坐标; - 不溢出 = 字体度量算宽度、动态缩字号/压字距;
- 扫描件 = OCR 把像素变"文字+坐标";
- 图内字 = 检测→识别→翻译→inpainting 抹除→重绘。
没有银弹。源文档越复杂、扫描越糊,天花板越低。但理解了这套链路,你就能判断一个工具到底是在"原地改串"还是"抽文本重排"------前者才是不乱的根。