线性化 PDF 的第一个对象必须逐字节精确地声明它所在文件的长度,而且必须出现在前 1024 字节之内。ISO 32000-1 附件 F 把这个条目叫做 /L,并告诉阅读器:一旦发现它与真实文件大小不符,就把文档当普通 PDF 处理、无视里面的每一条线性化提示。所以写入器必须在一个还没写完的文件里,把它的长度印在一个提示流偏移、第一页结尾和靠文件末尾的交叉引用表位置旁边。前面任何东西的尺寸一变,这四个数就全得动
回报是 Fast Web View。普通 PDF 把交叉引用表放在末尾,浏览器拿到一份 200 页的扫描报告时,最后一个字节不到就找不到第 1 页;线性化文件把第 1 页和一小段交叉引用挪到开头,用 HTTP 范围请求的客户端就能早早把第一页画出来。本文讲写入这一侧;读取那一侧的画面在PDF 线性化与 Fast Web View 解析里
HotPDF 如何布局一份线性化文件
THotPDF.LinearizeOutput 为 True 时,HotPDF 写出线性化输出,文件按附件 F 规定的物理顺序逐部分排布:头部之后是线性化参数字典,然后是第一页交叉引用节及其 trailer、catalog 连同其他文档级对象、主提示流、带着它需要的每个对象的第一页、余下页面按序排列、后续页面共享的对象、其余一切,最后是主交叉引用表
文档模型里没有任何东西说明一个对象属于哪个部分。写入器在 EndDoc 保存时从引用图推出来:从每个页面对象出发遍历,数每个间接对象被多少页到达。只被一页到达的对象进那一页的节。被多页共享的对象归共享部分——但有一个快速读规范会漏掉的转折:第 1 页也在使用它的页里时,对象照样进第一页节,因为第 1 页必须只靠开头的字节就能渲染。只有纯粹在后续页面之间共享的对象才在共享部分等待。catalog——无论它的 /ViewerPreferences、/OpenAction、/Threads 和 /AcroForm 条目指向哪里——连同受保护文档的加密字典构成文档级一组。页面树节点被推进尾部部分,这样它们永远不会把第一页撑大
用十六进制编辑器打开输出,编号看着是倒着的。从前部块——线性化字典到提示流——拿着文件里最高的对象号,它后面的一切从 1 开始编号。前面那小段交叉引用节恰好覆盖那个高位块,主表覆盖 1 到 N
分区列表里的一个记录初始化陷阱
这样的列表自然写成「一个记录装动态数组外加一个单独的计数」,而这个形状里藏着一个 Delphi 陷阱。编译器会初始化局部记录的托管字段,坐在它们旁边的普通 Integer 什么也得不到。Win32 下栈槽里碰巧常常是零,第一次追加落在索引 0,所有测试照过;Win64 下栈槽里可能是残留值,第一次追加写进任意索引
type
TPartList = record
Values: TArray<Integer>; // 托管:由编译器清零
Count: Integer; // 非托管:栈上原来是什么就是什么
end;
procedure Append(var List: TPartList; Value: Integer);
begin
if List.Count = Length(List.Values) then
SetLength(List.Values, List.Count * 2 + 16);
List.Values[List.Count] := Value;
Inc(List.Count);
end;
procedure BuildParts;
var
FirstPage, Shared: TPartList;
begin
FirstPage := Default(TPartList); // 必需,不是装饰
Shared := Default(TPartList);
Append(FirstPage, 12);
Append(Shared, 40);
end;
HotPDF 的写入器在碰每个分区列表之前都先给它赋 Default。一次绿色的 Win32 运行,说明不了这样的记录有没有被初始化
为什么布局必须先收敛才能写出第一个字节?
因为文件前部装着指向后部的偏移,而这些偏移的位数又会挪动它们身后的所有东西。第一页 trailer 带着一个 /Prev 条目,装主交叉引用表的字节偏移。把偏移写成 99998,然后发现表其实落在 100003,trailer 就多出一位。第一页交叉引用节跟着长一位,它身后每个对象挪一个字节,主表现在坐在 100004。单趟想蒙对全凭运气
HotPDF 用两招打破循环。参数字典放进一个用空格填充的固定 384 字节槽位,/L、/H、/E 和 /T 需要多少位就用多少位,一个对象都不挪;字典文本真要超过 384 字节,写入就抛异常,而不是挪动文件。这样 /Prev 就是唯一的自由变量,它进一个不动点循环。每个对象先序列化进一个只记长度、不留字节的计数流;一轮布局按节分配偏移并报告主表会落在哪,这个答案成为下一轮的输入。提议的偏移不再移动时循环停下,八次之后仍不收敛则抛异常放弃
只有位数变化才挪动东西,所以第三轮只在十的幂边界附近才需要,比如 99998 那种情况。收敛之后,写入器再跑一轮确认,用真实偏移重建提示流并检查其长度没变——偏移坐在定宽字段里,占位版本和最终版本必须一致。然后输出直接进目标流,没有缓冲的第二份拷贝,每个节边界上都把已写字节数与承诺的偏移对一遍。对不上就从 EndDoc 抛异常
提示表,以及附件 F 读出两种意思的地方
主提示流带两张表。页面偏移提示表为每一页记录它的节装了多少个对象、该节多少字节、以及它依赖哪些共享对象。共享对象提示表列出每个共享对象组的长度。两表在手,阅读器可以直接请求第 37 页;流的 /S 条目给出共享表在流内的起始位置
附件 F F.4 里有两个细节容易弄错。其一,提示表里存的偏移是逻辑偏移——按主提示流不存在来算。第一页对象和第一个共享对象物理上都坐在提示流之后,所以写入器在记录它们的偏移之前会先减去提示对象的长度。按物理偏移记录的写入器,产出的每个范围请求都恰好差一个提示对象的尺寸
其二是一处真正的歧义。F.4 的文本把每张表描述成一条连续的位流、高位在前,并把第一页共享组和后续共享组列为两条独立序列。流通中的提示流把每一列填充到字节边界、把共享组当成一条合并的列表,而这是已知的、消费提示的阅读器都接受的形式。HotPDF 写的是字节对齐的合并布局。该选的就是这一边:一张满足 F.4 字面、却让读取它的客户端错位的表,恰恰在线性化本该见效的地方失败,而整份下载永远暴露不了这个问题
在 Delphi 里打开线性化输出
API 这一侧的开关是一个布尔值。LinearizeOutput 默认 False,写入器在 EndDoc 序列化文档时看它,所以把它和其他输出选项一起在 BeginDoc 之前设好、然后别再动。上文描述的每种失败(不收敛、参数字典超尺寸、边界不匹配、下文的 4 GiB 天花板)都从 EndDoc 以异常浮出,这让兜底非常直接
procedure WriteReport(const FileName: string; Linearize: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.FileName := FileName;
PDF.Version := pdf17;
PDF.LinearizeOutput := Linearize; // 在 BeginDoc 之前设置
PDF.BeginDoc; // 创建第 1 页
PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
PDF.AddPage;
PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
PDF.EndDoc; // 布局与写出在这里发生
finally
PDF.Free;
end;
end;
procedure PublishReport(const FileName: string);
begin
try
WriteReport(FileName, True);
except
on E: Exception do
begin
LogWarning('Linearization failed: ' + E.Message); // 你自己的日志器
WriteReport(FileName, False); // 改写普通 PDF
end;
end;
end;
兜底产出一份任何查看器都打得开的普通 PDF,损失的只是快出来的第一页。对报告管线这笔交易很划算:第一页出得慢是烦,报告出不来是事故
部署这一侧比代码更重要。线性化只在服务器兑现 HTTP Range 请求时才有回报;一个把文件整个流出去的端点,只让你白付更慢的写出和更大的文件。会被发邮件、当附件、或者根本没人看就下载走的文件,让 LinearizeOutput 保持关闭。浏览器直接从 URL 打开的文档才打开它——先确认响应带着 Accept-Ranges: bytes
为什么 LinearizeOutput 会关掉对象流?
因为每条提示表条目描述的是一个字节范围,而装进 /ObjStm 容器的对象没有自己的字节范围:它只是另一条必须整体取回并解压的压缩流里的一片。启用 LinearizeOutput 时,HotPDF 写传统的文本交叉引用表和不打包的间接对象,哪怕 UseXRefStream 或 UseObjectStreams 也设了。线性化路径在保存时优先,你的代码没有需要仲裁的冲突
代价是文件体积:对象流本可以压在一起的小字典现在各自散着。如果紧凑输出比第一页延迟更重要,这笔取舍在HotPDF 中的对象流与增量更新里有铺开。Office 导出器从另一头撞上同一股张力,这也是它们中一些会发出Office 生成的 PDF 里的混合交叉引用流那种布局的原因
线性化还需要 PDF 1.2 或更高。Version 更低时,HotPDF 在保存时把文档抬到 1.2。设了 StrictVersionLock 则改为抛异常——你刻意钉死的版本不该在背后被改掉
线性化输出的 4 GiB 天花板
提示表用 32 位字段存偏移和长度,所以线性化文件无法寻址 4 GiB 之外的任何东西,HotPDF 拒绝产出这样的文件,而不是写出会回绕的偏移。天花板来自附件 F 定下的字段宽度
检查在三个点跑,全部发生在第一个字节抵达目标之前:每个对象在其序列化长度确定后、每个页面节在构建提示条目时、最终文件长度在主交叉引用表定尺寸后。回绕的偏移是最糟的失败方式:整份下载的查看器可以无视提示,字节范围客户端却跳到错误的字节,于是桌面测试永远复现不了故障。多 GB 的输出,请看面向大型 PDF 工作流的 Direct File API 那条流式路线
检查进来的文件是否线性化
THotPDF.IsLoadedLinearized 报告已加载的文档是否自称线性化。检查发生在解析之前:HotPDF 从源头位置 0 读至多 1024 字节,找到第一个对象,并在其中找值为 1 的 /Linearized。注释、字面字符串和十六进制字符串被跳过,所以名字出现在字符串里不算数,扫描在 stream 或 endobj 处停止。只接受 1 或 1.0 这样的等价值
答案在加载时缓存,因为等应用代码来问时,解析器早已挪动了流位置,LoadFromFile 也已经释放了自己的源流
这个标志报告的是文件的声明。附件 F 还加了第二个条件:/L 必须与实际文件长度一致,而线性化之后追加的增量更新会破坏这种一致,阅读器于是退回把文件当普通文件。接受那些会把保存追加到文件末尾的编辑器产物的管线,可以自己把两个数对一遍
// 你自己的帮手,不是 HotPDF 的一部分:比较 /L 与真实大小
// 简化解析;假定 /L 后面是一个空格
function DeclaredLengthMatches(const FileName: string): Boolean;
var
FS: TFileStream;
Head: AnsiString;
ActualSize: Int64;
P, Q: Integer;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
ActualSize := FS.Size;
SetLength(Head, 1024);
SetLength(Head, FS.Read(Head[1], 1024));
finally
FS.Free;
end;
P := Pos(AnsiString('/L '), Head);
Result := P > 0;
if not Result then
Exit;
Inc(P, 3);
Q := P;
while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
Inc(Q);
Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;
procedure InspectUpload(const FileName: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
if PDF.LoadFromFile(FileName) <= 0 then
Exit;
if not PDF.IsLoadedLinearized then
Writeln('Not linearized')
else if not DeclaredLengthMatches(FileName) then
Writeln('Linearized, but modified after linearization')
else
Writeln('Ready for Fast Web View');
finally
PDF.Free;
end;
end;
LinearizeOutput 和 IsLoadedLinearized 都是面向 Delphi 和 C++Builder 的 HotPDF Delphi Component 的一部分,产品页文档说明了它们与交叉引用流、对象流和版本锁定的互动方式