PDF 1.5的对象流把许多小的间接对象打包进一个经Flate压缩的容器,losLab PDF Library在完整保存时通过PackObjectStreams标志位输出它们。这带来的收益是实实在在的:数百个页面、字体和批注字典,原本未压缩时每个都要占掉几十字节,压缩后被塞进寥寥几个压缩数据块里。代价是每个被打包的对象现在都需要一个交叉引用流来描述它
写入器出问题的地方往往就在后半段。构建一个/ObjStm容器只是算术活;但要让交叉引用机制学会指向容器内部,则是一次重新设计。一个写入器如果生成了一个完全合法的容器,却仍然用普通的type-1偏移量来描述其成员,那它产出的文件Acrobat会打开,但打开的时间刚好够它宣布文件已损坏。这两个特性其实是同一个特性,本文按ISO 32000-1 §7.5.7和§7.5.8的定义,讲的是两者的写入侧
一个ObjStm容器里到底装了什么
对象流是一种流,它解码后的字节由两段拼接的区域组成,ISO 32000-1 §7.5.7给出的字典里,对构建来说真正要紧的恰好是三个键。/Type /ObjStm标识它,/N给出成员数量,/First给出头部区域的字节长度——等价地说,也就是主体开始的偏移量。头部是以空白分隔的"对象号 偏移量"数对;主体是各成员前后拼接序列化而成,每个偏移量都是从主体起始处计算的,而不是从解码后载荷的起始处计算。读一个完全解码后的容器就能看得很清楚:下面这个例子里,/First是14,因为三行头部信息占了十四个字节,对象7位于主体第55字节处,因为对象4序列化后是54个字符加一个分隔符
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
有两条成员资格规则是绝对的,都直接来自§7.5.7。流对象永远不能作为成员,因为流携带的是原始字节,若要嵌套进另一个流里就说不通了。而且成员必须是完整的对象值,绝不能是裸的间接引用——一个压缩对象如果仅仅是5 0 R,就会制造出一个读取器在不预先知道它指向哪里的情况下无法解析的间接层。losLab PDF Library在候选收集阶段就把这两种情况过滤掉,加密字典和对象0也一并排除,然后把剩下的存活对象每200个一组打包进容器。这个上限是出于随机访问的考虑,而不是规范限制:如果读取器只想要一个成员,也必须把整个容器解压出来,所以容器太大会让小查找的代价变得昂贵
为什么ObjStm成员必须使用type-2交叉引用条目?
因为被打包的对象没有文件偏移量可记录。ISO 32000-1 §7.5.8用二进制交叉引用流中的三种条目类型回答了这个问题:type 0对应空闲对象,type 1对应存储在某个字节偏移处的普通在用对象,type 2对应压缩对象,它的两个数据字段分别保存容器对象号和成员在容器内的索引。在经典的纯文本xref表里根本没有办法表达一个被打包的对象,这正是PDF 1.5要把这两个特性一起引入的原因
接下来的排序规则几乎绊倒了每一个初次实现的人,包括我们自己。普通对象获得type-1条目。/ObjStm容器本身也获得type-1条目,因为容器就是一个写在真实偏移量处的、完全正常的间接流对象。只有成员才获得type-2条目。而且交叉引用流本身在文件中也是一个间接对象,所以它也需要自己的type-1条目,指向它刚刚被写入的那个偏移量——正是startxref所记录的那个偏移量。我们写入器的一个早期版本在写入循环中排除的是容器对象号,而不是成员,结果生成的文件只有交叉引用流,却完全没有对象流:结构上自洽,语义上却是空的,下游会直接拒收。/Size的取值里藏着一处对应的差一错误,因为它是最大对象号加一,而交叉引用流本身被分配的正是最大对象号,所以也必须把它计算在内
确定/W数组的大小:为什么四字节不够用
/W数组声明了三个字段各自的字节宽度,losLab PDF Library把它写成/W [1 Field2 Field3],字段1固定为一字节用于类型码,字段3固定为两字节,这足以覆盖最高到65535的代数号以及成员索引。字段2是无法固定为常量的那一个,因为它承载着两种互不相关的量:在type-1条目中它是一个只受文件大小限制的字节偏移量,在type-2条目中它是容器对象号,在type-0条目中它是空闲链中的下一个对象。固定四字节的字段2在文件小于4 GB时都能正常工作,一旦文件跨过这个边界,所有超出边界的偏移量都会被悄悄截断,整张表就变成了垃圾数据。因此写入器会扫描已组装好的表,找出字段2位置可能出现的最大值——包括交叉引用流自身的偏移量——然后把字段宽度扩展到最多八字节
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
一旦宽度确定,载荷大小也就随之确定,因此写入器会预先分配好整块缓冲区,再按索引逐一填充;如果逐字节把条目追加到一个AnsiString上,表的构建就会变成二次方复杂度——一份十页的发票没人会察觉,但一份包含二十万个对象的文档人人都会察觉到。还有两个细节能让严格的读取器满意。/Index声明了这张表覆盖哪些对象号区间,对于一次完整重写来说,那就是简单的[0 N],中间没有空隙。而写入器实际上没有写出的每一个槽位,都必须默认为空闲而不是在用:对象0是空闲链的头,每个空闲槽位链接到下一个,曾经保存过已删除对象的槽位则把代数号加一保留下来。解析不可信PDF时的内存安全那篇姊妹文章从读取侧提出了同样的边界论证
为什么交叉引用流绝不能被加密?
因为读取器必须先解析它,才能知道该如何解密其他内容。交叉引用流正是告诉读取器/Encrypt字典存放在哪里的东西;如果它自己的字节也被加密了,读取器就需要先拿到文件密钥,才能找到描述这个文件密钥的对象。losLab PDF Library用一个谓词就强制实现了这一点:只要流字典携带/Type /XRef,ShouldCryptStreamData就返回False,因此不论走哪条路径到达序列化器,这个豁免都成立
/ObjStm容器则得到相反的待遇,这种不对称是刻意为之的。容器整体被加密,以自身对象号为密钥,就和其他任何流一样。它的成员不会被逐个单独加密——它们是以解密后的明文形式打包进去的,对组装好的容器整体做一次加密即可覆盖它们,字符串也包含在内。如果对成员再做一次加密,就会生成一个解密后仍是密文的文件,而且由于外层加密解密能顺利通过,失败会以对象图深处的解析错误的形式出现,而不是以身份验证失败的形式出现。有一个对象则完全被排除在这套方案之外:在加密文档中,Catalog始终保持为直接的type-1对象,绝不打包,因为打包它会迫使加载器在到达文档根节点之前,就必须先解压并解密一个对象流——而文档根节点本身又是帮助建立解密上下文所必需的
如何从Delphi中开启打包
公开的开关是PackObjectStreams,它以TPDFlibSaveOptions上的一个字段形式暴露,也有独立的设置函数SetPackObjectStreams,以及文档对象上的一个属性。它默认启用,并由版本号自动把关:只有当文档已经是PDF 1.5或更高版本时,写入器才会打包,并且会调用内部的最低版本守卫,确保一份被打包的文档会被提升到1.5,而不是被错误标注版本号。保存之后,GetLastSaveUsedObjectStreams会报告这个门是否真的打开了,这才是你在回归测试中应该断言的东西,而不是去比较文件字节大小
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
打包与垃圾回收之间的先后顺序很重要。可达性分析必须先运行,因为一个存活下来并被打包进容器的成员,会把它所在的容器也一并带活——如果一个存活对象被打包了,它的容器号按定义就是可达的,把容器清扫掉会让这个成员失去定位方式,变成孤儿。先运行收集器还意味着已死亡的对象根本不会进入容器,这正是体积收益能够叠加放大的地方。打包是对其他体积优化手段的补充,而不是替代;PDF文件体积优化与字体子集化一文讲的是作用于流载荷的那些手段,而对象流作用的是结构层面
启用它之前值得了解的边界情况
增量保存永远不打包。增量更新会追加新对象和新的交叉引用小节,同时让更早的版本在物理上保持不变,所以把已有对象重新打包进新容器,会让上一个版本仍然引用的那些type-1条目变成孤儿;losLab PDF Library只要处于追加模式就会禁用打包,增量更新与追加模式流式写入一文完整覆盖了这条路径。低于PDF 1.5的文档会无条件保留纯文本交叉引用表:1.4版本的消费者根本不知道/ObjStm是什么,仅仅因为写入器偏好更小的文件就悄悄把文档版本升级,是在替调用方做一笔不该做的错误交易。有一个可选的键我们刻意不写出,那就是/Extends,ISO 32000-1 §7.5.7定义它是为了让一个容器可以指名一个前驱,从而让读取器能把一串容器当作一个逻辑组来处理。它确实是可选的,我们写出的每个容器都是自包含且可独立解码的,跳过它就从写入器这边消除了一整类环引用和悬挂引用的错误——不过读取器在遇到其他生产者写出的文件中带有/Extends时,当然仍然必须遵循它
对象流打包与交叉引用流输出,随同与之组合使用的垃圾收集器与内容流优化器,一起作为面向Delphi与C++Builder的losLab PDF Library的一部分提供;产品页提供了完整的保存选项参考