HotPDF 通过 THotPDF 上的 LinearizeOutput 属性写出线性化 PDF 文件,也就是 Acrobat 标注为 Fast Web View 的那种布局。在 BeginDoc 之前设置这个属性,会让 HotPDF 对写完的对象图重新排序,使支持按字节范围请求的阅读器只需取回文件开头一部分,就能显示第一页,而不必先下载整份文档。这个机制对应的是 ISO 32000-1 附录 F
这个问题之所以重要,原因并不光鲜。一份普通的 PDF 把交叉引用表放在末尾,所以阅读器必须读到最后一个字节才知道任何东西在哪里。把一份 200 页的扫描报告丢给浏览器,用户就得盯着转圈图标看完整个传输过程,即便他们想要的只是第一页。线性化通过在写入时多付出一点成本来解决这个问题。本文专门讲这条写入路径本身——分区、测量循环和硬性限制;关于 Fast Web View 到底能带来什么好处的概念性背景,早先那篇PDF 线性化与 Fast Web View 说明已经覆盖了
线性化布局究竟保证了什么
一份线性化文件是一份普通的 PDF,只是带有一种极其特定的物理排列顺序,它提供的每一项保证都来自这个排列顺序本身,而不是来自任何新的对象类型。HotPDF 按附录 F 规定的顺序输出各个部分:线性化参数字典位于文件前 1024 字节内、一份提前出现的交叉引用表、文档级对象、主提示流、第一页及其私有对象,接着是其余页面、然后是共享对象、然后是其余的一切,最后是主交叉引用表
这个分区是推导出来的,不是声明出来的。HotPDF 从每一个页面对象出发遍历引用图,为每一个间接对象记录有多少个页面能到达它、以及哪个页面最先到达它。恰好被一个页面用到的对象,就成为该页面的私有对象。被一个以上页面到达的对象,就成为共享对象。目录,加上它在 /ViewerPreferences、/OpenAction、/Threads 和 /AcroForm 下引用的一切,再加上启用保护时的加密字典,共同组成必须排在最前面的文档级分组。页面树节点被刻意保留在后面,以免污染第一页那个区段
参数字典携带的是阅读器在读到其他任何内容之前就需要知道的数字:/L 表示文件总长度,/H 表示提示流的偏移量和长度,/O 表示第一页的对象号,/E 表示第一页区段结束的字节位置,/N 表示页数,/T 表示主交叉引用表条目的偏移量。这里的每一个数字,在你需要写出它们的那一刻,都是指向一份尚不存在的文件的字节偏移量
为什么提示表的偏移量必须收敛
因为参数字典里的数字描述的正是包含它们自身的这份文件,改动其中任何一个都会改变文件本身。这正是线性化写入器最核心的难点,也是为什么 HotPDF 要反复测量而不是一次写完。把 /T 从 6 位数扩展到 7 位数,参数字典就会多出一个字节;文件头随之变大;每个对象都会移位;主交叉引用表随之挪动;/T 现在又需要一个不同的值。布局必须先收敛到一个不动点,才能落笔写下一个真正输出的字节
HotPDF 用一种有上限的迭代来处理这个问题。它先把每个对象序列化进一个只计数不保留字节的计数流,从而得到每个对象已知的序列化长度。然后跑一遍布局,把偏移量分配给文档级分组、提示流、第一页分组、其余页面分组、共享分组和剩余部分,并报告主交叉引用表落在哪里。这个结果会被反馈回去,作为下一轮的输入。这个循环最多尝试八次,一旦不收敛就抛出异常,而不是生产出一份带着"看着挺像那么回事、实际错误"的偏移量的文件
CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
CalculateLayout(CandidateMainOffset, FirstXRefData,
HintOffset, EndFirstPage, NewMainOffset);
if NewMainOffset = CandidateMainOffset then
Break;
CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
raise Exception.Create('Linearization layout did not converge');
有两个细节让这个循环不至于反复抖动。参数字典被写进一个固定的 384 字节槽位,用空格填充,所以字典本身的增长永远不会破坏布局稳定性;如果字典文本一旦超出这个预留空间,HotPDF 会直接抛出异常,而不是悄悄把其他一切都往后挪。收敛之后,HotPDF 还会再跑一遍确认性的布局,并重新检查提示流的长度,因为提示流本身编码的偏移量,只有在布局稳定下来之后才是确定的。所有这些测量工作换来的好处是,HotPDF 永远不需要在内存里再缓存一份文档副本:一旦偏移量确定,对象就直接序列化进目标流,并且在每个区段边界都有断言检查写入的字节数和当初承诺的偏移量是否一致
从 Delphi 中开启它
API 只有一个布尔值,唯一的要求是在生成开始之前设置它。LinearizeOutput 默认是 False,布局这一遍是在文档写出时执行的,所以在 EndDoc 之后再赋值不会有任何效果
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.FileName := 'fast-view.pdf';
PDF.Version := pdf17;
PDF.LinearizeOutput := True; // must precede BeginDoc
PDF.BeginDoc;
PDF.Canvas.TextOut(72, 72, 'First page');
PDF.EndDoc;
finally
PDF.Free;
end;
end;
有一个部署层面的注意事项,重要性压过代码层面的一切。线性化只有在传输层支持 HTTP 范围请求时才有回报。如果把同一份文件放在一个整份流式传输的服务端点后面,或者放在一个忽略 Range 头的 CDN 配置后面,你换来的只是一条更慢的写入路径和一份更大的文件,而用户端毫无收益可言。先检查服务器,再检查代码
为什么线性化会覆盖 UseXRefStream 和 UseObjectStreams
因为线性化写入器需要每一个对象都拥有各自可直接寻址的字节偏移量,而这两项特性恰好都会破坏这一点。因此只要 LinearizeOutput 被启用,HotPDF 就会输出传统的文本交叉引用表和未打包的间接对象,即便调用方也设置了 UseXRefStream 或 UseObjectStreams。这是刻意的覆盖行为,不是需要你自己去解决的冲突
这个道理源自提示表本身。提示表描述的是一个页面区段从哪里开始、长度是多少,好让阅读器能精确地请求那一段范围。一个被打包进 /ObjStm 容器的对象根本没有独立的偏移量;它只作为另一个压缩流内部的一个切片存在,必须整体取回并解压才能用。如果你原本指望靠对象流来控制文件体积,请明白线性化和压缩在这里是朝相反方向拉扯的,具体权衡可以参考姊妹篇文章HotPDF 中的对象流与增量更新。同样的张力也塑造了混合引用文件的存在,它正是为了让老阅读器和基于流的表能并存工作,详情见Office 生成 PDF 中的混合交叉引用流一文
还有一个版本下限。线性化要求 PDF 1.2 或更高版本。如果当前选定的版本更旧,HotPDF 会自动把它提升上去,除非设置了 StrictVersionLock,在那种情况下写入会直接抛出异常,而不是悄悄提升一个你特意锁定过的文档版本
4 GiB 的墙,以及 HotPDF 为什么拒绝而不是截断
线性化提示表把偏移量存成 32 位数值,所以一份线性化文件无法寻址到 4 GiB 及以上的位置,HotPDF 会用一个明确的异常拒绝这样的输出,而不是写出一份带着回绕偏移量的文件。这个限制不是 HotPDF 自己的实现选择,而是附录 F 所定义字段的位宽本身
这个检查在三个地方都会执行,三处都很重要。HotPDF 会在每个对象的序列化长度确定后校验它,会在构建提示表条目时校验每个页面区段的长度,也会在主交叉引用表确定大小之后校验最终文件长度。尽早失败正是关键所在:一份带着悄悄被截断的偏移量的提示表,在整体下载的阅读器里会打开得毫无问题,只会在按字节范围请求的客户端上失败——而按字节范围请求的客户端,恰恰正是线性化本该服务的对象,这是最糟糕的失败模式,因为你测试用的阅读器永远复现不出这个问题。如果你要生成的是数吉字节级别的输出,线性化就不是合适的工具,面向大型 PDF 工作流的 Direct File API 笔记里描述的流式方案才是该看的方向
检测已加载文件是否经过线性化
THotPDF.IsLoadedLinearized 用来报告当前加载的文档是否已经以线性化形式写出,它给出的答案来自解析之前拍下的一份快照,而不是来自实时流。HotPDF 从源流位置零开始读取前 1024 字节,在其中扫描第一个 obj 关键字,再查找一个值为 1 的 /Linearized 条目,然后把这个布尔结果缓存起来
var
PDF: THotPDF;
PageCount: Integer;
begin
PDF := THotPDF.Create(nil);
try
PageCount := PDF.LoadFromFile('incoming.pdf');
if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
Writeln('Source is not Fast Web View ready');
finally
PDF.Free;
end;
end;
这段描述里有两个约束是承重结构。检测过程不能依赖流的位置,因为等到应用代码来问这个问题的时候,解析器早就把流位置挪动过了;也不能按需重新读取,因为 LoadFromFile 一旦加载完成就会释放内部的源流。因此才有了"解析前先捕获并缓存"这种设计。这个扫描过程对取值也刻意做得很字面:只接受 /Linearized 1,或者小数部分全为零的数值等价形式,因为一份参数字典写着别的值的文件,根本没有做出附录 F 所承诺的那种保证
一个值得学一手的 Delphi 记录陷阱
包含动态数组的局部记录,只会初始化它托管的那些字段,别的什么都不会初始化,如果你在数组旁边还放了一个普通的 Count 字段,就必须自己去清零它。这个坑在线性化分区功能的开发过程中被踩中过,而这正是那种会因为某个平台把问题藏起来而白白耗掉一天的 bug
type
THPDFLinearIndexList = record
Values: THPDFIntegerArray; // managed field: cleared for you
Count: Integer; // plain field: whatever was on the stack
end;
// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);
动态数组字段是引用计数的,所以编译器会把它清零。旁边的 Count 是一个普通整数,没有这层保证,一个未初始化的 Count 会让第一次追加操作落到一个随机的索引上。在 Win32 下,那个栈槽位恰好是零,追加操作正好落在索引 0,于是每个测试都通过了。在 Win64 下,同样的代码却写到了数组末尾之外。这个教训远远超出线性化本身的适用范围:当一个记录混杂着托管字段和非托管字段时,就用 Default(TRecord) 赋值,别再去纠结编译器到底覆盖了哪些字段,也永远不要把 Win32 下跑绿了当作初始化正确的证据
这里描述的 LinearizeOutput 和 IsLoadedLinearized 成员,随标准版 HotPDF Component 一起提供,面向 Delphi 和 C++Builder;产品页带有完整的属性参考,包括与交叉引用流、对象流和版本锁定之间的交互规则