技术文章

为什么 Delphi 中一些 PDF 对象流会解码成乱码

一个能够无错误完成解压、读取结果却仍像噪声的 PDF 对象流,通常少了一个步骤:还原 ISO 32000-1 定义的 Predictor。当流的 /DecodeParms 字典包含 /Predictor 2 或更高值时,FlateDecode 返回的字节并不是原始数据,而是 PNG 风格的按行差分值或 TIFF 风格的水平差分值,必须再执行一次还原,字典查找才有意义。PDFiumPas 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件库,它在 v2.16.0 中加入了这一步还原,正是因为 PDF 1.5 及更高版本的对象流会展开成差分字节,任何字典解析器都无法读取

为什么仅执行 FlateDecode 还不够

FlateDecode 本身只是 DEFLATE 解压(ISO 32000-1 §7.4.4.1):它只还原编码器交给压缩器的字节,不会做更多处理。Predictor 位于更上一层的流 /DecodeParms 字典中,它描述编码器在压缩前执行的变换——差分会把交叉引用流或对象流中紧密排列的整数等相似结构值转换成长串小数字,使 DEFLATE 更容易压缩。ISO 32000-1 §7.4.4.3(表 8)明确规定,还原该变换属于过滤流解码的一部分,而不是可选的清理步骤,但编写一个只调用 inflate、执行完就停止的 FlateDecode 辅助函数仍然很容易

掌握这个现象后,它的症状其实很有辨识度。Predictor 差分字节并不是随机噪声,它们仍保留着压缩流的形状,因此粗略的解析器常常会先读过几个看似有效的标记,随后才遇到不可能属于 PDF 名称、数字或分隔符的字节序列;而且不同的行会在不同偏移处失败,具体位置取决于底层值与相邻值之间实际有多大差异。正是这种不一致,让问题很难从一个失败文件中定位:同一生成器生成的两个 PDF 可能只是在重复值上有所不同,一个几乎碰巧解析成功,另一个却会彻底失败

PDF Predictor 参数究竟做什么

/DecodeParms 中的 /Predictor 条目会告诉符合规范的读取器应执行哪种还原,而 ISO 32000-1 表 8 定义了实践中最重要的取值:1 表示未应用预测,2 选择 TIFF Predictor 2(水平差分),10 到 15 之间的任意值选择 PNG 风格预测。另有三个键与它一起使用——/Colors/BitsPerComponent/Columns——它们共同描述差分计算所依据的行几何,即使流中完全没有图像数据也一样:对象流不是图片,但 PDF 编写器会复用同一套基于行的预测机制,因为先做差分再执行 deflate,比直接压缩紧密排列的整数和对象偏移量更有效

TIFF Predictor 2 是两种方案中更简单的一种:每个分量都保存为同一行前一个像素中对应分量的差值,每行从左边界重新开始,不会携带上一行的差值。PNG 预测更为复杂,因为实际过滤器可以逐行变化:每行开头都有一个标签字节——0 表示 None,1 表示 Sub,2 表示 Up,3 表示 Average,4 表示 Paeth——具体行如何还原由该标签决定,而不是由声明的 /Predictor 值决定。/Predictor 为 12,实际上只是编码器偏好 Up 过滤器的提示;Up 会通过加上前一行正上方的字节来还原每个字节,但正确的解码器仍必须读取每一行的标签,而不能假设所有行都使用 Up

为什么遗漏 Predictor 会在对象流中悄悄发生

对象流会放大这个问题,而不只是简单重复它。ISO 32000-1 §7.5.7 允许 PDF 1.5 及更高版本的编写器将多个间接对象打包到一个压缩容器 /ObjStm 中,而验证器最需要的对象——目录、/OutputIntents 或 XMP /Metadata 流——恰好经常带着 /Predictor 12 经过这个容器,因为这些对象短小且重复度高,适合采用按行差分。缺少 Predictor 步骤时,展开对象流不会引发错误,而是生成一个表面上看似合理、却无法分词为预期对象的字节序列,因此其中打包的内容就不会出现。渲染通常不会察觉,因为符合规范的渲染引擎早已在布局之前还原了预测差分数据;真正暴露问题的正是这个缺陷藏身的代码类型——验证器、签名器或版本检查器自行遍历原始 PDF 字节来回答结构问题,而一旦它对对象流的理解出错,就没有回退路径

PDFiumPas 在 v2.16.0 之前正是遇到了这种失败。使用 /Predictor 12 构建的对象流是 PDF 1.5 及更高版本编写器的常见情况,它们经过 PdfExpandObjectStreams 展开后变成结构扫描器无法解析的差分字节,因此其中打包的目录、/OutputIntents/Metadata 对象实际上对合规性扫描不可见——没有异常,没有警告,扫描只是悄悄表现得像这些对象不存在。PDFiumPas 如何根据当前交叉引用表解析对象流的更深层机制,包括混合和纯 xref 流情形,另见验证对象流和 xref 流的文章;本文介绍的 Predictor 步骤会在完成这次解析后,对每个压缩对象实际包含的字节执行还原

在 Pascal 中还原 PNG 和 TIFF Predictor 行

PDFiumPas 在一个例程 PdfApplyPredictor 中完成差分还原,无论你是调用它,还是在自己的 Delphi 代码中重新实现这个思路,都值得了解它的几何计算。行宽(以字节计)是 ceil(Columns × Colors × BitsPerComponent ÷ 8),两种算法使用的每像素字节宽度是 ceil(Colors × BitsPerComponent ÷ 8);任一舍入出错,还原就会跨过行边界读取,而不是在行内读取。低于 2 的 /Predictor 会保持不变,因为 1 表示编码器根本没有应用变换;2 选择下面展示的 TIFF 分支,10 及以上的值则进入 PNG 行过滤器还原,此时每行开头的标签字节决定该行如何还原,而不是声明的 /Predictor

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

PDFiumPas 在 v2.16.0 中做了哪些改变

PDFiumPas v2.16.0 发布的修复位于 PdfReadAndDecodeStream 内,这是为所有需要在字节层面检查 PDF 结构的调用方读取原始流字节并完成解码的例程,也包括对象流展开。它只有在确认 /Filter 是单独的 FlateDecode、而不是过滤器链之后,才会尝试还原,因为在这一层无法安全地对串联过滤器执行 Predictor 修正。从流字典中读取 /Predictor/Colors/BitsPerComponent/Columns 也不需要通用字典解析器:PdfDictRefNum 会在该字典自己的字节范围内直接搜索名称标记来找到每个键,这里之所以安全,正是因为这四个键不可能在一个流字典中重复或嵌套。将同样的名称标记搜索指向 PDF 文件中更大或边界更宽松的区域就危险得多,这正是安全解析 PDF 字典的配套文章所讨论的主题

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

在 v2.16.0 之前,使用 /Predictor 12 构建的对象流会在没有引发错误的情况下展开为差分字节,因此其中打包的目录、/OutputIntents/Metadata 对象会在 PDFiumPas 的结构扫描中消失,且没有任何警告。修复之后,同一个对象流会先完成膨胀,再正确还原,内部打包的对象也会重新出现在这些扫描中。修复同时加入了防御性边界:PdfApplyPredictor 现在会直接拒绝超过 64 的 /Colors、超过 32 的 /BitsPerComponent 以及超过 2^24 的 /Columns,因为这些组合描述的行几何不是现实中的 PDF 生产器所需,主要用途是让解码器分配远超输入字节所能证明的内存

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

需要了解的限制

在依赖 PDFiumPas 的 Predictor 还原之前,有两点限制值得了解。TIFF Predictor 2 的还原只覆盖每个分量 8 位的情况;PDF 允许更窄的打包方式,但子字节 TIFF 差分数据会保持未还原状态,而不是由程序猜测,因此当前通过这条路径解码时,声明 /Predictor 2 且 /BitsPerComponent 为 1、2 或 4 的流无法正确解码。PNG 预测没有这一限制——每行都提供自己的过滤器标签,无论声明的 /Predictor 值在 10 到 15 之间具体是多少,定义的五种类型都会被还原。这也符合 PNG 风格过滤器的实际工作方式:声明值更接近编码器主要使用哪种过滤器的提示,而不是对每一行的承诺

PDFium 的原生渲染引擎已经能够正确还原预测差分的图像数据和内容流数据,这正是文件可以在普通查看器中完美渲染、而构建在其上的字节级验证器、签名器或版本检查器却错误读取同一批字节的原因。本文介绍的 Predictor 感知解码支持 PDFiumPas 的 PDF/A 验证、结构扫描和签名功能,它是面向 Delphi 和 C++Builder 的原生 VCL PDFium 组件