前言
做 PDF 翻译预处理时,英语文档基本不出幺蛾子,但一到西班牙语、法语、德语文档,各种"灵异现象"就来了:é 变 é、office 变 office、整段文字变成 (cid:123) 编号。这些问题的根源都指向同一个环节------PDF 的字符编码与 Unicode 映射。本文用本机实测把三个最常见的坑复现一遍,每个坑给出可直接复制的解决方案。环境:Python 3.13,pymupdf 1.28.2 / pdfplumber 0.11.10 / pypdf 6.18.0,测试用 fpdf2 2.8.8 构造。
先把背景知识补齐,三个坑其实是同一个机制的三种故障表现。PDF 里的文字本质上不是字符串,而是"字形编号 + 映射表":文件里存的是字形引用(glyph id),靠一张叫 ToUnicode CMap 的映射表翻译回 Unicode。映射表完整,任何库都能抽出正确文本;映射表缺失或被上游程序错误处理,就会分别表现成 mojibake、连字分裂、cid 编号三种症状。理解了这个机制,排查思路就清晰了------看现象定位是"映射表坏了"还是"字节流被二次误解码了",前者换 OCR,后者修编码。
坑一:重音字符抽取后变乱码(mojibake)
现象与复现
先构造一份西/法/德三语的 PDF(含 ñ、é、à、ç、ü、ß、ä、ö 等典型字符):
python
from fpdf import FPDF
pdf = FPDF()
pdf.add_page()
pdf.set_font("helvetica", size=12)
pdf.multi_cell(0, 6, "El proveedor garantiza la confidencialidad ... Cataluña y el diseño.")
pdf.multi_cell(0, 6, "Veuillez debrancher l'equipement ... sécurité, où que vous soyez en France.")
pdf.multi_cell(0, 6, "Dieses Handbuch beschreibt ... Größe und Breite des Geräts.")
pdf.output("test_multilingual.pdf")
然后用三个库分别抽取,统计关键重音字符的出现次数、检测典型乱码片段:
python
import pymupdf, pdfplumber
from pypdf import PdfReader
PDF = "test_multilingual.pdf"
def accent_check(text):
keys = ["ñ", "é", "à", "ç", "ü", "ß", "ä", "ö"]
hits = {k: text.count(k) for k in keys if k in text}
broken = [f for f in ("Ã", "â€", "\ufffd") if f in text]
return hits, broken
mu = "\n".join(p.get_text() for p in pymupdf.open(PDF))
with pdfplumber.open(PDF) as pl:
plt = "\n".join(p.extract_text() or "" for p in pl.pages)
pp = "\n".join((pg.extract_text() or "") for pg in PdfReader(PDF).pages)
for name, t in (("pymupdf", mu), ("pdfplumber", plt), ("pypdf", pp)):
print(name, accent_check(t))
实测结果
pymupdf accents={'à': 1, 'ü': 2, 'ß': 2, 'ö': 2, 'é': 2, 'ä': 6, 'ñ': 2} broken=无
pdfplumber accents={'à': 1, 'ü': 2, 'ß': 2, 'ö': 2, 'é': 2, 'ä': 6, 'ñ': 2} broken=无
pypdf accents={'à': 1, 'ü': 2, 'ß': 2, 'ö': 2, 'é': 2, 'ä': 6, 'ñ': 2} broken=无
结论:三库对正常编码的西/法/德字符抽取全部无损 。所以如果你的文本出现 diseño 这种乱码,问题大概率不在抽取库,而在管道中的某一步把字节流按错误编码解码了。
这个结论做小语种处理时特别有用:它意味着你不需要为"乱码"而换库、升级版本或者到处找"更好用的 PDF 库"------先把管道里的编码转换环节捋一遍,问题多半出在 open(..., encoding=...) 这种看似不起眼的地方。另一个实用技巧是拿德语文档当"重灾区测试件":ß、ä、ö、ü 在错误编码下的变形特征最明显(ß 经常变成 ß),比英语文档敏感得多。
修复方案
diseño 是典型的 UTF-8 字节被按 Latin-1 解读的双重编码乱码。修复方向相反:把字符串先按 Latin-1 编回字节,再按 UTF-8 解出:
python
raw = "diseño"
fixed = raw.encode("latin-1").decode("utf-8")
print(fixed) # diseño
实测有效。批量处理时用 try/except 兜底------只有真正被双重编码的片段才能走通这条转换,正常文本会抛异常,原样保留即可:
python
def fix_mojibake(s: str) -> str:
try:
return s.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
return s # 不是双重编码,原样返回
这个方案的安全性好在不依赖任何第三方库:双重编码是可逆变换,修不对就抛异常回退原文,不存在"越修越乱"的风险。相比之下,用 ftfy 之类的库当然更省心,但引入依赖前值得先评估自家管道里 mojibake 的实际发生频率------如果只在处理历史遗留文档时偶发,几行标准库代码就够了。
注意判断依据:à 后跟 ©/±/± 类字符、†开头的三连序列,都是双重编码的指纹。
坑二:连字(ligature)导致搜索和比对失效
现象与复现
排版引擎常把 fi、fl 渲染成单个连字字符 fi(U+FB01)、fl(U+FB02)。用 Arial TTF 生成含连字的 PDF 后抽取:
python
from fpdf import FPDF
import pymupdf, unicodedata
pdf = FPDF()
pdf.add_page()
pdf.add_font("arial", "", r"C:\Windows\Fonts\arial.ttf")
pdf.set_font("arial", size=12)
pdf.cell(0, 10, "fiyat efficiency file flow office")
pdf.output("test_ligature.pdf")
text = pymupdf.open("test_ligature.pdf")[0].get_text()
print(repr(text.strip()))
实测结果
抽取结果: 'fiyat efficiency file flow office'
NFKC 归一化后: 'fiyat efficiency file flow office'
file 抽出来是 file 而不是 file------这时 text.count("file") 找不到目标,翻译内存比对、术语替换全部静默失效,而且不报任何错,这是它最阴险的地方。
修复方案
一行 Unicode 兼容分解(NFKC)把连字还原成普通字母序列:
python
import unicodedata
clean = unicodedata.normalize("NFKC", text)
建议把它做成翻译预处理管道里的固定步骤,对所有抽取文本无差别执行(NFKC 对正常文本无副作用)。
坑三:(cid:xxx) 乱码------字体子集没有 ToUnicode 映射
现象
抽取出来整段是 (cid:3)(cid:45)(cid:12)... 这种编号。我此前实测过一份真实文档:提取出 481 个 BT/ET 文本块,其中大量内容全是 (cid:N) 编号,文本层完全不可读。
根因
PDF 内嵌字体如果是子集嵌入且没有写入 ToUnicode CMap,抽取库只能拿到字形编号(glyph id),没有任何字典告诉它这个编号对应哪个 Unicode 字符------库里存的本来就是编号,不是文字。
处理方案(按优先级)
- 换 OCR 路线兜底:pymupdf/pypdfium2 把页面渲染成图片,交给 OCR(如 RapidOCR)重建文本层。我此前实测 RapidOCR + pypdfium2 组合做不可见文本层回填,词级召回能到 91% 以上,是唯一可靠的兜底。
- 放弃文本抽取,直接整版翻译:如果最终目的是翻译,根本不需要抽文本------用保留版面的整版翻译工具(解析版面后按原排版重排译文),从源头绕开编码问题。
- 尝试 cid 映射表:对常见商业字体有公开的 cid→Unicode 表,但覆盖率不稳定,不建议在流水线里依赖。
把三个检查做成流水线里的"编码体检"
与其出了问题再排查,不如把上面三个坑的检测固化成抽取后必跑的一段脚本。核心思路是拿小语种特殊字符当探针------西法德文档里 ñ/é/ü/ß 这类字符出现频率高、且绝不应该丢失,比"肉眼看乱码"可靠得多:
python
# -*- coding: utf-8 -*-
"""encoding_check.py - PDF 抽取文本的编码体检,翻译预处理管道必跑"""
import unicodedata, re
BROKEN_FINGERPRINTS = ("Ã", "â€", "\ufffd", "\x00")
def encoding_check(text: str, page_no: int = 0) -> dict:
"""对抽取文本做三项体检,返回报告"""
report = {"page": page_no, "ok": True}
# 1. 双重编码乱码指纹
moji = [f for f in BROKEN_FINGERPRINTS if f in text]
if moji:
report["ok"] = False
report["mojibake"] = moji
# 2. cid 乱码检测
cid_hits = len(re.findall(r"\(cid:\d+\)", text))
if cid_hits:
report["ok"] = False
report["cid_count"] = cid_hits
# 3. 连字统计(不修复,只记录;NFKC 统一在清洗阶段做)
lig = sum(text.count(c) for c in "\ufb01\ufb02\ufb00\ufb03\ufb04\ufb06")
if lig:
report["ligatures"] = lig
return report
def clean_text(text: str) -> str:
"""体检通过后的标准清洗:NFKC 归一化 + mojibake 修复"""
text = unicodedata.normalize("NFKC", text)
try:
return text.encode("latin-1").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
return text
流水线里的调用顺序:
抽取文本 → encoding_check() 分流:
├─ ok=True → clean_text() → 送翻译
├─ 有 mojibake → clean_text() 修复后复检 → 复检不过则人工介入
├─ 有 cid 乱码 → 整页转 OCR 路线
└─ 有连字 → 照常 clean_text()(NFKC 已处理)
总结
| 坑 | 指纹 | 一键修复 |
|---|---|---|
| 双重编码乱码 | é、†|
s.encode("latin-1").decode("utf-8") |
| 连字失效 | fi fl(U+FB01/02) |
unicodedata.normalize("NFKC", s) |
| (cid:xx) 乱码 | 文本全是 (cid:N) |
OCR 兜底 / 整版翻译 |
三个坑的共同教训:翻译预处理管道里,抽取完成后先跑一遍"编码体检"(重音字符计数 + NFKC 归一化 + cid 检测),再送翻译。小语种文档尤其如此------西法德的特殊字符就是最灵敏的编码探针。
标签:PDF翻译、Python、字符编码、OCR、pymupdf