Technical Article

Why Some PDF Object Streams Decode to Garbage in Delphi

A PDF object stream that inflates without error but still reads as noise is usually missing one step: reversing the ISO 32000-1 Predictor. When a stream's /DecodeParms dictionary carries /Predictor 2 or higher, the bytes that FlateDecode hands back are not the original data — they are PNG-style row-differenced or TIFF-style horizontally differenced values that need a second reconstruction pass before any dictionary lookup makes sense. PDFiumPas, the native VCL PDF component library for Delphi and C++Builder, added that reconstruction pass in v2.16.0, specifically because PDF 1.5+ object streams were expanding into differenced bytes that no dictionary parser could read

Why FlateDecode alone is not enough

FlateDecode itself is only DEFLATE decompression (ISO 32000-1 §7.4.4.1): it reproduces whatever bytes the encoder handed to the compressor, nothing more. The Predictor lives one layer up, in the stream's /DecodeParms dictionary, and it describes a transform the encoder applied before compression — differencing turns long runs of similar structured values, like the tightly packed integers inside a cross-reference stream or an object stream, into long runs of small numbers that DEFLATE compresses far better. ISO 32000-1 §7.4.4.3 (Table 8) is explicit that undoing this transform is part of decoding a filtered stream, not an optional cleanup pass, yet it is easy to write a FlateDecode helper that only calls inflate and stops there

The symptom is distinctive once you know to look for it. Predictor-differenced bytes are not random noise — they still carry the shape of a compressed stream, so a naive parser often walks past a few valid-looking tokens before hitting a byte sequence that cannot possibly be a PDF name, number, or delimiter, and different rows fail at different offsets depending on how much the underlying values happened to differ from their neighbours. That inconsistency is what makes the bug hard to pin down from a single failing file: two PDFs from the same producer can differ only in which values happen to repeat, so one parses almost by accident while the other fails outright

What does the PDF Predictor parameter actually do?

The /Predictor entry in /DecodeParms tells a conforming reader which reversal to run, and ISO 32000-1 Table 8 defines the values that matter in practice: 1 means no prediction was applied, 2 selects TIFF Predictor 2 (horizontal differencing), and any value from 10 through 15 selects PNG-style prediction. Three more keys travel alongside it — /Colors, /BitsPerComponent, and /Columns — and together they describe the row geometry the differencing was computed against, even when the stream holds no image data at all: an object stream is not a picture, but PDF writers reuse the same row-based predictor machinery for it because delta-then-deflate compresses tightly packed integers and object offsets better than deflating them raw

TIFF Predictor 2 is the simpler of the two schemes: every component is stored as the difference from the same component in the previous pixel on the same row, and each row resets at its left edge rather than carrying a difference in from the row above. PNG prediction is more particular, because the actual filter can change from row to row: every row starts with a single tag byte — 0 for None, 1 for Sub, 2 for Up, 3 for Average, 4 for Paeth — and that tag, not the declared /Predictor value, decides how that specific row gets reconstructed. A /Predictor of 12 is really just the encoder's hint that it favoured the Up filter, where each byte is restored by adding the byte directly above it in the previous row, but a correct decoder still has to read the tag on every row rather than assume Up throughout

Why do object streams make a missed Predictor invisible?

Object streams compound the problem instead of just repeating it. ISO 32000-1 §7.5.7 lets a PDF 1.5+ writer pack multiple indirect objects into a single compressed container, an /ObjStm, and it is common for exactly the objects a validator most needs — the catalogue, /OutputIntents, or an XMP /Metadata stream — to travel through that container with /Predictor 12 attached, because those objects are short and repetitive enough to benefit from row differencing. When the predictor step is missing, expanding the object stream does not raise an error: it produces a byte sequence that looks superficially plausible but does not tokenise into the expected objects, so whatever was packed inside simply does not turn up. Rendering rarely notices, because a conforming rendering engine already reconstructs predictor-differenced data before it ever gets to layout; the code that notices is exactly the kind this bug hid inside — a validator, signer, or version checker that walks the raw PDF bytes itself to answer a structural question, with no fallback once its own view of the object stream comes back wrong

PDFiumPas hit exactly this failure before v2.16.0. Object streams built with /Predictor 12, the common case for PDF 1.5+ writers, expanded through PdfExpandObjectStreams into differenced bytes that the structural scanner could not parse, so the catalogue, /OutputIntents, and /Metadata objects packed inside were effectively invisible to compliance scans — no exception, no warning, just a scan that quietly behaved as if those objects were absent. The deeper mechanics of how PDFiumPas resolves an object stream against the active cross-reference table, including the hybrid and pure xref-stream cases, are covered separately in the article on validating object and xref streams with PDFiumPas; the predictor step described here runs after that resolution, on the bytes each compressed object actually contains

Reversing PNG and TIFF Predictor rows in Pascal

PDFiumPas reverses the differencing in a single routine, PdfApplyPredictor, and its geometry maths is worth knowing whether you call it or reimplement the idea in your own Delphi code. The row width in bytes is ceil(Columns × Colors × BitsPerComponent ÷ 8) and the per-pixel byte width both algorithms use is ceil(Colors × BitsPerComponent ÷ 8) — get either rounding wrong and the reconstruction reads across a row boundary instead of within one. A /Predictor below 2 is left untouched, since 1 means the encoder applied no transform at all; 2 selects the TIFF branch shown below, and anything from 10 upward falls through to PNG row-filter reconstruction, where the tag byte at the start of each row — not the declared /Predictor value — decides how that specific row gets undone

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;

What PDFiumPas changed in v2.16.0

The fix that shipped in PDFiumPas v2.16.0 sits inside PdfReadAndDecodeStream, the routine that reads a stream's raw bytes and decodes them for every caller that needs to inspect PDF structure at the byte level, including object stream expansion; it only attempts reconstruction after confirming /Filter is a bare FlateDecode, never a cascade, because a chained filter cannot be safely predictor-corrected at this layer. Reading /Predictor, /Colors, /BitsPerComponent, and /Columns back out of the stream dictionary does not need a general dictionary parser, either: PdfDictRefNum finds each key by direct name-token search within that one dictionary's byte range, which is safe here precisely because those four keys cannot repeat or nest inside a single stream dictionary. That same name-token search is far riskier once it is pointed at a larger or less-bounded region of a PDF file, which is the subject of the companion article on parsing PDF dictionaries safely

// 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;

Before v2.16.0, an object stream built with /Predictor 12 expanded to differenced bytes with no error raised, so any catalogue, /OutputIntents, or /Metadata object packed inside it went missing from PDFiumPas's structural scans without any warning. After the fix, the same object stream inflates and then reconstructs correctly, and the objects packed inside it become visible to those scans again. Defensive bounds travelled along with the fix: PdfApplyPredictor now rejects /Colors above 64, /BitsPerComponent above 32, and /Columns above 2^24 outright, because those combinations describe row geometries no real PDF producer needs and exist mainly to make a decoder allocate far more memory than the input bytes justify

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;

Limits worth knowing

PDFiumPas's predictor reconstruction has two edges worth knowing before you rely on it. TIFF Predictor 2 reconstruction only covers the 8-bit-per-component case; PDF permits narrower packings, but sub-byte TIFF-differenced data passes through unreconstructed rather than being guessed at, so a stream declaring /Predictor 2 with /BitsPerComponent 1, 2, or 4 will not decode correctly through this path today. PNG prediction has no such restriction — every row supplies its own filter tag, and all five defined types are reconstructed regardless of what the declared /Predictor value between 10 and 15 happens to be, which matches how PNG-style filtering actually works: the declared value is closer to a hint about what the encoder mostly used than a promise about every row

PDFium's native rendering engine already reconstructs predictor-differenced image and content-stream data correctly, which is exactly why a file can render perfectly in any ordinary viewer while a byte-level validator, signer, or version checker built on top of it reads the same bytes wrong. The predictor-aware decoding described here backs the PDF/A validation, structural scanning, and signing features of PDFiumPas, the native VCL PDFium component for Delphi and C++Builder