技术文章

用 PDFiumPas 在 Delphi 中做稀疏惰性 PDF 对象索引

你只想从一个 2 GB 的 PDF 里取一个字典,工具却先把整张交叉引用表展开成按尾节 /Size 定大小的数组。PDFiumPas 用稀疏惰性对象索引替换那一步:它只保留 xref 段描述符,通过有界窗口按需解析单个对象号,只缓存你真正碰过的条目

FPdfCompress 里这段代码的旧形态诚实但昂贵。ApplyDefaultOpenAction 把整个文件读进一个 TBytes,然后分配一个稠密的 TPdfActiveXrefEntries 数组,直到 /Size 的每个对象号一个槽。规模一大,两件事出问题。即使调用方只想要四个字典,读取成本也随文档大小线性增长;稠密数组还与解析器预算相撞:TPdfParserResourceBudget.DefaultMaxObjects 设为 4,000,000,于是一个最高对象号在那个天花板之上的完全有效文件,因内存理由而非正确性理由被拒绝

PDFiumPas 在 Delphi 中的稀疏惰性对象索引与稠密交叉引用数组对比:稠密路径读整个文件并按直到尾节大小的每个对象号分配一个槽,而稀疏路径只保留段描述符
只有描述符留在内存,条目留在文件里,每次读取都走有界的 1 MiB 窗口

为什么 PDFium 公共 API 回答不了这个问题?

因为信息存在于 PDFium 内部,但从不跨过 C 边界。CPDF_Parser 在内部维护交叉引用表、对象流成员关系和修订优先级,但发布的头文件没有暴露任何入口点接收对象号并返回其原始偏移、世代号、哪个修订胜出、或它住在哪个 ObjStm 里。保存侧同样封闭:FPDF_SaveAsCopyFPDF_SaveWithVersion 只给你一个顺序写回调。因此原生保存后对目录的任何字节级补丁都必须在 Pascal 层构建,这就是 PDFiumPas 自己解析这些结构而不是复用 DLL 的原因

稀疏索引到底在内存里留什么?

描述符,不是条目。对于经典表(ISO 32000-1 §7.5.4),TPdfSparseXrefSubsection 存第一个对象号、对象数、条目行开始的字节偏移和测得的条目宽度。条目本身留在文件里。宽度从第一行测得,而不是假设为 20 字节,因为生成器对行尾意见不一;PDFiumPas 接受 18 到 64,拒绝此带之外的任何宽度,以及声明计数会越过流末尾的任何子段。对于交叉引用流(§7.5.8),段保存三个 /W 字段宽度(每个限制在 0 到 8)、扁平化的 /Index 对,以及解码后的条目字节,其预期长度在任何一个字节被解压之前就从 /W/Index 算出

整个索引由 Initialize 从一个至多 1 MiB 的尾部窗口构建,startxref 就在那里找到,之后每次对象读取都用 1 MiB 对象窗口。原始流上限是 64 MiB,单行 xref 不得超过 1024 字节。如果你读过我们关于用 PDFiumPas 验证对象流和交叉引用流的笔记,同样的字段宽度纪律适用于此,只是如今它用于定位一个条目,而不是审计整张表

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { 只遍历 startxref、/Prev 链和目录 }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

一次查找如何到达一个对象?

靠算术,两种布局都是。经典子段是定宽行,所以条目的地址是子段起点加对象偏移乘以测得宽度;PDFiumPas 然后读那一行,解析十位偏移和五位世代号,按 §7.5.4 的 65535 天花板检查世代号,并把尾部关键字分类为 axkDirectaxkFree。交叉引用流需要多一步,因为 /Index 子段在解码字节串里拼接,所以索引先累加前面子段的计数,再乘以求和后的 /W 宽度。类型 1 产出偏移,类型 2 产出对象流号和成员索引,其他任何东西成为 axkUnknown 而不是猜测

{ 经典表,ISO 32000-1 第 7.5.4 节 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ 交叉引用流,ISO 32000-1 第 7.5.8 节 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

两条路径里没有任何东西与 /Size 成比例。这就是重写的全部要点:尾节大小值作为元数据向前携带,写增量修订时使用,但它从不驱动分配。回归套件用一个夹具钉住这一点:其页面树住在对象 1,000,000,000 和 1,000,000,001,尾节声明 /Size 1000000002。旧的稠密实现拒绝那个文件;稀疏索引解析两个引用,并在输出尾节中保留声明的大小

PDFiumPas 在 Delphi 中如何解析一个对象号:经典交叉引用表乘以测得行宽,而交叉引用流先累加前面子段的计数,再乘以 /W 数组求和后的字段宽度
两次查找都是纯算术,所以没有一个与尾节声明的对象数成比例

混合修订、/Prev 链及周围的守卫

修订优先级是朴素惰性索引出错的地方。PDFiumPas 从 startxref 按最新优先顺序遍历链,一次查找在第一个回答的段处停下,这在不物化合并表的情况下复现了优先级规则。混合引用文件(§7.5.8.4)在经典分支内处理:尾节带 /XRefStm 时,补充流段在引用它的经典段之前注册,于是普通表看不见的压缩对象仍能找到,而经典条目保持其地位。更旧的修订然后通过 /Prev 跟进

两个守卫约束那次遍历,在损坏文件上两者都要紧。每个访问过的偏移都被记录,所以指回链内的 /Prev 会终止而不是空转,遍历深度由 MaxRecursionDepth 封顶,默认为 1024。加密标志跨整条链累加,而不是只从最新尾节读取,因为最新尾节省略 /Encrypt 的文档在更深处仍可能加密;追加修订的调用方依赖那个标志拒绝把明文对象写进加密文件

PDFiumPas 在 Delphi 中如何遍历混合 PDF 修订链:段从 startxref 起按最新优先注册,补充 XRefStm 段排在点名它的经典表之前,/Prev 遍历由已访问偏移和深度上限约束
查找在第一个回答的段处停下,这在从不物化合并表的情况下复现了修订优先级

类型 2 条目:对象流为什么等待

类型 2 条目点名一个对象流,在调用方请求它的某个成员之前,PDFiumPas 不碰那个流。最终碰时,先验证 /Type /ObjStm,按对象预算检查 /N、按解码字节上限检查 /First,并对 /N/First 做健全性检查,因为每个头部对至少需要四字节。只有那时流才被解压,头部扫描停在所请求的成员及其后继处,而不是建完整成员表。一次只保留一个解码后的对象流,当页面树分支聚进单个 ObjStm 时这是正确的取舍;我们关于Delphi 中对象流与预测器解码的文章讲了那一步解压内部发生什么(§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { 一个保留索引,多次感知世代的读取 }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { Source 仍归你所有 }
  end;
end;

缓存不再许诺的地方

索引是快照,这一点值得直说。段在 Initialize 中解析一次;如果底层流之后被修改,每个缓存条目都是陈旧的,类不会察觉。TPdfSparseDictionaryReader 在源由调用方拥有的生命周期内持有索引,这正是页面树递归遍历想要的,也正是你跨重写绝不能做的。条目缓存是线性搜索的扁平数组,它也存负面结果,所以几百次查找便宜、几十万次不便宜。ReadDictionary 要求精确世代匹配,而 ReadLatestDictionary 解析活动的那个,区别是刻意的:引用解析需要前者,目录检查需要后者。在这些界限无法兑现的地方,周围单元回退到遗留的整文件解析器,而不是缩窄仍能工作的文件集合,我们在按需流式处理大型 PDF中也用同样的模式

跨编译器回归在所有三个工具链上覆盖同样行为,包括断言 2 MiB 的源从不见到一次大于 1 MiB 的读取。如果你维护直接碰 PDF 结构的 Delphi、C++Builder 或 Lazarus 代码,并且厌倦了为四个字典付整文件解析成本,稀疏索引和它周围的公共接缝随 PDFiumPas Delphi PDFium 组件一起交付