分类: pdf

  • PDF 繁转简,为什么我们最后选了「组合方案」

    把一份港股中报里的繁体字批量改成简体,听起来像是「查找替换」那么简单。真做起来才发现,PDF 这种格式对「改字」这件事极不友好:文字的字形、位置、字距、图片、字体编码分散在好几层结构里,动一处很容易牵连一片。

    这篇文章想把我们踩过的坑和最终决策讲清楚:三条技术路线各自的长短,为什么单独任何一条都不够,以及为什么最后收敛到一个「分情况处理 + 统一校验」的组合方案。

    1. 先理解 PDF 里「一个字」是怎么画出来的

    要看懂后面的取舍,得先建立一个基本认知:PDF 里我们看到的一个汉字,其实是三样东西配合的结果。

    • 内容流(content stream):一串绘图指令,类似「在坐标 (x, y) 用当前字体画第 0x27F3 号字形」。它管的是顺序、位置、字距、排版方向。
    • 内嵌字体:一张「字形表」,告诉渲染器第 0x27F3 号字形长什么样(轮廓曲线)。
    • ToUnicode 映射:一张「翻译表」,告诉复制粘贴/搜索时,第 0x27F3 号字形对应的是哪个 Unicode 字符(比如 U+5831「報」)。

    打个比方,内容流像一份「按号码点菜的菜单」——它只写「上 27 号菜」,至于 27 号是什么菜,得去翻字体这本菜谱。理解了这三层各管什么,就能看清:改一个字,到底该动哪一层。

    2. 方案 A:把替换字当成新文字贴上去

    这是我们最先做出来、也是现在跑通的方案。思路很直接:先用 redaction(涂改)把原来的繁体字形从页面上删掉,再用高层 API(TextWriter)把简体字当作一段全新的文字对象,追加到内容流末尾。

    它最大的好处是「实现简单、可控性强」:

    • 全程用 PyMuPDF 的高层 API,不用碰 PDF 底层语法。
    • 新字体我们自己嵌、自己控制编码,不受原字体缺 cmap / 缺 ToUnicode 的影响。
    • 校验能力最强。因为原字被真删、新字 1:1 补上,我们可以做「渲染像素 + 字符多重集 + 图片指纹」三重门禁。实测下来字符总数逐页守恒(269746 个进、269746 个出),这种强校验正是 A 的结构天然带来的。

    但也正是「另起炉灶贴新字」这个结构,带来了一串绕不开的毛病:

    • 复制出来的文字是乱序的——这是 A 最要命的硬伤。因为新字是「贴」在内容流末尾的,页面上看,字都在原来的位置、排得整整齐齐,人眼读起来完全没问题;可一旦框选复制,拿到的文本层顺序全乱了。也就是说,视觉正确和文本层正确在 A 这里被拆成了两回事——看着对,复制出来却是一堆错位的字。开了 sort=True 也只能救回「字的先后大致对,但词被切断」的程度,救不回真正的阅读顺序。对一份要拿去做检索、摘录、喂给下游程序解析的 PDF 来说,这个缺陷几乎是致命的。
    • 字体风格会「飘」。替换字用的是 Noto,和原文的 HYQiHei 是两套完全不同的字体。它们并排在一起时,粗细、结构、笔画风格明显不一致——原本统一的一段话,被换过的那几个字会显得格格不入,像是从别处剪贴过来的。整页看下去,字体风格是飘忽的,观感明显打折。
    • 体积膨胀。内容流涨了 63%(889KB → 1447KB),再加上多嵌 3 个 Noto 字体子集,又多了 436KB。
    • redaction 有连带杀伤,而且都是我们实测踩过的坑:全角标点的字框会压进邻字(后来用 _safe_redact_rect 做避让)、竖排文本几何偏移会删错格(后来改成直接跳过)、还有 4 份中报「莫名少了 1 张图」这个问题至今没解决。
    • 原字体删不掉。没被改写的字还在用原字体,于是新旧两套字体都得留着。

    一句话概括 A:两个硬伤最扎眼——复制出来的文字乱序(看着对、复制乱)、字体风格飘,再加上体积涨、redaction 会误伤;但胜在实现简单、校验最硬。

    3. 方案 B:直接改内容流里的绘图指令

    既然贴新字会打乱顺序,那能不能就地把内容流里那条「画第 0x27F3 号字形」的指令,改成「画简体那个字形」?这就是方案 B。

    它的优点几乎是 A 的镜像:

    • 顺序、位置、字距天然正确,因为我们没有新增任何文字对象。
    • 不做 redaction,所以不碰图片、不碰矢量图元。
    • 体积几乎不变。

    但这条路比看上去难得多,核心障碍是一个绕不开的事实:子集化的原字体里,根本没有目标简体字的字形。港股报告的内嵌字体是子集化的,只含用到的那些繁体字,「报」这个简体字形压根不在里面。所以「换个编码指向另一个字形」这条捷径走不通——要么改字体、要么把这个字切换到我们新引入的字体资源上。

    而一旦要切字体资源,麻烦就层层叠加:

    • 得重排定位。要把那个字从 TJ 数组里单独拆出来,前面插 Tf 切字体、Tj 画字,再用 Td / TJ 位移把基线接续回去。
    • 还得补偿横向压缩。港股表格里的中文是横向压缩排版的(实测压缩比中位数 0.894 / 0.837),切字体后还得用 Tz 或额外位移去还原。
    • 要自己写一个内容流 tokenizer,并正确处理字符串转义、TJ 数组、内联图像(BI/ID/EI)、Form XObject(文字常常在 XObject 里而不在页面内容流里)、以及多页共享的 XObject。
    • 出错是静默的。错了不会报错,而是排版悄悄错乱——字挤在一起、跑出单元格。这比 A 的「顺序乱但字都对」难发现得多。而且渲染必然变化,A 那套像素门禁在这里直接失效。

    一句话概括 B:排版天然正确、体积不变,但要在 PDF 语法级别手写解析和重排,复杂度高、错误还难以察觉。

    4. 方案 C:只换字体里的字形,内容流一个字都不动

    顺着「三层结构」再想一层:如果我们不动内容流(它只是说「在这里画第 0x27F3 号字形」),而是去改字体这本菜谱——把内嵌字体里 0x27F3 号(報)的轮廓,换成「报」的轮廓,同时把 ToUnicode 里这个 CID 的映射改到 U+62A5(报)——会怎么样?

    结果是:页面上原地就把繁体显示成了简体,而内容流完全没被碰过。

    这带来一串很漂亮的性质:

    • 顺序、位置、字距、横向压缩、竖排、旋转文本全部天然保持。因为这些信息都在内容流里,而内容流没动。
    • 不需要 redaction,不会误删邻字,不会丢图——4 份中报「丢图」的门禁失败也就随之消失了。
    • 不新增字体、不新增文字对象,体积几乎不变,甚至可能略减。
    • 工作量有界且可度量。实测共 1143 个字形轮廓,覆盖 99.98% 的待改写字符。
    • 机制已经跑通。这次我们已经验证了「改内嵌字体 + 注入 CMap」这套机制(remap_font_by_gid + update_stream),而且它和之前做的 ToUnicode 修复是同一套底层机制,可以复用。

    当然它也有代价:

    • 要做轮廓格式转换。Noto CJK 是 CFF 曲线,而 HYQiHei 用的是 glyf 二次曲线,需要用 cu2qu + TTGlyphPen 做转换;还要对齐 em box、基线和推进宽度(好在 CJK 全角字宽都是 1000,这一项风险很低)。
    • 字形是全局替换。某个 GID 一旦被替换,它在文档里的所有出现都会变成简体。在这份文档里这恰好是我们想要的,因为映射是纯函数;但换一份文档,如果繁简转换在不同上下文里给同一个字形不同的简体结果,C 就表达不了——所以必须先做冲突检测。
    • 有 14 个字改不了。它们用的 MSungHK / MHeiHK 是未内嵌字体(占 0.02%),字形不在文档里,没法改。
    • 渲染像素必然变化(本来就该变),A 那套像素门禁失效。校验得换成「字符多重集 + 逐字基线坐标不变 + 抽样目视」。不过「基线坐标逐字不变」其实是比 A 更强的校验——内容流没改,坐标就必须一字不差,一比对就知道有没有出问题。

    一句话概括 C:排版天然完美、体积不变、风险可控,但要做字体轮廓转换,且依赖「映射是纯函数」这个前提。

    5. 三条路摆在一起看

    把三条路线的关键维度并排,长短就很清楚了。

    • 文本层顺序:A 破坏;B 正确;C 正确。
    • 位置 / 字距 / 压缩:A 需要用 morph 近似还原;B 需要手动重算位移;C 天然不变。
    • 竖排 / 旋转文本:A 已知踩坑、现在直接跳过;B 需要专门处理;C 天然支持。
    • 图片 / 矢量图风险:A 有(实测丢过图);B 无;C 无。
    • 体积:A 涨 8%~37%;B 几乎不变;C 几乎不变。
    • 实现复杂度:A 低(已完成);B 高(PDF 语法级);C 中(字体轮廓级)。
    • 主要风险:A 是顺序乱、redaction 误删;B 是静默排版错乱;C 是轮廓转换精度和全局替换。
    • 覆盖率:A 100%;B 100%;C 99.98%(未内嵌字体除外)。

    看这张对照,一个很自然的结论浮出来:C 在「排版正确性、体积、风险」上几乎全面占优,唯一的短板是那 0.02% 的未内嵌字体它够不着。而 B 恰好能补上这个短板——对未内嵌字体,切字体资源本来就是必须的。

    6. 为什么最终是「组合方案」

    单看任何一条都不完美:

    • A 的顺序破坏和丢图,是结构性缺陷,补不回来。
    • B 全量用的话,复杂度和静默错乱风险太高,不值得为 99.98% 的字都去手写内容流重排。
    • C 覆盖不了未内嵌字体那 0.02%。

    于是我们让它们各自去做最擅长的事,组成一条流水线:

    1. 繁 → 简转换:用 OpenCC 按上下文算出每个字该变成什么(为什么要按上下文,下一节详说)。
    2. GID 映射冲突检测:确认「同一个字形在文档里所有出现处,是否都指向同一个简体字」。只有满足这个条件,方案 C 的「全局替换」才安全。这一步是 C 能不能用的前提闸门,细节见下一节。
    3. 三级分流:
    4. 第一优先 ★ 方案 C:内嵌字体、且该 GID 目标唯一的字,改字形轮廓、内容流不动。这是主力,吃下绝大多数。
    5. 第二优先 方案 B:该 GID 目标不唯一(一繁对多简),或字体未内嵌的字,逐位置切字体改写。这是补漏。
    6. 第三优先 保留繁体并报警:上下文提取失败、OpenCC 无法确定、字体结构异常等情况,宁可不转也不乱转,并记录下来人工复核。
    7. 统一校验:用「字符多重集守恒 + 逐字基线坐标不变 + 抽样目视」把几条分支的结果一起验收。

    这个组合的精妙之处在于:用一次冲突检测,把绝大多数字送上那条「排版天然正确、还不丢图」的路(C),只把够不着或有歧义的少数字交给复杂但必要的 B,再用一条保守兜底守住正确性的下限。我们既拿到了 C 的排版正确性和体积优势,又靠 B 补齐了覆盖率,还顺手甩掉了 A 的丢图顽疾。

    7. 方案 C 最隐蔽的坑:繁简不是一对一

    前面反复提到方案 C 的短板是「字形被全局替换」。这句话听起来抽象,但它背后藏着一个极容易被忽略、又足以让 C 出错的事实:

    繁体转简体,并不总是字符级的一对一映射,存在「一个繁体字对应多个简体字」的情况。

    也就是说,同一个繁体字,在不同词里可能要变成不同的简体字。举几个典型例子:

    • 「乾」:在「乾燥、乾净」里要变成「干」,但在「乾隆」里仍然是「乾」——不能转。
    • 「發 / 髮」:「發展、發生」的「發」变「发」,「頭髮」的「髮」也变「发」,是两个繁体字合并到一个简体。
    • 「後 / 后」:「後来、後面」的「後」变「后」,而「皇后」的「后」本来就是「后」。
    • 「臺 / 台」:「臺灣、臺北」的「臺」变「台」,「台阶、舞台」的「台」也是「台」。
    • 「鐘 / 鍾」:「鐘表」变「钟」,而「鍾姓、鍾馗」的「鍾」也变「钟」。

    其中最能说明问题的是「乾」:

    • 乾燥 → 干燥
    • 乾净 → 干净
    • 乾隆 → 乾隆

    所以「乾 → 干」并不是一个无条件成立的字符映射,它取决于所在的词。好在 OpenCC 本身就在解决这件事——它是按词典、按上下文转换的,convert("乾隆") 会正确地保留「乾隆」,而不是傻乎乎地把「乾」全换成「干」。

    为什么这对方案 C 是致命的

    问题在于:OpenCC 是按「字符+上下文」给答案的,而方案 C 是按「GID」动手的,这两个粒度对不上。

    假设 PDF 里同时有「乾燥」和「乾隆」,而字体里恰好:

    • GID 0x123 = 乾

    如果方案 C 直接做「把 GID 0x123 的轮廓换成『干』」,结果就是:

    • 乾燥 → 干燥 ✓
    • 乾隆 → 干隆 ✗

    因为同一个 GID 被全局替换了,它管不了「这一处该转、那一处不该转」。这正是「字形被全局替换」这句话真正的杀伤力所在。

    所以冲突检测的粒度必须是 (GID, 上下文)

    这也是为什么我们强调:不要只做 GID → Unicode,而要做 GID → 出现位置 → 上下文 → OpenCC → 目标 Unicode

    具体地说,对每一个 GID:

    1. 找到它在 PDF 中的所有出现位置。
    2. 提取每个出现处的上下文(前后字)。
    3. 用 OpenCC 按上下文判断各自的目标字。
    4. 把这些目标字聚合起来,看是不是只有一个。

    举个例子,GID 0x123(乾)可能得到:

    • page 1:乾燥 → 干
    • page 5:乾隆 → 乾
    • page 8:乾燥 → 干

    聚合后目标集合是 {干, 乾},有两个结果。于是判定:这个 GID 不能用方案 C 全局替换

    换句话说,C 的「纯函数」前提要重新定义——不是验证 GID → Unicode 唯一,而是验证:

    对某个 GID 的所有出现,经 OpenCC 按上下文转换后,目标 Unicode 完全相同。

    只有满足这个条件的 GID,才能安全地提升成 GID → 新字形,交给方案 C。像「報 → 报」「發 → 发」「經 → 经」「濟 → 济」这类目标唯一的字,就是 C 的主场;而「乾」这种目标不唯一的,就得退给 B 逐位置处理。这也解释了为什么 B 在这里不可替代——B 是逐字符、逐位置改写的,天然能表达「同一个繁体字,这里转、那里不转」。

    一句话抓住重点

    OpenCC 和方案 C 解决的是两个不同的问题,必须分开看:

    • OpenCC 解决的是:「这个具体语境应该变成什么字?」
    • 方案 C 的约束是:「同一个 GID 能不能被全局改成同一个东西?」

    前者是语言学问题,后者是工程约束。真正决定一个字走 C 还是走 B 的,是后者——这个 GID 的所有出现,OpenCC 之后是不是只有一个目标字符

    也正因如此,真正值得重点统计的其实是两个「小集合」:一是未内嵌字体的字符,二是 GID 目标不唯一的字符。从当前文档 99.98% 内嵌、仅 14 个未内嵌字的情况看,只要第二个集合也足够小,「C 主、B 兜底」就是一个相当稳的工程方案。

    8. 从这次决策里能提炼的经验

    回头看,这次选型的思路其实可以复用到很多「改造既有格式/数据」的场景:

    • 先搞清楚「这个东西是由哪几层拼出来的」。PDF 的三层结构一旦看透,「该动哪一层代价最小」这个问题就自己有了答案——动内容流(B)、动字体(C)、还是另起炉灶贴新的(A),对应完全不同的代价曲线。
    • 校验能力本身就是方案的一部分。A 之所以一度让人安心,是因为它能做像素门禁;而 C 换来了一个更强的校验维度——「内容流没动,基线坐标就必须一字不变」。选方案时,别只看功能,也要看它给不给得起一个硬校验。
    • 不必执着于「一个方案打天下」。当 99.98% 和 0.02% 的处理代价差异巨大时,分流是更划算的工程决策。关键是找到那个便宜的「主力方案」,以及一个能守住边界的「前提检测」(这里就是 GID 冲突检测)。

    最终这套组合,本质上是让每条路线只在它成本最低、风险最小的区间里工作,再用一道统一校验把它们缝合起来。

    如果觉得这篇文章对你有帮助,欢迎点赞、收藏加关注。后续持续分享更多有价值的内容。你的支持是我创作的最大动力!