技术文章

Delphi 中惰性加载的 PDF 对象流成员与完整重写

当 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 字典和结构元素不用,它们保持为记录,直到某次页面渲染或重写碰到它们

HotPDF Delphi Component 在压缩成员被解析之前如何存放它示意图:FCompactObjects 记录保存着对象号、容器索引、成员索引和一个 nil 的 ParsedObject 指针,而 EnsureCompressedObjectLoaded 通过缓存命中、容器重载、偏移表切片和零拷贝解析把一条记录变成已注册的对象
LoadFromFile 把 /ObjStm 的成员体留成字节,只在有读取方来要时才解析,所以加载时间跟着你碰过的东西走——catalog 和页面树早早到位,而字体、颜色空间和结构元素保持为记录

你可以从外部观察这件事。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 会直接走到写入器,而每个没被碰过的成员仍然未解析

HotPDF 完整重写展开所处的位置示意图:SaveToStream 先让每个 FCompactObjects 条目过一遍 EnsureCompressedObjectLoaded,再分派到经典、打包或线性化写入器,所以无论走 SaveLoadedDocument 那套词汇还是走 LoadFromFile 加 BeginDoc 加 EndDoc 那套词汇,序列化出来的都是完全解析的对象而不是 nil 记录
增量更新在原始字节之后追加并让旧容器仍可读,而完整重写会把它们丢掉——在所有写入器分支之上跑一趟展开,正是它挡住了一个未加载的字体或结构元素被序列化成空
// 两套重写词汇现在都会在任何写入器运行之前展开紧凑成员
// 已加载文档路径:
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 在已加载 PDF 上的解密隔离如何运作示意图:解密抛异常的容器被记成一条带 osqrDecryptFailed 原因和其成员对象号的 THPDFObjStmQuarantineInfo,彼此独立的容器继续加载,而 BeginDoc 在第一个失败条目上抛异常,不让重写报出成功
隔离记录能挺过解析器回退,而 BeginDoc 是按名字而不是按加密标志来检查它们的,于是一份有一个损坏容器的文档仍然能打开,而重写路径会停下而不是写出空对象

隔离列表能挺过解析器回退。如果主交叉引用加载失败,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 参考的链接