A stream marked /Predictor 12 does not mean every row uses PNG filter 2. HotPDF, the native VCL PDF component for Delphi and C++Builder, treats predictor values 10 through 15 as one family: the real filter tag, 0 to 4, is the first byte of each encoded row, and HPDFDecodePredictor reads and validates that tag row by row. That distinction is the shape of almost every bug in this corner of PDF, because nothing raises when you get it wrong. The filter chain runs, the raster is the size you expected, and the image comes out as diagonal static or a gradient that drifts further off with every scanline. The five numbers in /DecodeParms (ISO 32000-1 §7.4.4) mostly change the meaning of the bytes rather than their length, so a wrong one produces plausible garbage instead of an error
Why does /Predictor 12 not mean PNG filter 2 on every row?
Because the predictor number only says "PNG prediction is in use", not which filter. PNG encoders choose a filter per scanline and the PDF filter inherits that, so predictor values 10 (None), 11 (Sub), 12 (Up), 13 (Average), 14 (Paeth) and 15 (Optimum) all decode identically: the leading tag byte of each row is what the decoder must obey. The layout consequence matters as much as the semantics. Every encoded row is 1 + RowBytes bytes long, the input therefore exceeds the output by exactly the row count, and a stream whose length is not an integral multiple of RowBytes + 1 is truncated by definition. HotPDF checks that boundary before touching a byte, rejects any tag above 4 with Invalid PNG predictor row tag, and reads the previous row directly out of the single output buffer instead of materialising a two-dimensional row array. Filters 1 and 3 reach back BytesPerPixel within the current row, filter 2 reads straight up, filter 4 runs the Paeth choice over left, up and up-left — and all four operate on already reconstructed output, which is why the up-row has to be the decoded row and never the filtered input
uses
HPDFPredictor;
var
Filtered, Raster: AnsiString;
ErrorText: string;
begin
// /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
Int64(1024) * 3 * 8192, Raster, ErrorText) then
ConsumeRaster(Raster)
else
LogStreamDefect('predictor', ErrorText); // no exception, no partial raster
end;
The MaxOutputBytes argument is not decoration. A predictor stage is a decompression stage in disguise, and a hostile or merely broken /Columns value turns a few kilobytes of input into a multi-gigabyte allocation request. HotPDF computes bits per row, bytes per row and total raster size in Int64 first, refuses geometry that overflows, and honours the caller-supplied ceiling. Pass a real bound derived from the image dictionary and the failure mode becomes a logged message rather than an out-of-memory dialog on a customer machine
Why does TIFF Predictor 2 corrupt 4-bit images?
Because Predictor 2 is horizontal differencing per sample, not per byte, and at 1, 2 or 4 bits per component several samples share a byte. The common implementation adds byte N-Colors to byte N, which happens to be correct at 8 bits per component and is silently wrong everywhere else. An 8-bit RGB scan decodes perfectly, then the same code destroys a 4-bit indexed image the first time one shows up in production
The correct arithmetic works inside the bit field. HotPDF walks samples from index Colors to Colors * Columns - 1, extracts the sample and its same-component left neighbour with a mask of (1 shl BitsPerComponent) - 1 at the appropriate shift, adds them modulo that mask, and writes the result back without disturbing the other samples packed into the same byte. The tail matters too: a row is padded to a byte boundary, so the padding bits after the last sample must survive untouched instead of being folded into the arithmetic. At 16 bits per component each sample is a big-endian pair of bytes and the addition wraps at $FFFF across the pair rather than carrying between bytes independently; at 8 bits the simple byte recurrence is right, stepping by Colors so red accumulates against red and alpha against alpha. In every variant the first pixel of a row is a literal, never a difference, and the recurrence restarts at each row boundary — TIFF prediction never reads the row above, which is the whole difference between it and the PNG family
What does EarlyChange really control in LZWDecode?
It controls when the reader widens its code size by one bit, and being one code out of step corrupts everything that follows. HotPDF expresses the rule as a single invariant: after adding a dictionary entry, the next read widens when NextCode reaches (1 shl CodeSize) - Ord(EarlyChange). With /EarlyChange 1, the ISO 32000-1 §7.4.4 default, the switch happens one code early; with /EarlyChange 0 it happens exactly at the boundary. Both appear in real files and nothing in the bitstream tells you which one the encoder used. The rest of the state machine has to move in lockstep: a clear code resets code size, bit mask, next free code and the phrase storage together, and the end-of-information code is read at whatever width is current at that moment, not at the initial 9 bits. HotPDF starts at InitialCodeSize 9, caps the code size at 12 and the dictionary at 4096 entries, and defaults FillOrder to foTop because PDF packs codes high-order bit first — foBottom exists for TIFF-style streams that do not
uses
HPDFLZW;
var
Decoder: TPDFLZWDecompressor;
Parms: TPDFLZWParms;
Plain: AnsiString;
begin
Decoder := TPDFLZWDecompressor.Create;
try
Decoder.EarlyChange := True; // /EarlyChange 1 is the PDF default
Decoder.FillOrder := foTop; // high-order bit first
Decoder.MaxOutputBytes := 256 * 1024 * 1024;
Decoder.RequireInitialClear := False;
Decoder.RequireEndOfInformation := False;
Parms.Predictor := 12;
Parms.Colors := 3;
Parms.BitsPerComponent := 8;
Parms.Columns := 1024;
Parms.ExpandedTo8Bit := False;
Parms.ColorSpace := 'DeviceRGB';
if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
Decoder.KwKwKExpansions, Decoder.OutputBytes)
else
LogStreamDefect('lzw', Decoder.LastError);
finally
Decoder.Free;
end;
end;
The statistics exist for triage, not vanity. When a file decodes to the right length but the wrong pixels, PeakCodeSize and DictionaryAdds tell you immediately whether the reader ever widened where the writer did. Flip EarlyChange, decode again, compare the two: if the numbers move, you have your answer in one run instead of stepping through a bit reader
The KwKwK branch, and when a stream should simply fail
The one legal case that looks illegal is Code = NextCode, and HotPDF handles it by constructing the entry before emitting it. An encoder may emit the code for a phrase it is defining in the same step, which happens whenever the input contains a pattern of the form K w K w K; the decoder cannot look that code up because it does not exist yet, so it must build Previous + First(Previous), add it as the new entry, and emit the entry it just created. HotPDF counts those in KwKwKExpansions and cross-checks that the code it added is the code it was asked for. Everything above NextCode is corruption, and there a decoder should stop rather than improvise: HotPDF raises on a future code, on a dictionary prefix pointing outside the phrase arena, on a full dictionary, and on a first code that is not a literal. Two strictness switches are deliberately off by default, RequireInitialClear and RequireEndOfInformation, because plenty of production PDFs omit the leading clear code or run out of data without a terminator. Turn them on when validating your own output, leave them off when consuming files from the wild
Where /DecodeParms is actually read on the loaded-document side
HotPDF resolves /DecodeParms or its abbreviation /DP on the image stream dictionary, accepts either a dictionary or an array and takes the last element when it is an array, then carries Predictor, Colors, BitsPerComponent, Columns and EarlyChange into the raster path. The array case is the one people forget: a stream filtered by [/ASCII85Decode /FlateDecode] carries a parallel parameter array, and the predictor settings belong to the last filter, not the first
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
for I := 0 to Pdf.GetLoadedImageCount - 1 do
if Pdf.GetLoadedImageInfo(I, Info) then
begin
Bmp := Pdf.ExtractLoadedImage(I); // nil when the raster is unusable
if Bmp <> nil then
try
Bmp.SaveToFile(Format('image-%d.bmp', [I]));
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
One historical defect on that path is worth naming, because the class of bug repeats. The old Flate-with-parameters routine created a decompression stream and then copied from the original compressed input, so the predictor stage received compressed bytes and dutifully un-predicted them: always wrong, never raised. The current code reads only from the decoder before handing the result to the shared predictor, and it rejects a raster shorter than the computed size instead of falling back to the still-compressed bytes — a fallback that used to turn a decode failure into a corrupt bitmap. That same predictor implementation now serves cross-reference streams too, which is a useful consistency if you also work with object streams and incremental updates, and the surrounding extraction machinery is covered in the companion piece on extracting loaded images and their decode filters. Images arriving as DCTDecode or JPXDecode never reach the predictor at all; they carry their own compressed pixel model
Throughput: a contiguous phrase arena versus per-entry strings
Replacing the per-entry string dictionary with a contiguous phrase arena measured about 1.61 times faster on a pathological input: 1558 MiB/s against 969 MiB/s on a benchmark whose single longest phrase reaches 7,370,880 bytes. The shape of that input explains the gap, because the classic implementations pick one of two bad trades. A dictionary of AnsiString values allocates and copies a fresh string for every one of up to 4096 entries, each new entry copying its parent whole; a prefix/suffix stack avoids that memory entirely but reconstructs each phrase by walking the chain backwards one byte at a time and reversing it, which is fine for ordinary text and painful when one phrase runs to megabytes. HotPDF appends each phrase contiguously to a geometrically grown arena, indexes entries by offset and length, and emits a phrase with a single Move into the output buffer. The honest cost is memory: an arena holding every phrase in full is bounded by the sum of all phrase lengths rather than by the entry count, which is precisely why MaxOutputBytes exists on both the decompressor and the predictor. Derive that limit from what the image dictionary claims the raster should be, and a lying stream fails fast
The LZW decompressor, the shared predictor and the loaded-image extraction path shown here ship as part of the standard HotPDF Component for Delphi and C++Builder, with the full filter and DecodeParms reference on the product page