
1. 名字叫 colibri 的 PDF 工具我是怎么被它逼疯又治好的先交代背景。colibri 是西班牙语和法语里蜂鸟的意思长周末我给自己挖了一个坑做一个极轻量的 PDF 处理工具核心就三件事——压缩、拆分合并、清洗元数据。要求只有一条别像那些动辄几百 MB 的办公套件一样为了改个页眉要等三秒转圈。结果这个命名非常讽刺。蜂鸟是世界上最小的鸟翅膀每秒扇动几十次悬停、倒退、直上直下灵活得像个违反物理定律的小东西。我做的 colibri 项目前期确实轻盈一个脚本文件跑完全流程后面越加需求越臃肿先是支持加密 PDF再是处理扫描件然后是字体子集化最后还要搞网页界面。等我反应过来的时候一个极简工具已经长出了 Web 服务、数据库缓存和前端项目结构蜂鸟活生生被我喂成了火鸡。这篇文章不是从零开始教你写 PDF 解析器PDF 格式的 ISO 规范有八百多页没人有精力重造轮子。我要记录的是 colibri 这个项目从一个 print 命令都敢直接上到能在生产环境扛住日均两万次请求的全过程踩过的坑、做过的取舍、以及那些文档里永远查不到、只能拿真实文件试出来的边界情况。适合来看这篇内容的人有两类。一类是你手里攒着几万个 PDF 要批量处理公司又不给买商业授权想找个靠谱思路自己动手另一类是单纯想看看一个轻量工具是怎么在真实压力下把性能、兼容性和代码复杂度同时卷死的。我的建议是先看完第三节的架构选型再决定要不要往下走因为 colibri 最核心的教训就一句话PDF 处理这行选型定生死代码只是执行。2. 先盘清楚 PDF 这摊水有多深格式特性决定架构边界很多人以为 PDF 就是个把图片和文字装进去的箱子这种误解会直接让你在项目中期崩溃。colibri 第一个原型跑通只用了三个小时但处理第一个真实用户的异常文件就花了两天。原因就是 PDF 的复杂性从来不在读取上而在写入后其他软件认不认。PDF 文件本质上是一个由对象组成的图结构。每个对象有类型字典、数组、流、整数、布尔值、十六进制字符串等和编号文件体由对象集合构成交叉引用表记录每个对象在文件里的物理偏移量Trailer 字典告诉解析器从哪开始找根对象。听着很规整对吧问题在于这个规范允许的兼容写法太多了。举个例子。交叉引用表有两种格式传统的 xref 表会老老实实列出每个对象的偏移量PDF 1.5 之后的 xref stream 则是把同样的信息塞进一个压缩流对象里。如果不支持后者打开文件时就必须全量扫描obj 关键字来找对象扫描逻辑本身又要处理对象可能出现在字符串或流数据内部的情况。更恶心的还有增量更新某些编辑软件会把修改追加在文件末尾而不是重写整个文件同一个对象可能出现好几个版本只有最后一个有效。凡是在这个层面偷懒的解析器最终都会碰上某用户的 PDF 在这个软件打开正常在另一个软件里缺了半页内容的神奇问题。colibri 的定位是轻量但可靠所以我一早就放弃了从零解析 PDF 的执念转而选定 PyMuPDFfitz作为处理内核。这个库对 PDF 规范的覆盖度非常高C 语言级别的性能能直接读取 xref stream能处理增量更新还能通过 mutool 兜底。有人会问那我还写什么答案是解析交给内核但业务编排、异常恢复、压缩策略、元数据清洗这些才是工具的核心价值也是商业软件收费的地方。再强调一个容易被忽略的点PDF 没有页的概念。你看到的每一页其实是一堆内容流、资源字典和注释对象的组合。合并两个 PDF 不是简单地把文件拼起来而是要把页对象和资源对象重编号、复制、去重再生成新的交叉引用表。colibri 第一个版本在合并文件时是原样拷贝对象结果生成的文件体积比两份源文件加起来还大 50%。原因在于两份文件各自的字体子集被当成完全独立的资源整体复制了没有做共享。后来加了资源去重逻辑合并后的体积才回到正常范围。所以架构上首先要做的决策是接受PDF 的千层套路然后在上面盖一层自己掌控的标准化处理管道。我把 colibri 分成四层层级职责关键点输入层文件识别、格式校验、加密检测魔数验证、PDF 版本识别、权限标志读取处理层页对象操作、资源合并、内容流重写基于 fitz 的对象级/页级操作策略层压缩参数、书签映射、元数据字段规则业务可配置不用改底层代码输出层增量写、线性化、标准合规校验版本指定、校验和、交叉引用表重建现在回头看这个分层是后补的不是一开始就设计好的。现实是需求得加代码却不想乱所以花了两个晚上做了重构。如果你打算照着做类似的东西建议直接在第一天就把这四层分开尤其是策略层独立出来否则后面每加一个压缩模式都要动核心函数。3. 核心功能逐个落地压缩、拆分、合并、元数据清洗的完整实现细节3.1 压缩策略图像重采样是收益大头但字体子集化才是技术分水岭colibri 第一个上线的功能是压缩。绝大多数 PDF 的体积构成里图像占 80% 以上所以压缩核心是图像重采样。设计压缩参数时我定了三档预设draft图像 DPI 上限 96JPEG 质量 50适合快速发送预览standard图像 DPI 上限 150JPEG 质量 70默认推荐high图像 DPI 上限 200JPEG 质量 85不带原图但也接近无损具体实现上用 fitz 遍历每页的图片对象通过page.get_images(fullTrue)拿到 xref再用doc.extract_image(xref)检查图像尺寸、颜色空间和编码格式。关键逻辑是不是所有图都要重采样。如果源图本身是 72 DPI 的截图重采样到 200 DPI 没有任何意义如果源图是矢量图矢量化失败后嵌入的高分辨率位图这才是压缩的主要目标。判断依据是图像的实际像素尺寸和它在页面上的显示尺寸之比。比如一张 3000x2000 的图显示在 A4 页面某矩形区域区域物理尺寸是 15cm x 10cm折算下来有效 DPI 大约 508。standard 模式只需要 150 DPI此时重采样到 884x589 就够了体积能压掉 60% 以上。计算逻辑def calculate_dpi(width_px, height_px, rect_width_pt, rect_height_pt): # 1 pt 1/72 inch dpi_x width_px / (rect_width_pt / 72.0) dpi_y height_px / (rect_height_pt / 72.0) return max(dpi_x, dpi_y)重采样用pix fitz.Pixmap(doc, xref)然后pix.set_dpi(target_dpi)再做缩放最终转成 JPEG 写回流替换原对象。这里有个坑颜色空间不是 RGB 的图直接转 JPEG 会偏色或直接失败。CMYK、灰度、索引色必须先转换为 RGB但转换后体积反而可能变大CMYK JPEG 压缩率通常比 RGB 高。稳妥做法是检查pix.colorspace只有 RGB 直接压缩其余统一走pix.tobytes(jpeg, jpg_qualityquality)的标准通道由 PIL 做底层转换这一步实测会增加 20% 的处理时间但换来的是输出文件在所有查看器里颜色一致。字体子集化是另一个大头。很多办公软件导出的 PDF 会嵌入完整字体文件一套中文字体动辄 8MB正常文档实际只用了几十个汉字。fitz 提供了doc.subset_fonts()能将嵌入字体精简到实际使用的字形子集。但这个方法有两个前提文档没有加密且字体对象允许子集化字体描述符的 Flags 字段里没有禁用字体的标志。实测下来银行对账单类的 PDF 往往都允许子集化而某些专业排版软件生成的 PDF 会禁用这种情况只能放弃。另外要小心子集化对文本提取的影响。子集化之后的字体内部会重新编码字符映射原本能正常复制出来的中文文本在部分老旧 PDF 查看器里可能变成乱码。colibri 的处理方式是压缩流程结束后强制做一次文本提取回归校验随机抽三页确认关键字符串仍在。这个校验在批量处理两万份文件时救了我两次两次都是因为目标文件本身是扫描图片隐藏文字层的混合结构子集化把隐藏文字层里的字体搞挂了但显示层完全正常肉眼看不出问题。3.2 拆分与合并书签导航和资源复用是体验分水岭拆分的需求五花八门按固定页数切分、按书签层级切分、按页码范围抽取。colibri 的拆分模块核心就两个函数但接口设计上做了细粒度区分def split_by_pages(doc, ranges): ranges: list[tuple[start, end]]1-based 页码闭区间 for idx, (start, end) in enumerate(ranges): new_doc fitz.open() new_doc.insert_pdf(doc, from_pagestart - 1, to_pageend - 1) new_doc.save(fsplit_{idx 1:02d}_{start}_{end}.pdf) new_doc.close() def split_by_bookmarks(doc, min_level1, max_level2): toc doc.get_toc() 返回新的分段文件同时保留层级书签结构拆分时最容易翻车的是书签层级。PDF 的书签树Outline可以嵌套任意深度顶层可能是一个第一章下面挂了几十个第 1.1 节。如果你只是把第 10 到 20 页拆出来但书签信息没有跟着页范围做过滤重映射新文件打开后点击书签链接会跳到一个错误的空白页。fitz 的insert_pdf在复制页面时不会自动处理这种情况需要在拆分后重建目录。我写了个递归函数遍历原书签树只保留目标页范围内的节点并将页号偏移修正为新文件的页号。这个逻辑本身不算复杂但书签条目里的目标可能是命名目的地而非纯数字页号那种情况只能放弃对应书签否则链接会失效且无法定位。合并功能看着简单一行new_doc.insert_pdf(source)就能搞定但做可靠了很费功夫。colibri 对外的承诺是将 N 个 PDF 合并为一个且保持原书签结构每个源文件的书签作为一级子节点挂在文件名下面。这个需求来自一个做标书集成的用户他一周要合 200 份标书每份标书本身有三级目录。具体实现上insert_pdf会把源文档的页面和资源完整拷贝进目标文档真实副本生成在目标文档的底层对象池里。但书签树不会自动迁移需要手动构建def merge_pdfs(file_list, output_path, add_bookmark_layerTrue): merged fitz.open() total_pages_so_far 0 for file_path in file_list: src fitz.open(file_path) start_page merged.page_count merged.insert_pdf(src) if add_bookmark_layer: base_title Path(file_path).stem merged.bookmark_page(base_title, start_page) for lvl, title, page in src.get_toc(): merged.bookmark_page(title, page start_page, levellvl 1) total_pages_so_far src.page_count src.close() merged.set_toc(merged.get_toc()) # 重新构建目录树 merged.save(output_path, garbage4, deflateTrue)这里有个很值得说的参数save时的garbage4。fitz 支持多个 garbage 级别其中 4 表示除了必要的对象外所有未被引用的对象都会被删除同时合并重复对象。如果不加这个参数合并出来的文件会包含大量源文件的残留对象体积膨胀到吓人。我的实测数据合并 12 份 PDF每份 5~8MB直接insert_pdf后不清理输出是 147MB加了garbage4, deflateTrue之后输出只有 63MB接近源文件总和。原因是原文件里那些被删改留下的孤儿对象和重复字体在单文档时不显眼合并时被全量带上了。3.3 元数据清洗表面上是在删字段实际上是在处理隐私泄漏元数据清洗是 colibri 里技术含量最低、但用户反馈最热烈的一个功能。PDF 元数据分成三种载体DocumentInfo 字典标题、作者、创建时间等老式字段、XMP 元数据一份嵌在流里的 XML现代软件主要写到这、以及隐藏文本层扫描件底下那层 OCR 出来的文字用来做搜索和复制。DocumentInfo 和 XMP 的清洗很简单直接重写并清空即可doc.set_metadata({}) doc.del_xml_metadata()真正的坑在第三类。银行对账单、财务报表这类扫描 PDF原始扫描件只有图片但经过 OCR 软件处理后图片下埋了一层透明文字。这层文字在屏幕上不可见但用文本提取工具能完整读出账号、金额、日期。很多元数据清洗工具根本不处理这个因为检测它需要额外做页面内容流解析。colibri 的做法是逐页提取文本如果按页面矩形区域扫描发现大量分散的透明文本就执行内容流裁剪删除对应绘制指令。这个逻辑耗时严重一页大约要多 1 秒所以做成可选项只有用户显式指定--scrub-text-layer才执行。另外要提醒一个所有 PDF 工具都会踩的坑重置元数据后文件的唯一标识/IDTrailer 字段也要重新生成。很多匿名上传平台只做表面处理结果 PDF 文件里通过/ID可以反查到同一个工作流程的多个文件是同一台电脑导出的。colibri 会用随机数重写 Trailer 的 ID 数组这个字段不影响任何软件打开文件但直接影响匿名性。别问我为什么知道要做这个评论区可以猜。4. 性能实测和边界情况的完整排查链路colibri 是怎么把用户坑回来的4.1 一组真实压测数据项目到了后期我用一批真实场景文件做了压测。这批文件包括某政府网站下载的 58MB 扫描版公报300 DPI 彩色图片共 128 页、一个包含 24 份小文件的投标文档集、以及一个从旧版 InDesign 导出的怪异 PDF交叉引用表损坏但能在 Acrobat 中打开。压测机器是 Docker 容器分配了 4 核 CPU 和 6GB 内存操作系统为 Debian 12Python 3.11PyMuPDF 1.24。任务输入输出体积耗时备注压缩 draft58MB / 128页11.8MB23.4s图像全量重采样压缩 standard58MB / 128页16.2MB29.1s平均每页 0.23s压缩 high58MB / 128页27.5MB41.7s画质接近原图合并 24 份文件合计 103MB79MB8.2s书签重建全部成功按页拆分 128 页成 8 段58MB8 x 4~9MB5.6s文件编号补零元数据清洗 500 份累计 3.2GB3.18GB12min主要是 I/O 负担数据说明一个问题压缩的操作是 CPU 密集型的但合并拆分是 I/O 密集型的。如果你要在生产环境跑类似工具瓶颈根本不在算法复杂度而在磁盘读写速度和临时目录空间。我用 Docker 挂载了一个慢速网络磁盘跑合并压测耗时直接翻了 3 倍因为临时文件写满 tmpfs 之后开始 swap。建议所有类似工具在接收大文件时显式让用户配置临时目录不要默认走系统/tmp。4.2 一次完整的线上故障排查损坏的交叉引用表colibri 上线后第一次被真实用户反馈处理失败是一个 2MB 的小文件。命令行输出报错RuntimeError: cannot open broken document按照普通思路这可能就是文件本身损坏直接抛给用户即可。但我在排查时留了个心眼用fitz.open(path, filetypepdf, recoverTrue)是否能用容错模式打开这个recover参数背后的逻辑是解析器通过线性扫描在文件中定位疑似对象而不是依赖交叉引用表。很多损坏的 PDF 就是交叉引用表坏了但对象本身数据完好。pyMuPDF 底层会尝试重建对象间引用成功率非常高。排查路径如下先看文件尾部是否保留着交叉引用表偏移量。PDF 文件的最后一行通常是%%EOF倒数几行写着 xref 表起始位置。如果这里被篡改或截断文件大概率救不回来。用十六进制编辑器检查startxref指向的位置附近是否有xref关键字或/XRef流。部分编辑器如老版本的 LibreOffice会写出非标准结构导致某些解析器找不到正确的表。用recoverTrue打开成功后立刻执行doc.save(out, garbage4)做一次标准重写将其变成一个干净的文件。再做一次完整性测试打开新文件逐页page.get_text()检查每一页是否都提取出了非空文本。原本损坏文件里可能有些页面对象残缺但恢复模式会跳过它们这种页面在重写后依然是坏的只是不报错了。需要统计残缺页列表反馈给用户。这类修复逻辑我现在已经固化成了 colibri 的自动策略默认先正常打开失败后自动触发 recover 模式并在输出报告里标注文件是否经过修复。生产环境里一次尝试修复的执行时间大约比正常打开多 5 到 10 倍但因为实际触发概率不到 2%整体平均性能影响可以忽略。4.3 加密、畸形流对象和色彩空间的三个老朋友加密 PDF 是我接手需求时最不以为然、后来最痛苦的部分。PDF 加密分 RC4 和 AES 两种密钥长度分 40/128/256 位密码分用户密码和所有者权限密码。fitz 对加密文件的支持非常成熟打开时填写密码即可doc fitz.open(path) if doc.needs_pass: doc.authenticate(password)但有个隐藏坑某些加密文件的权限标志规定禁止复制或禁止打印但允许打开。fitz 的authenticate只解决读取密码如果用户只是打开了文件而没有权限密码后续save会报权限错误。colibri 的处理方式是先尝试用空密码打开不行再尝试用户提供的密码成功打开后直接做解密保存输出一个不带加密的新文件。如果你处理的是律师函、合同这类保密文件输出文件要统一落盘到安全目录并立刻删除临时明文这是基本素养。畸形流对象的处理是性能刺客。正常 PDF 的每个流对象比如页面内容流、图像数据流都有/Length字段标明流长度。但有的文件该字段缺失或错误解析器只能通过扫描endstream关键字来确定流边界。这个扫描看似简单但如果流内部包含二进制数据恰好某段字节碰巧和endstream模式匹配就会导致提前截断后续所有解析全部错乱。fitz 的内核对这个情况做了多层回退在绝大多数情况下能自愈但如果自愈失败会抛出一个异常错误信息是mupdf: cannot find start of object。这个错误在批量任务里需要单独捕获否则一个坏文件会导致整批任务中断。colibri 给每个文件处理都包了异常隔离坏文件单独进问题队列不阻塞批次。色彩空间问题主要出现在压缩流程里。前面提过 CMYK 转换实际还有一种 Lab 颜色空间的 PDF这类文件主要来自印刷行业。Lab 转 RGB 在 PIL 里不是直接支持的需要先转成 XYZ 再到 RGB而且转换矩阵依赖具体的白点定义做错了偏色非常严重。我的建议很直白遇到 Lab 空间图片直接跳过压缩保留原始编码不要为了 2% 的体积牺牲色彩准确性。绝大多数用户宁愿文件大一点也不愿打印出来的效果不对。在 colibri 的日志里Lab 空间文件会输出一个 warning 级别信息并在最终报告里列出未优化图像数让用户知道为什么没压下去。5. 从能用走向好用CLI 设计、批处理退出码和 Docker 部署的收尾细节一个命令行工具的价值不在能处理文件而在能在无人值守的流水线里稳定跑一个月。colibri 从个人脚本进化为可部署服务靠的是三件事严格的退出码语义、友好的进度输出、以及 Docker 部署时的资源约束。退出码的设计我参考了 Unix 哲学但做了扩展退出码含义典型场景0全部成功所有文件处理完成无警告1参数错误输入路径不存在、参数组合非法2部分失败批处理中有文件处理失败但进程完成3严重失败输出目录不可写、磁盘空间不足4文件已损坏且不可恢复尝试 recover 后仍失败这个语义设计有一个很大的好处在 Shell 脚本里可以精确判断执行结果。比如企业常见的自动化流程晚上 10 点处理共享目录里的 PDF处理完发报告邮件。如果退出码是 2说明有文件没处理干净脚本可以自动生成一份失败清单发出来退出码 0 则直接静默不发邮件避免半夜打扰。这个设计省掉了大量啥也没干但每次都发邮件的烦人机器人行为。批处理时的进度信息也有讲究。colibri 默认输出格式是一个jsonl每处理完一个文件就追加一行{file: xxx.pdf, status: ok, output_size: 1234, duration_ms: 233}。这个设计有几个好处机器可读、日志可追、断了可以续跑。续跑逻辑是每次启动时读取输出目录里已有的结果跳过已经处理成功的文件。这个续跑功能在长时间批量任务里至关重要实测处理两万份扫描件时中途断电或者 OOM 是必然发生的事没有续跑机制就要重头再来你根本扛不住。Docker 部署时有两个参数值得单独提。第一个是内存上限colibri 在处理超大 PDF 时峰值内存约为文件体积的 3 倍我见过一个 500MB 的超大 PDF 吃掉 1.8GB 内存。容器必须设置--memory2g的硬限制否则一个文件就能把宿主机拖垮。第二个是 CPU 限额PyMuPDF 底层是 C 实现的multiprocessing并行可以接近线性扩展但 PDF 的读写本身有很强的磁盘依赖并行度太高会导致磁盘 I/O 排队整体吞吐反而下降。我在 4 核容器上压测得出的最优并行度是 3留一核给主进程做调度和进度统计。这个结论看起来违反直觉4 核为什么不是 4 并行因为 PDF 写回时会产生大量小文件写入磁盘队列一旦打满性能断崖式下跌。最后一个收尾建议关于测试集。colibri 项目里我维护了一个叫test-files/的目录里面放了约 80 个真实世界的 PDF 文件全都是用户反馈过的异常样本损坏的、加密的、超大分层的、含 JS 脚本的、内嵌 Flash 的古董文件没错 2010 年的 PDF 是可以内嵌 Flash 的。每次改动核心代码第一件事就是全量跑一遍这 80 个文件。它们比任何单元测试都有价值因为 PDF 对象边界情况的复杂程度超出任何一套人为构造的测试用例。也正因为这套回归集colibri 才敢在后来的版本里加入自动修复损坏文件这种激进功能而不用天天担心修 A 坏 B。最后说个小技巧。如果你只是临时想处理几个文件不想为了跑批处理再搭一套环境个人经验是先用fitz.open()试读再决定要不要上容器。PDF 工具链的 80% 问题都可以在裸 Python 环境里定位真正需要的容器化的是那些每天跑、定时跑、给同事跑的服务化场景。工具能解决问题但解决问题的工具得先解决自己的运维问题这才是 colibri 整个开发周期里最值钱的一课。