PDF 不是扁平文件

PDF 不是一组独立页面的简单序列。它是一个对象图:一棵由编号对象和交叉引用构成的树,页面之间通过间接引用共享字体、图像、色彩空间和其他资源。理解这个结构,才能预测拆分和合并操作的真实行为。

页面树与共享资源

PDF 的根对象包含一棵页面树(Page Tree)——由页面节点构成的层次结构。每个页面节点引用:

  • 内容流(Content Streams):绘制指令(文本定位、图形算子)
  • 资源字典(Resources Dictionary):字体、图像、色彩空间、图案、XObject
  • 注释(Annotations):链接、表单域、批注、印章、涂改标记

关键点在于资源通常是跨页面共享的。第 1 页使用的字体可能与第 47 页引用的是同一个对象;每页都出现的页眉图片在文件中可能只存在一份。

code
% 简化的 PDF 对象图
1 0 obj  << /Type /Catalog /Pages 2 0 R >>
2 0 obj  << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
3 0 obj  << /Type /Page /Parent 2 0 R /Resources 5 0 R /Contents 6 0 R >>
4 0 obj  << /Type /Page /Parent 2 0 R /Resources 5 0 R /Contents 7 0 R >>
5 0 obj  << /Font << /F1 8 0 R >> >>  % 共享资源

交叉引用表

每个 PDF 文件末尾都有一张交叉引用表(Cross-Reference Table,PDF 1.5+ 中可以是交叉引用流),将对象编号映射到字节偏移量,这使得阅读器无需解析整个文件就能随机访问任意对象。

拆分或合并时,交叉引用表必须完全重建——对象编号改变、字节偏移改变、Trailer 字典也随之更新。

拆分的真实行为

"拆分 PDF"这个说法是模糊的。实际上存在两种本质不同的操作:

页面提取(浅拷贝)

最简单的形式:将选中的页面对象及其传递性依赖复制到新文件中。大多数工具执行的"拆分"就是这种操作。

被保留的内容:

  • 文本内容与定位
  • 嵌入图像(光栅化形式)
  • 被选中页面引用的字体子集
  • 附着在被选中页面上的页面级注释

可能丢失或损坏的内容:

特性 提取后的行为
书签(大纲) 除非显式为子集重建,否则丢失
跨页面链接 损坏——目标页面不存在于新文件中
命名目标 如果引用的页面不在子集中则丢失
表单域 被提取页面上的域保留,但提交 URL 和计算顺序可能损坏
JavaScript 动作 文档级脚本丢失;页面级脚本可能存活
文章线索 损坏
可选内容(图层) 如果图层定义被复制则可能存活
数字签名 失效——任何字节级修改都会使其无效
文档元数据 可能被原样复制(对子集来说不正确)或丢弃
附件 除非显式复制,否则丢失

结构化拆分(保留结构)

一种更复杂的操作,试图相对于页面子集保留文档结构。这需要:

  1. 为子集重建页面树
  2. 过滤书签,仅保留指向子集内页面的条目
  3. 重映射命名目标
  4. 保留被包含页面的图层/OCG 成员关系
  5. 重建交叉引用表

没有任何工具能在拆分后保留跨文档关系。从第 3 页到第 47 页的链接,在你只提取第 1–10 页后就变成了无效链接。

合并的真实行为

合并是将多个 PDF 文件组合为一个。这听起来简单,但需要在每个结构层面解决冲突。

对象编号冲突

每个源 PDF 都有自己的对象编号(从 1 开始)。合并器必须重新编号所有源文件中的所有对象以创建统一的命名空间,每个间接引用都必须更新。

资源去重

朴素合并会复制共享资源。如果两个源文件都嵌入了相同的字体(比如 Times New Roman),朴素合并会嵌入两份。智能合并器通过内容哈希检测重复流并复用对象:

code
源文件 A: /Font /F1 → Object 42 (TimesNewRoman 子集)
源文件 B: /Font /F1 → Object 18 (TimesNewRoman 子集)

朴素合并:两份都嵌入 → 输出更大
智能合并:检测到相同流 → 复用一个对象

书签合并

每个源 PDF 可能有自己的书签树。合并策略:

  1. 串联:将每个源文件的书签作为顶级章节放置
  2. 扁平化:将所有书签合并到单一树中
  3. 丢弃:丢弃所有书签(有损)

大多数工具使用策略 1,但书签的目标必须重映射到新的页码。

表单域冲突

PDF 表单使用域名作为标识符。如果两个源文件都有名为 "email" 的域,合并器必须:

  • 重命名其中一个域(破坏预填数据)
  • 合并它们(要求域类型相同)
  • 丢弃重复项(有损)

这是合并后表单损坏的常见原因。

元数据与文档属性

合并输出需要一组文档属性(标题、作者、创建日期)。没有标准算法——工具可能取第一个文件的元数据、丢弃所有元数据,或询问用户。

数字签名与结构操作

任何结构修改都会使 PDF 数字签名失效。 这是设计如此——签名覆盖的是原始文档的字节范围。

含义:

  • 拆分签名 PDF 产生的是未签名输出
  • 将签名 PDF 合并到另一个文档中会使签名失效
  • 向签名 PDF 添加页面会使其失效(除非使用增量更新且签名的字节范围明确排除了新内容)

如果你需要证明拆分/合并后的文档派生自某个签名原件,请保留原件并记录操作过程。

加密和权限在操作中的行为

PDF 加密有两种形式:

  1. 用户密码:打开文档时需要提供
  2. 所有者密码:控制权限标志(打印、复制、编辑)

拆分或合并加密 PDF 时:

  • 必须提供密码以解密才能操作
  • 输出可以用新密码重新加密,也可以不加密
  • 源文件的权限标志不会自动继承到输出
  • 某些工具会静默剥离加密;另一些会拒绝操作加密输入

权限标志是建议性的,不是强制性的。 PDF 规范明确指出权限标志依赖阅读器的合规实现。忽略标志的工具可以无视权限提取内容。

验证拆分/合并输出

永远不要假定操作是无损的。请验证:

结构验证

bash
# 检查页数
pdfinfo output.pdf | grep Pages

# 检查交叉引用是否损坏
qpdf --check output.pdf

# 验证 PDF/A 合规性(如有要求)
verapdf --flavour 2b output.pdf

内容验证

bash
# 提取文本并对比
pdftotext original.pdf original.txt
pdftotext split-output.pdf split.txt
diff original.txt split.txt

# 视觉对比(渲染后逐像素比较)
pdftoppm -r 150 original.pdf orig-page
pdftoppm -r 150 output.pdf out-page

元数据验证

bash
# 对比元数据
pdfinfo original.pdf > meta-original.txt
pdfinfo output.pdf > meta-output.txt
diff meta-original.txt meta-output.txt

# 检查签名有效性
pdfsig output.pdf

"客户端处理"声明的可验证性

许多在线工具宣称"你的文件从未离开你的设备"。这是一个可验证的声明——你可以检查:

  1. Network 标签页:打开浏览器 DevTools → Network → 执行操作 → 检查是否有文件上传请求
  2. Service Worker:某些工具使用 Service Worker 拦截 fetch 请求——检查 SW 源码
  3. WebAssembly 二进制:真正的客户端 PDF 库(如 pdf-lib、PDF.js 或编译为 WASM 的 libmupdf)完全在浏览器中运行
  4. 文件大小:如果处理 50MB 文件瞬间完成且没有上传进度条,很可能是客户端处理

但"客户端"在所有威胁模型中并不等于"隐私":

  • JavaScript 代码本身由工具服务器提供,可以随时更改
  • 浏览器扩展程序可以拦截文件内容
  • 工具的 JavaScript 可能发送文档遥测数据(页数、元数据)而不上传完整文件

对于敏感文档,请审计工具的源代码或使用可检查的本地开源工具。

场景选择指南

场景 推荐方案
从 200 页报告中提取 3 页 页面提取——快速、依赖少
将书籍按章节拆分并保留书签 结构化拆分 + 书签重建
将月度发票合并为年度归档 简单串联——顺序重要,元数据不重要
合并含有表单的文档 需处理域名冲突的精细合并
处理已签名的法律文档 不要拆分/合并——使用原件;如必须,需重新签名
机密文档 本地工具(qpdf、pdftk、Python pikepdf)——验证无网络活动

程序化操作的库与工具

面向开发者的 PDF 拆分与合并选型:

语言 拆分 合并 书签保留 表单处理
qpdf C++ / CLI 有限
pikepdf Python 通过 pypdf
pdf-lib JavaScript
iText Java / .NET
PDFBox Java
Poppler (pdfunite/pdfseparate) C++ / CLI 基础 基础

示例:保留书签的拆分(pikepdf)

python
import pikepdf

def split_with_bookmarks(src_path: str, page_range: range, dst_path: str):
    with pikepdf.open(src_path) as src:
        dst = pikepdf.new()
        page_map = {}
        for new_idx, old_idx in enumerate(page_range):
            dst.pages.append(src.pages[old_idx])
            page_map[old_idx] = new_idx

        with dst.open_outline() as outline:
            for item in pikepdf.open_outline(src).root:
                dest_page = item.destination[0]
                page_num = src.pages.index(dest_page)
                if page_num in page_map:
                    new_item = pikepdf.OutlineItem(
                        item.title,
                        page_map[page_num]
                    )
                    outline.root.append(new_item)

        dst.save(dst_path)

示例:带元数据控制的合并(qpdf CLI)

bash
# 合并三个文件
qpdf --empty --pages file1.pdf file2.pdf file3.pdf -- merged.pdf

# 验证结构
qpdf --check merged.pdf

# 设置自定义元数据
exiftool -Title="Q3 综合报告" -Author="财务团队" merged.pdf

常见失败模式

  1. 拆分后文字乱码:通常意味着字体子集未被完整复制——页面引用了存在于共享字体对象中但未被包含在提取结果中的字形轮廓。

  2. 合并后出现空白页:往往是内容流引用了错误对象命名空间中的资源——重编号失败。

  3. 表单域不可编辑:域计算顺序引用了不再存在的页面,或 JavaScript 验证脚本引用了被丢弃的文档级全局脚本。

  4. 合并后文件体积暴涨:未执行资源去重——相同的字体/图像被嵌入了 N 次。

  5. 签名验证失败:符合预期——任何修改都会使字节范围签名失效。

总结

PDF 拆分与合并不是简单的剪切粘贴。它们是需要处理对象重编号、资源去重、书签重映射、表单域冲突解决和元数据决策的结构变换。对签名 PDF 的任何操作都会使签名失效。"无损"仅在特定条件下适用于页面内容——结构元数据、书签、链接和交互功能需要显式处理。

在将拆分或合并输出用于具有法律、归档或无障碍要求的工作流之前,请使用结构验证工具(qpdf --check、verapdf)和内容对比(文本提取 diff、视觉渲染 diff)验证输出。