一份下载到一半被截断的 PDF 可能拒绝打开,哪怕几乎所有页面都已到达;而一份两千页的手册却能瞬间跳到第 1500 页。两种行为都源于 PDF 文件结构,也就是 HotPDF Delphi Component 写出的那套结构:一个头部、一个由带编号对象组成的主体、一张字节偏移的交叉引用表(xref),外加一个尾部,后两者存放在文件末尾
ISO 32000-2 §7.5.1 为每一份 PDF 规定了同样的四段式解剖,从一页收据到扫描归档无一例外。这四个部分在文件中永远按同一顺序排列,并以一条固定的链相互衔接:尾部点出目录并说明 xref 表从哪里开始,xref 表说明每个对象从哪里开始,对象之间再按编号相互指向。下文摘录来自一份 610 字节的 Hello World 文件,偏移量经过逐字节核对;带逐关键字注释的完整文件见 PDF 内部长什么样,阅读器如何把这些部件一步步变成像素,见 阅读器打开 PDF 时它如何工作
PDF 头部有两行,第二行举足轻重
第一行如 %PDF-1.7,就语法而言是一条注释,但阅读器把它当作文件签名,并从中取版本号;实践中的版本处理相当宽松,把这个数字当作特性提示即可。第二行就是人们会误删、然后花一下午调试的那一行。它的载荷是至少四个高于 ASCII 127 的字节(ISO 32000-1 §7.5.2),其存在是为了让任何以文本模式搬运文件的程序识别出二进制并停止改写行尾。压缩流里有些字节碰巧与回车或换行相同;一旦被改写,字典里记录的流长度就和磁盘上的字节对不上。每个严肃的写入器至今仍输出这条注释,因为它防住的那类失败无声而彻底
主体:带编号的对象与八种 PDF 对象类型
主体是间接对象的扁平序列,每个对象都以 N G obj ... endobj 包裹,带对象编号和生成编号。把这份扁平清单变成 PDF 内部结构的,是引用标记 N G R:2 0 R 意为“对象 2、生成 0,无论它恰好住在文件的哪个位置”。任何值也可以就地书写为直接对象。摘录中,页面的 /Resources 字典是直接的,而它点名的字体是间接的,于是一百个页面可以共享对象 4,而不必各带一份拷贝:
1 0 obj
<< /Type /Catalog /Pages 2 0 R >> % 目录指向页面树
endobj
...
3 0 obj
<< /Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources << /Font << /F1 4 0 R >> >> % 直接字典,间接字体
/Contents 5 0 R
>>
endobj
ISO 32000-1 §7.3 把词汇表压得很短:PDF 中的每个值都是八种基本类型之一。布尔值和数字是标量,PDF 没有指数记法,所以合规文件里永远不会出现 1e6。字符串存放字节,写作 (Hello, World!) 或十六进制 <48656C6C6F>。/Type、/Helvetica 这类名称逐字节比较,所以在 /BaseFont 期望名称的位置写 (Helvetica),即便字符一致也是类型错误。数组按顺序容纳任意内容,比如 MediaBox。字典把名称映射到值,承载大部分结构。流是字典加上原始字节,页面内容、字体和图像都住在里面。第八种类型是 null,指向不存在对象的引用会解析成它而不是抛错,这让受损文件得以降级而不是彻底失败
生成编号是为复用而设的。某次编辑删除一个对象时,它的编号进入空闲列表;如果后来的编辑复用该编号,生成编号就加一,这样过期的 12 0 R 就不会悄悄解析成新的 12 1 obj。你遇到的几乎所有对象生成编号都是 0。解析从目录开始,即摘录中的对象 1,它的 /Pages 条目通向页面树;内部节点在 /Kids 里收集页面、维护 /Count,还可以把 /MediaBox 之类的属性向下传给后代。为什么决定页面顺序的是这棵树而不是对象的物理顺序,见 PDF 页面树与页面顺序
xref 表如何定位一个对象?
xref 表靠算术把对象编号换算成字节偏移,两千页的手册因此能打开在第 1500 页,而不必解析它前面的页面。ISO 32000-1 §7.5.4 把每个条目定为整整二十字节:一个从文件开头计数的十位偏移量、一个空格、一个五位生成编号、一个空格、一个类型字母,再加两个字节的行尾——采用 LF 时是一个空格加换行符。行既然整齐划一,阅读器就能直接索引到对象 n 所在的行。0 6 行是小节标头,条目 0 永远空闲、生成编号 65535,领衔空闲对象编号链表。其余每个偏移都必须落在对应 N G obj 头部的第一个数字上,610 字节文件中即是如此:
xref
0 6 % 6 个条目,从对象 0 开始
0000000000 65535 f % 条目 0:空闲列表表头
0000000019 00000 n % 对象 1(catalog)从字节 19 开始
0000000068 00000 n % 对象 2(页面树)从字节 68 开始
0000000125 00000 n % 对象 3(页面)从字节 125 开始
0000000263 00000 n % 对象 4(字体)从字节 263 开始
0000000333 00000 n % 对象 5(内容流)从字节 333 开始
截断的 PDF 为何打不开?
截断的 PDF 打不开,是因为把一切系在一起的两个部分——xref 表和尾部——是最后写入的字节。尾部字典携带 /Root(目录)和 /Size(xref 条目数),后者比最高对象编号大一,因为 0 号槽留给空闲条目。它下方,startxref 给出 xref 关键字的字节偏移,%%EOF 收尾:
trailer
<< /Size 6 /Root 1 0 R >> % 条目数,文档目录
startxref
427 % xref 关键字的字节偏移
%%EOF
丢掉最后几百字节,文件就只剩对象而没有指向它们的地图。严格的阅读器到此为止;宽容的会扫描整个文件找 N G obj 头部并重建表——xref 的存在正是为了避免这条慢路径——而重建的地图仍可能漏掉对象,或给被编辑过的对象挑中错误的版本。文本模式传输的破坏更隐蔽,因为改写行尾会移动每一个对象。我们把 610 字节文件转成 CRLF 行尾后运行 qpdf --check(qpdf 12.3.2):它报告 “file is damaged” 和 “xref not found”,位置在偏移 427,并在检查任何东西之前先扫描重建了表
经典 xref 表还是交叉引用流?
自 PDF 1.5 起,xref 也可以是交叉引用流(ISO 32000-1 §7.5.8):一个 /Type /XRef 的间接对象,用二进制行取代文本表,其流字典吸收了尾部条目,于是 trailer 关键字消失,startxref 指向该对象。行类型变成数字(0 空闲、1 在字节偏移处、2 在对象流内),/W 给出每列的字节宽度,HotPDF 用 Flate 加 PNG Up 预测器压缩行。Version 为 pdf15 或更高时 HotPDF 写出流形式,所以示例显式设置它,UseObjectStreams 建在同一个开关之上。必须能在 PDF 1.4 时代工具里打开的文件,我们让 UseXRefStream 保持关闭——那些工具完全无法解析 xref 流;大文档则打开它,紧凑索引在此物有所值。改变的只有文件尾部:
uses
System.SysUtils, HPDFDoc;
procedure SaveWithXRef(const FileName: string; AsStream: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.FileName := FileName;
PDF.Version := pdf17; // xref 流需要 PDF 1.5 或更高版本
PDF.UseXRefStream := AsStream; // 默认 False:文本表加尾部
PDF.BeginDoc;
PDF.CurrentPage.TextOut(72, 72, 0, 'Same page, different index');
PDF.EndDoc;
finally
PDF.Free;
end;
end;
// SaveWithXRef('table.pdf', False);
// 预期尾部:xref、0 N、二十字节行、
// trailer << /Size /Root /Info /ID >>、startxref、%%EOF
// SaveWithXRef('stream.pdf', True);
// 预期尾部:N 0 obj << /Type /XRef /Size /Root /Info /ID /W ...
// /Filter /FlateDecode >> stream ... endstream endobj、startxref、%%EOF
增量更新追加第二个 xref 而不是重写
PDF 可以在不碰磁盘上任何既有字节的情况下编辑。被更改和新增的对象追加到末尾,后跟一张只列出它们的 xref 节,以及一个 /Prev 条目保存上一张表偏移量的尾部。阅读器沿 startxref 找到最新一节,沿 /Prev 链向后回溯,让每个对象编号的最新条目获胜。下面的编辑给 610 字节文件加一条便签注释:页面对象 3 以同一编号重写、带上 /Annots 数组,对象 6 是新增。去掉注释并使用 LF 行尾后偏移精确无误,结果通过 qpdf --check 且零错误:
% ... 原 610 字节,原样未动 ...
3 0 obj % 替换页面 3,同一对象编号
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R] >>
endobj
6 0 obj % 新增的注释
<< /Type /Annot /Subtype /Text /Rect [72 650 92 670] /Contents (Checked) >>
endobj
xref % 两个单条目小节
3 1
0000000610 00000 n
6 1
0000000752 00000 n
trailer
<< /Size 7 /Root 1 0 R /Prev 427 >>
startxref
843
%%EOF
追加正是签过名的 PDF 保持可验证的原因:签名覆盖一个字节范围,而增量更新从不移动这些字节。这也是 PDF 能携带可恢复历史的原因——旧页面 3 仍在字节 125 处,不可达但存在。修订多次的文件会长出一条 /Prev 长链;qpdf revised.pdf flat.pdf 把可达对象在单一 xref 下重新序列化并丢弃旧修订,对上面的文件来说,结果是只剩一张 xref 表、没有 /Prev,代价是原始字节上的签名一并失去。在 HotPDF 里,增量修订长这样:
procedure ReviseInPlace(const Original, Revised: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.BeginIncrementalUpdate(Original); // 原始字节逐字保留
PDF.SetLoadedTitle('Quarterly report, revised');
PDF.SetLoadedAuthor('Finance');
PDF.SaveIncrementalUpdate(Revised); // 被更改的对象、新 xref、带 /Prev 的尾部
finally
PDF.Free;
end;
end;
四段式布局也给行为异常的 PDF 提供了一条快速排查顺序:确认文件以 %%EOF 收尾且 startxref 数值合理,抽验一两个 xref 偏移是否落在 N G obj 头部上,然后沿 /Root 进入目录和页面树。面向 Delphi 与 C++Builder 的 HotPDF Delphi Component 可按需写出经典 xref 表或交叉引用流,追加的增量修订让更早的字节及其上的签名原样保留