pypdf 中的 PDF CMap 解码机制:从 codespacerange 到 bfchar/bfrange 的文本提取原理

发布时间:2026/9/15 18:12:05
pypdf 中的 PDF CMap 解码机制:从 codespacerange 到 bfchar/bfrange 的文本提取原理 pypdf 中的 PDF CMap 解码机制从 codespacerange 到 bfchar/bfrange 的文本提取原理【免费下载链接】pypdfA pure-python PDF library capable of splitting, merging, cropping, and transforming the pages of PDF files项目地址: https://gitcode.com/GitHub_Trending/py/pypdf导读PDF 文本提取的核心难点在于把内容流里的字节码翻译成真正的 Unicode 字符而这一翻译规则正是由CMapCharacter Map字符映射表描述的。本文以 pypdf 仓库自带的示例文档 docs/dev/cmaps.md 为骨架结合 pypdf/_cmap.py、pypdf/_font.py 与 tests/test_cmap.py 的源码实现逐步拆解 CMap 中的begincodespacerange、beginbfchar、beginbfrange等关键指令的语法与含义。读完本文你将能够读懂任何 PDF 中的 ToUnicode CMap 数据理解 pypdf 内部是如何解析它们、如何用预定义 CMap 表处理中日韩编码以及它在面对超大/畸形 CMap 时如何做安全防护。CMap 是什么PDF 字符映射的翻译字典CMapCharacter Map是 PDF 规范中用于描述字符代码Character Code→ 字符标识符CID/ Unicode 码点映射关系的数据结构。在包含非 ASCII 文本尤其是东亚文字、连字、符号字体的 PDF 中内容流里写入的往往不是 UTF-8 文本而是经过字体编码的字节序列。没有 CMap这些字节只是一串无意义的数据有了 CMappypdf 才能把每个字节码还原成正确的字符。pypdf 的文本提取流水线中字体对象正是通过 pypdf/_cmap.py 中的get_encoding()拿到编码 字符映射两件套再交给 pypdf/_text_extraction/_text_extractor.py 逐字符翻译的。用 pdftk 解压观察真实 CMap原文档使用仓库自带的 resources/crazyones.pdf 作为观察对象该文件也是 tests/test_cmap.py 中多处测试的输入文件。PDF 默认会对对象流做压缩直接查看是乱码先用pdftk解压pdftk crazyones.pdf output crazyones-uncomp.pdf uncompress解压后即可在文件流中看到如下一段典型的 CMap 定义begincmap /CMapName /T1Encoding-UTF16 def /CMapType 2 def /CIDSystemInfo /Registry (Adobe) /Ordering (UCS) /Supplement 0 def 1 begincodespacerange 00 FF endcodespacerange 1 beginbfchar 1B FB00 endbfchar endcmap CMapName currentdict /CMap defineresource pop这段 CMap 声明了映射表名为T1Encoding-UTF16、类型为 2即以 UTF-16 为目标的字符映射、注册信息为 Adobe UCS 体系。随后是核心的三段指令begincodespacerange、beginbfchar、beginbfrange。codespacerange界定字节码的有效区间原文档明确指出codespacerange 把一个完整的字节序列映射到一段 Unicode 字形区间它本质上定义了 CMap 所覆盖的源字节码取值范围。看上面的例子1 begincodespacerange 00 FF endcodespacerange这一行表示源字节码从00到FF即单字节 0–255 的全部取值都在映射范围内。注意begincodespacerange与beginbfchar/beginbfrange是两套不同的指令。codespacerange 声明合法输入域bfchar/bfrange 才真正给出码点到字符的具体对应关系。原文档中以beginbfchar开头的片段为例1 beginbfchar 1B FB001B是十六进制记法换算成十进制是 27它被映射到 Unicode 码点FB00——即小写字母 f 的连字 ffUFB00LATIN SMALL LIGATURE FF。这意味着内容流中一旦出现字节1Bpypdf 就会把它翻译成ff。codespacerange 中给出的00与FF两个数其含义是起始偏移映射从偏移量 0即1B ➜ FB00一直延续到偏移量 FF十进制 255因此1B FF 282十进制对应 Unicode 码点FBFF。也就是说这条 CMap 实际覆盖的字符区间是 UFB00 到 UFBFF 这 256 个码点。pypdf 如何记录 codespacerange在源码层面pypdf 并不会为 codespacerange 单独建一张表而是把它编码进解析结果中。看 pypdf/_cmap.py 中parse_bfrange()的实现当解析到一条bfrange行时会用map_dict[-1] (nbi 1) // 2记录当前源字节码的字节长度一个十六进制字符占半个字节后续所有解码都依赖这个长度信息。例如 tests/test_cmap.py 中的test_parse_bfrange__multibyte_source_codesparse_bfrange(lineb0041 0043 0061, map_dictmap_dict, int_entryint_entry, multiline_rgNone) assert map_dict {-1: 2, A: a, B: b, C: c}0041..0043是两字节码map_dict[-1] 2被依次映射为A/B/C ➜ a/b/c而20 22 0061这类单字节源码则走charmap分支解码。这正是 codespacerange 字节宽度在实现层面的体现。bfchar 与 bfrange逐条映射与区间映射CMap 的映射数据由两类指令承载beginbfchar ... endbfchar逐条列出单个源码 → 单个目标码的映射。每行形如src dst例如1B FB00。beginbfrange ... endbfrange批量声明一段连续源码的映射有两种写法不带列表a b c表示源码a..b依次映射到c..c(b-a)带列表a b [ d1 d2 ... ]为区间内的每个源码显式指定目标。对应到 pypdf/_cmap.pyparse_bfchar(line, map_dict, int_entry)把beginbfchar区块内的每一行拆成两两一组源码经unhexlify转字节后按字节长度解码为键目标十六进制串解码为 Unicode 字符串存入map_dict同时把源码整数值追加进int_entry列表供后续修正编码使用。parse_bfrange(line, map_dict, int_entry, multiline_rg)处理上面说的两种 bfrange 写法并支持多行续接状态——当bfrange的目标是[ ... ]列表且列表跨越多行时用multiline_rg元组暂存尚未闭合的(当前码, 剩余数量)下一行继续填充。pypdf 的解析入口process_cm_line()是一个典型的状态机遇到beginbfrange置process_rgTrue遇到beginbfchar置process_charTrue遇到对应的end指令复位处于哪个状态就调用哪个解析函数。同时它对畸形数据有容错ValueError/IndexError会被捕获并记为警告Skipping broken line ...而不是让整个文本提取崩溃tests/test_cmap.py 的test_parse_to_unicode_skips_truncated_lines专门验证了这一点。内容流中的八进制转义从字节到字符的最后一公里CMap 定义了映射规则真正要翻译的数据则躺在页面内容流里。原文档给出了该 PDF 内容流中的一行(The)-342(mis\034ts.)其中\034是八进制转义代表十进制 28。结合前面codespacerange 00 FF的声明覆盖 0–255 全部单字节内容流中的\034会作为一个源字节码交给 CMap 查表最终被翻译成 UFB00 区间内的对应字符。这里体现了 PDF 的经典约定内容流中非 ASCII 字符以\加八进制数字的形式书写而 CMap 中的码点则一律以xx十六进制形式书写解析时需要先完成八进制 → 字节 → 十六进制码 → Unicode 的层层换算。当 PDF 没有 ToUnicode CMap 时pypdf 的兜底策略并非所有 PDF 都自带ToUnicodeCMap。pypdf 在 pypdf/_cmap.py 的_parse_to_unicode()中做了多层兜底预定义 CMap 表_predefined_cmap把 PDF 规范1.7 附录 H中的标准 CMap 名映射到 Python 标准编解码器例如/Identity-H→utf-16-be、/GB-EUC-H→gbk、/90ms-RKSJ-H→cp932日文 Shift-JIS等。复合字体Type0只声明/Encoding为这些名字而无 ToUnicode 时就靠这张表直接解码。该映射关系在_parse_encoding()中通过elif enc in _predefined_cmap: encoding _predefined_cmap[enc]生效。嵌入字体文件对于 Type1 字体且无/ToUnicode时_parse_to_unicode()会尝试从/FontFileType1 程序或/FontFile3Type1C/CFF需安装 fontTools中提取编码信息_character_map_from_type1_font_file()解析dup code /glyphname put条目_character_map_from_cff_type1_font_file()用 fontTools 读取 CFF 的 Encoding 表再经_glyph_name_to_unicode()把 Adobe 字形名如/uniXXXX翻译成 Unicode。标准编码兜底连嵌入字体也没有时回退到/StandardEncoding见_parse_encoding()的 fallback 分支。同时get_encoding()还实现了 PDF 参考手册 1.7 §5.9.1 的规则只要 CMap 非空字体字典中的/Encoding就对这些字符失效即字符码 ≤ 255 的部分被替换为恒等映射避免双重映射造成乱码。安全防护pypdf 为 CMap 解析设置的上限CMap 数据来自不可信的 PDF 文件恶意构造的超大bfrange例如beginbfrange 00000000 001FFFFF 00000000若被逐条展开会造成内存耗尽。pypdf 在 pypdf/_cmap.py 中定义了如下硬性限制MAPPING_DICTIONARY_SIZE_LIMIT 100_000映射表条目数上限超限抛出LimitReachedError定义于 pypdf/errors.pyMAX_CMAP_CODE_BYTES 8即 16 个十六进制字符单个源码的最大字节数MAX_CMAP_STRING_BYTES 512即 1024 个十六进制字符单个目标字符串的最大长度。_check_mapping_size()与_check_token_length()在parse_bfrange/parse_bfchar的循环中反复校验。例如 tests/test_cmap.py 的test_parse_bfrange__iteration_limit构造了一个声称覆盖 2,097,152 个码点的 bfrangepypdf 直接以Maximum /ToUnicode size limit reached: 2097152 100000.拒绝解析test_parse_bfrange__entry_size_limit则验证了超长 token 的拦截。写入侧pypdf 如何生成自己的 ToUnicode CMapCMap 解析不止用于读取pypdf 在把字体资源写回 PDF 时也会生成ToUnicode CMap。见 pypdf/_font.py 的_create_widths_list_and_unicode_stream()Type0复合字体使用CMapName /Adobe-Identity-UCS、codespacerange0000 FFFF、源码 4 位十六进制简单字体使用Custom-Simple-8Bit、codespacerange00 FF、源码 2 位十六进制CIDSystemInfo固定为 Adobe / UCS / Supplement 0所有映射以beginbfchar/endbfchar分组输出每组最多CMAP_MAX_ENTRIES_PER_GROUP条生成的流以标准前缀/CIDInit /ProcSet findresource begin ... endcmap包裹与文章开头 pdftk 解压出的结构完全同构。可见 CMap 的读与写在 pypdf 内部是闭环的两面解析侧的状态机负责吞下各种 PDF 生产者Word、LaTeX、各种排版引擎写出的 CMap 方言生成侧则输出规范、自洽的标准 CMap。小结读懂 CMap就掌握了 pypdf 文本提取的钥匙回到开头的例子一条 CMap 的本质就是三句话begincodespacerange 00 FF—— 声明输入域为 0–255 的单字节beginbfchar 1B FB00—— 声明0x1B翻译为连字 ff内容流里的\034八进制 28等字节码 —— 进入查表输出对应 Unicode。pypdf 在 pypdf/_cmap.py 中用get_encoding()→_parse_to_unicode()→process_cm_line()→parse_bfchar()/parse_bfrange()的调用链实现了这套逻辑并辅以预定义 CMap 表、嵌入字体兜底和严格的上限防护。想深入验证上述行为可以直接阅读 pypdf/_cmap.py 与 tests/test_cmap.py其中包含日文 90ms-RKSJ、GBK、UTF-16 等多编码的真实用例也可以自行对 resources/crazyones.pdf 执行pdftk ... uncompress复现本文的所有观察。【免费下载链接】pypdfA pure-python PDF library capable of splitting, merging, cropping, and transforming the pages of PDF files项目地址: https://gitcode.com/GitHub_Trending/py/pypdf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考