Python 处理小语种 PDF 的编码坑:重音字符、连字与 (cid:xx) 乱码的解决方案

前言

做 PDF 翻译预处理时,英语文档基本不出幺蛾子,但一到西班牙语、法语、德语文档,各种"灵异现象"就来了:ééofficeoffice、整段文字变成 (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)导致搜索和比对失效

现象与复现

排版引擎常把 fifl 渲染成单个连字字符 (U+FB01)、(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 字符------库里存的本来就是编号,不是文字。

处理方案(按优先级)

  1. 换 OCR 路线兜底:pymupdf/pypdfium2 把页面渲染成图片,交给 OCR(如 RapidOCR)重建文本层。我此前实测 RapidOCR + pypdfium2 组合做不可见文本层回填,词级召回能到 91% 以上,是唯一可靠的兜底。
  2. 放弃文本抽取,直接整版翻译:如果最终目的是翻译,根本不需要抽文本------用保留版面的整版翻译工具(解析版面后按原排版重排译文),从源头绕开编码问题。
  3. 尝试 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")
连字失效 (U+FB01/02) unicodedata.normalize("NFKC", s)
(cid:xx) 乱码 文本全是 (cid:N) OCR 兜底 / 整版翻译

三个坑的共同教训:翻译预处理管道里,抽取完成后先跑一遍"编码体检"(重音字符计数 + NFKC 归一化 + cid 检测),再送翻译。小语种文档尤其如此------西法德的特殊字符就是最灵敏的编码探针。

标签:PDF翻译、Python、字符编码、OCR、pymupdf

相关推荐
量化吞吐机1 小时前
会写代码之后,量化学习还要补上交易判断
人工智能·python
lie..2 小时前
30天从零开始学AI应用开发(Day 1):机器学习、深度学习、大模型,到底是个啥关系
人工智能·python·大模型
Mininglamp_27182 小时前
WebRetriever技术架构:视觉特征与DOM结构融合的网页元素定位方案
ai·agent·web
Ivanqhz2 小时前
Unigram 算法
开发语言·人工智能·python·深度学习·mlir
冯一川2 小时前
Python 实现与 Ollama 运行的模型对话
人工智能·python
车间溜盖子2 小时前
TE(TEC 半导体制冷片)Qc / Qh 冷热面基础定义
python
天赐范式2 小时前
天赐范式第168天:让子代开始从父代肩膀上演化——跨代β传递demo与完整代码
python·数字生命·天赐范式·动态运行时·跨代β传递·可继承性验证·digitallife
刘天远2 小时前
Agent系统接入编排:评分模型、状态机与Python门禁
数据库·人工智能·python
myaifas2 小时前
如何选择智能体可视化设计的平台
人工智能·ai·ai编程