当 HotPDF Delphi Component 用 LoadFromFile 加载一份 PDF 1.5 文件时,它并不解析打包在 /Type /ObjStm 容器里的对象。它只记下每个压缩成员住在哪里,等到有东西来要它时才解析。正是这条惰性不变式让加载时间与你真正碰过的东西成正比,也正是它让一次完整重写必须在任何字节出去之前多做一件事:把每一个还没解析的成员展开,因为这次重写马上要把这些成员所住的容器丢掉
促成这篇笔记的症状很容易描述,调试起来却不愉快。加载一份字体、颜色空间和结构树都住在对象流里的文件,把它过一遍 BeginDoc 与 EndDoc 的生成流程,输出打开时没有任何抱怨。页数是对的,随手抽查的页面上文字可见。然后同事打开第 40 页,正文用一种替换字体渲染,或者「提取文本」命令在本该是一个 ActualText 替换的地方返回乱码。没有任何东西崩掉。写入器只是序列化了一个从未被加载的对象,而未加载的对象序列化出来就是空
对一个压缩对象,LoadFromFile 到底留了什么?
对每一个 type-2 交叉引用条目,LoadFromFile 在 FCompactObjects 里留一条小记录:对象号、所在流在容器表里的索引、成员在那个流里的位置,以及一个初始为 nil 的 ParsedObject 指针。容器本身会被定位、在文档加密时被解密、被解压,但成员体保持为字节。ISO 32000-1 §7.5.7 定义了让这一切成为可能的容器布局:一个由对象号与偏移量对组成的头部,然后是拼在 /First 之后的各个成员体,所以任何一个成员都能在不碰邻居的情况下被切出来
EnsureCompressedObjectLoaded 是唯一一条把记录变成对象的路径。它按对象号找到记录,如果 ParsedObject 已经设好,就返回那个缓存对象并计一次缓存命中。否则它在容器被逐出时重新加载容器,按偏移表算出成员的字节范围,把那个切片的一份零拷贝视图交给解析器,再把结果存回记录。此后这个对象就是间接的、带着它真实的对象号,并且像任何从文件主体解析出来的对象一样注册进文档的对象索引。catalog、info 字典、页面树根和页面对象在加载时就走这条路径,因为导航需要它们。字体、颜色空间、ExtGState 字典和结构元素不用,它们保持为记录,直到某次页面渲染或重写碰到它们
你可以从外部观察这件事。GetLoadedObjectStreamCacheInfo 报告有多少个容器、索引了多少个成员,以及其中到目前为止解析了多少个:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
在一份结构很重的文件上,第三个数字在刚加载完时只是第二个数字的一小部分。这个差额正是惰性加载的全部意义,而它同时也正好是完整重写必须回头去找的那批对象
为什么完整重写会丢掉增量保存能留住的字体?
完整重写会丢弃源文件的 /ObjStm 和 /XRef 容器,并从零重新序列化对象图,所以任何 ParsedObject 仍为 nil 的成员在输出里没有任何表示。增量更新从来没有这个问题,因为它在原始字节之后追加新对象,并把旧容器留在原处供前一个交叉引用段寻址。差别不在两种模式如何对待字体。差别在于原始容器能不能活下来被下一个查看器读到
修法住在 SaveToStream 里,这个序列化器由 EndDoc 驱动,不管你设的是 FileName 还是 OutputStream。在分派到任何写入器分支之前,它遍历 FCompactObjects,对每个条目调用 EnsureCompressedObjectLoaded。如果有成员加载不了,保存会抛异常而不是继续,因为一次悄悄丢掉字体字典的重写比一次停下来的重写更糟。展开必须放在这个层次上,位于经典分支、打包分支和线性化分支之上,也位于线性化路径对重新加载的结构流所做的裁剪之上。早期版本只在 SaveLoadedDocument 内部展开成员,那只覆盖了已加载文档那套词汇,完全漏掉了生成那套词汇。LoadFromFile 后接 BeginDoc、页面编辑和 EndDoc 会直接走到写入器,而每个没被碰过的成员仍然未解析
// 两套重写词汇现在都会在任何写入器运行之前展开紧凑成员
// 已加载文档路径:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// 针对已加载文件的生成路径:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream 先把每个 FCompactObjects 条目实体化
被缓存的成员保留你对它们做过的任何事。一个在保存之前被解析、编辑并标记为脏的对象,会带着它的编辑从缓存返回,而你删掉的成员会在反复保存中保持它的删除状态。这一趟展开按构造就是幂等的:它只会填 nil 槽
为什么只查三页像素会漏掉 ActualText 的情况
结构元素是这个 bug 藏得最久的地方。标记内容序列上的一个 ActualText 条目,ISO 32000-1 §14.9.4 有定义,它替换的是用于提取和无障碍的字形,而不影响渲染。如果那个结构元素住在对象流里,而重写丢了它,页面照样画得对,首页、中间页和末页与源文件逐像素比较都一样,只有等到有人跑文本提取或读屏软件时回归才显形。一个只渲染页面的重写测试不是针对带标签 PDF 的重写测试。请把提取出的文本和结构树也一并比较
空的用户密码如何改变加载?
空的用户密码仍然意味着文件是加密的,而这种文件里的对象流在文件密钥被恢复之前就是密文。ISO 32000-1 §7.6.3.4 的 Algorithm 2 从密码、/O 条目、/P 和第一个文档标识符派生那个密钥,而 HotPDF 必须在 type-2 那一趟能解压任何一个容器之前拿空字符串跑一遍它。这就是为什么在一个已加载的加密文档上,BeginDoc 会先带空密码调用 DecryptLoadedDocument:不管调用方是否打算保护输出,对象图都必须先被认证和解密,重写才能开始。输出加密是另一个决定,由调用方的保护设置驱动,而 BeginDoc 在解密这一趟之后会恢复那些设置,好让一份加密的输入不会悄悄变成一份加密的输出
容器策略是在试任何密码之前从 /Encrypt 字典读出来的。对 /V 1 和 2,每个流都用文件密钥加密。对 crypt filter,HotPDF 通过 /CF 解析 /StmF:Identity 过滤器或 /CFM 为 None 意味着明文容器,而 V2 和 AESV2 意味着加密容器。答案落进 FReloadObjectStreamsEncrypted,而它在一个特定情形下很重要。当容器是明文而字符串不是时,成员携带的是需要逐个解密的加密字符串,所以 MaterializeMembersOfPlaintextObjectStreams 会在逐对象解密那一趟之前把每个紧凑成员展开。策略还不知道时它什么都不做,容器本身被加密时它也什么都不做,因为加密容器的成员已经随着容器一起被解密过,绝不能再解密第二次
一个容器解密失败时会怎样?
解密失败的容器会被隔离,而不是致命。type-2 那一趟在 FObjStmQuarantine 里记一条 THPDFObjStmQuarantineInfo,包含容器的对象号、一个 THPDFObjStmQuarantineReason、一段诊断字符串,以及交叉引用路由进它的那些成员对象号列表。osqrDecryptFailed 会为四种不同情况抛出:解析不出任何 crypt filter、AES-256 或 AES-GCM 解密抛了异常、旧的 RC4 或 AES-128 解密抛了异常,或者根本没有可用的文件密钥。彼此独立的容器继续加载,于是一份有一个损坏容器的文档仍然能打开,也仍然能渲染每一页不依赖它的页面
隔离列表能挺过解析器回退。如果主交叉引用加载失败,HotPDF 改为扫描文件重建对象表,第一次尝试得到的加密标志可能在这次重建里活不下来,但隔离记录活得下来。这就是为什么 BeginDoc 检查的是隔离列表而不是加密标志:在已加载文档上它遍历 FObjStmQuarantine,在第一条 osqrDecryptFailed 条目上抛异常,点名那个容器并要求用有效密码重新加载。越过这一点继续走的重写会把容器本该持有的成员写成空对象,并且报告成功。你可以自己更早地、按自己的策略跑同一项检查,通过这几个公开访问器:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // 空的用户密码
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// 从这里开始重写是安全的
end;
其他隔离原因覆盖非加密类失败:容器不是流、缺字典、/N 或 /First 无效、流大小超出可接受范围、解压失败、/First 指向数据之外,或者成员体解出来了但没解析成功。这些都值得在摄入时记日志,因为每一条都点名了你在下游会缺掉的确切成员
为什么重写需要原始的数字 token?
HotPDF 把每个数值对象存成 Single,而 Single 重现不出一个实数的源文本。ISO 32000-1 §7.3.3 允许写入器对同一个值写 0.750000、.75 或 0.75,而这三个在经 24 位二进制和一个通用格式化器往返之后都不能原样存活。更糟的是,像 0.7 这样的值在 Single 里根本表示不了;它解析成最近的那个浮点值,而重新格式化那个浮点值会视数字循环的不同产出 0.69999999 或者一个四舍五入的近邻。落在一个填充色或一个 /CA 透明常数上,那就是 8 位通道里差一个计数,足以让像素比较对源文件失败,而在渐变边界上足以看得见
THPDFNumericObject.RememberSourceToken 为未修改的情形解决了这个问题。解析器在给 Value 赋值之后立刻用它调用这个方法;该方法只接受由数字、至多一个小数点和一个可选前导符号组成的 token,并把该 token 与它对应的值一起存进 FSourceValue。只要 Value 仍然等于 FSourceValue,SourceToken 属性就返回存下来的那段文本。改了这个数,token 就蒸发了,所以被修改过的值永远走既有的格式化路径,绝不输出过期文本。SaveNumericObject 先检查 SourceToken,有就原样写出,只有对那些在内存里创建或编辑过的数字才落到整数、颜色空间引用和小数分支
这条不变式很小,值得明说:你没碰过的数字用它被读进来时的字节写出,你碰过的数字由 HotPDF 自己的格式化器写出。紧凑成员跟主体对象一样受益,因为 EnsureCompressedObjectLoaded 在成员切片上跑的是同一个解析器。数字格式化本身以及它与进程区域无关这件事,写在HotPDF 区域无关 PDF 数字格式化那篇里
针对对象流测试重写路径
三项检查能抓住上面描述的每一种失败,而且都不需要 Acrobat。第一,保存之后把 IndexedObjectCount 与 MaterializedObjectCount 做对比;在一次完整重写上它们必须相等,任何差额就是一个被丢掉的成员。第二,对两份文件都做文本提取并枚举结构树,不要只是渲染,这样一个丢失的 ActualText 或一个丢失的结构元素才会以差异的形式显现。第三,用一个全新的实例加载输出并断言 GetLoadedQuarantinedObjStmCount 为零,这同时证明写入器没有产出一个读取器打不开的容器。决定 FReloadObjectStreamsEncrypted 的那些 crypt filter 组合列在 StmF、StrF 与 EFF 策略那篇里。这个故事的写入器一侧——怎么发出对象流、什么时候该选增量更新而不是重写——在对象流与增量更新指南里
惰性成员加载、写入器之前的展开趟、解密隔离以及源 token 保留,都随 HotPDF Delphi Component 一起发布,面向 Delphi 和 C++Builder。如果你想拿 GetLoadedObjectStreamCacheInfo 和那些隔离访问器对着自己的摄入管线查一遍,产品页上有 API 参考的链接