Ein PDF-Objektstrom, der fehlerfrei entpackt, aber trotzdem als Rauschen liest, fehlt meist ein Schritt: das Rückgängigmachen des ISO-32000-1-Predictors. Wenn das /DecodeParms-Wörterbuch eines Streams /Predictor 2 oder höher trägt, sind die Bytes, die FlateDecode zurückgibt, nicht die Originaldaten — sie sind PNG-artig zeilendifferenzierte oder TIFF-artig horizontal differenzierte Werte, die einen zweiten Rekonstruktionsdurchlauf brauchen, bevor irgendein Wörterbuch-Lookup Sinn ergibt. PDFiumPas, die native VCL-PDF-Komponentenbibliothek für Delphi und C++Builder, fügte diesen Rekonstruktionsdurchlauf in v2.16.0 hinzu, speziell weil PDF-1.5+-Objektströme zu differenzierten Bytes expandierten, die kein Wörterbuch-Parser lesen konnte
Warum FlateDecode allein nicht genug ist
FlateDecode selbst ist nur DEFLATE-Dekompression (ISO 32000-1 §7.4.4.1): Es reproduziert, welche Bytes auch immer der Encoder an den Kompressor übergeben hat, nicht mehr. Der Predictor lebt eine Ebene höher, im /DecodeParms-Wörterbuch des Streams, und beschreibt eine Transformation, die der Encoder vor der Kompression anwendete — Differenzbildung verwandelt lange Läufe ähnlicher strukturierter Werte, wie die eng gepackten Ganzzahlen in einem Kreuzreferenz-Stream oder Objektstrom, in lange Läufe kleiner Zahlen, die DEFLATE weit besser komprimiert. ISO 32000-1 §7.4.4.3 (Tabelle 8) ist ausdrücklich: Diese Transformation rückgängig zu machen ist Teil des Dekodierens eines gefilterten Streams, kein optionaler Aufräumdurchlauf, doch es ist leicht, einen FlateDecode-Helfer zu schreiben, der nur inflate aufruft und dort stehen bleibt
Das Symptom ist unverkennbar, sobald man weiß, wonach man suchen muss. Predictor-differenzierte Bytes sind kein zufälliges Rauschen — sie tragen weiterhin die Form eines komprimierten Streams, sodass ein naiver Parser oft an ein paar gültig aussehenden Tokens vorbeiläuft, bevor er auf eine Byte-Sequenz trifft, die unmöglich ein PDF-Name, eine Zahl oder ein Trennzeichen sein kann, und unterschiedliche Zeilen scheitern an unterschiedlichen Offsets, je nachdem, wie sehr sich die zugrunde liegenden Werte zufällig von ihren Nachbarn unterschieden. Diese Inkonsistenz macht den Bug schwer an einer einzelnen fehlschlagenden Datei festzumachen: Zwei PDFs desselben Erzeugers können sich nur darin unterscheiden, welche Werte sich zufällig wiederholen, sodass eines fast zufällig parst, während das andere rundweg scheitert
Was macht der PDF-Predictor-Parameter tatsächlich?
Der /Predictor-Eintrag in /DecodeParms sagt einem konformen Reader, welche Umkehrung er ausführen soll, und ISO 32000-1 Tabelle 8 definiert die in der Praxis relevanten Werte: 1 bedeutet, es wurde keine Prädiktion angewendet, 2 wählt TIFF Predictor 2 (horizontale Differenzbildung), und jeder Wert von 10 bis 15 wählt PNG-artige Prädiktion. Drei weitere Schlüssel reisen daneben mit — /Colors, /BitsPerComponent und /Columns — und zusammen beschreiben sie die Zeilengeometrie, gegen die die Differenzbildung berechnet wurde, selbst wenn der Stream überhaupt keine Bilddaten enthält: Ein Objektstrom ist kein Bild, aber PDF-Writer verwenden dieselbe zeilenbasierte Predictor-Maschinerie für ihn wieder, weil Delta-dann-Deflate eng gepackte Ganzzahlen und Objekt-Offsets enger komprimiert als sie roh zu deflaten
TIFF Predictor 2 ist das einfachere der beiden Schemata: Jede Komponente wird als Differenz zur selben Komponente im vorherigen Pixel derselben Zeile gespeichert, und jede Zeile setzt an ihrem linken Rand zurück, statt eine Differenz aus der Zeile darüber mitzutragen. PNG-Prädiktion ist eigenwilliger, weil sich der tatsächliche Filter von Zeile zu Zeile ändern kann: Jede Zeile beginnt mit einem einzelnen Tag-Byte — 0 für None, 1 für Sub, 2 für Up, 3 für Average, 4 für Paeth —, und dieses Tag, nicht der deklarierte /Predictor-Wert, entscheidet, wie diese spezifische Zeile rekonstruiert wird. Ein /Predictor von 12 ist eigentlich nur der Hinweis des Encoders, dass er den Up-Filter bevorzugte, wo jedes Byte durch Addition des direkt darüberliegenden Bytes aus der vorherigen Zeile wiederhergestellt wird, aber ein korrekter Decoder muss trotzdem bei jeder Zeile das Tag lesen, statt Up durchgehend anzunehmen
Warum machen Objektströme einen verpassten Predictor unsichtbar?
Objektströme verschärfen das Problem, statt es nur zu wiederholen. ISO 32000-1 §7.5.7 erlaubt einem PDF-1.5+-Writer, mehrere indirekte Objekte in einen einzigen komprimierten Container zu packen, ein /ObjStm, und es ist üblich, dass genau die Objekte, die ein Validator am meisten braucht — der Katalog, /OutputIntents oder ein XMP-/Metadata-Stream — durch diesen Container mit angehängtem /Predictor 12 reisen, weil diese Objekte kurz und repetitiv genug sind, um von Zeilendifferenzierung zu profitieren. Fehlt der Predictor-Schritt, wirft das Expandieren des Objektstroms keinen Fehler: Es erzeugt eine Byte-Sequenz, die oberflächlich plausibel aussieht, aber nicht in die erwarteten Objekte tokenisiert, sodass, was auch immer darin verpackt war, einfach nicht auftaucht. Rendering bemerkt das selten, weil eine konforme Render-Engine predictor-differenzierte Daten bereits rekonstruiert, bevor sie überhaupt zum Layout gelangen; der Code, der es bemerkt, ist genau die Art, in der sich dieser Bug versteckte — ein Validator, Signierer oder Versionsprüfer, der die rohen PDF-Bytes selbst durchläuft, um eine strukturelle Frage zu beantworten, ohne Rückfalllösung, sobald die eigene Sicht auf den Objektstrom falsch zurückkommt
PDFiumPas traf genau auf diesen Fehlschlag vor v2.16.0. Objektströme, gebaut mit /Predictor 12, der übliche Fall für PDF-1.5+-Writer, expandierten über PdfExpandObjectStreams zu differenzierten Bytes, die der strukturelle Scanner nicht parsen konnte, sodass der Katalog, /OutputIntents und /Metadata-Objekte, die darin gepackt waren, für Konformitätsscans effektiv unsichtbar waren — keine Exception, keine Warnung, nur ein Scan, der sich still so verhielt, als wären diese Objekte abwesend. Die tieferen Mechanismen, wie PDFiumPas einen Objektstrom gegen die aktive Kreuzreferenz-Tabelle auflöst, einschließlich der hybriden und reinen Xref-Stream-Fälle, werden separat in dem Artikel zur Validierung von Objekt- und Xref-Streams mit PDFiumPas behandelt; der hier beschriebene Predictor-Schritt läuft nach dieser Auflösung, auf den Bytes, die jedes komprimierte Objekt tatsächlich enthält
PNG- und TIFF-Predictor-Zeilen in Pascal umkehren
PDFiumPas kehrt die Differenzbildung in einer einzigen Routine um, PdfApplyPredictor, und ihre Geometrie-Mathematik lohnt es sich zu kennen, egal ob Sie sie aufrufen oder die Idee in Ihrem eigenen Delphi-Code neu implementieren. Die Zeilenbreite in Bytes ist ceil(Columns × Colors × BitsPerComponent ÷ 8), und die Pro-Pixel-Byte-Breite, die beide Algorithmen verwenden, ist ceil(Colors × BitsPerComponent ÷ 8) — verrechnet man eine der Rundungen, liest die Rekonstruktion über eine Zeilengrenze hinweg statt innerhalb einer. Ein /Predictor unter 2 wird unangetastet gelassen, da 1 bedeutet, dass der Encoder überhaupt keine Transformation angewendet hat; 2 wählt den unten gezeigten TIFF-Zweig, und alles ab 10 fällt durch zur PNG-Zeilenfilter-Rekonstruktion, wo das Tag-Byte am Anfang jeder Zeile — nicht der deklarierte /Predictor-Wert — entscheidet, wie diese spezifische Zeile rückgängig gemacht wird
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;
Was PDFiumPas in v2.16.0 änderte
Die in PDFiumPas v2.16.0 ausgelieferte Lösung sitzt innerhalb von PdfReadAndDecodeStream, der Routine, die die rohen Bytes eines Streams liest und für jeden Aufrufer dekodiert, der PDF-Struktur auf Byte-Ebene inspizieren muss, einschließlich Objektstrom-Expansion; sie versucht Rekonstruktion erst, nachdem bestätigt wurde, dass /Filter ein reines FlateDecode ist, nie eine Kaskade, weil ein verketteter Filter auf dieser Ebene nicht sicher predictor-korrigiert werden kann. Auch /Predictor, /Colors, /BitsPerComponent und /Columns aus dem Stream-Wörterbuch zurückzulesen braucht keinen allgemeinen Wörterbuch-Parser: PdfDictRefNum findet jeden Schlüssel per direkter Namens-Token-Suche innerhalb des Byte-Bereichs dieses einen Wörterbuchs, was hier genau deshalb sicher ist, weil diese vier Schlüssel sich innerhalb eines einzelnen Stream-Wörterbuchs nicht wiederholen oder verschachteln können. Dieselbe Namens-Token-Suche ist weit riskanter, sobald sie auf einen größeren oder weniger begrenzten Bereich einer PDF-Datei gerichtet wird, das Thema von dem begleitenden Artikel zum sicheren Parsen von PDF-Wörterbüchern
// 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;
Vor v2.16.0 expandierte ein mit /Predictor 12 gebauter Objektstrom ohne Fehlermeldung zu differenzierten Bytes, sodass jedes Katalog-, /OutputIntents- oder /Metadata-Objekt, das darin gepackt war, ohne jede Warnung aus PDFiumPas' strukturellen Scans verschwand. Nach der Lösung entpackt sich derselbe Objektstrom und rekonstruiert sich korrekt, und die darin gepackten Objekte werden für diese Scans wieder sichtbar. Defensive Grenzen reisten mit der Lösung mit: PdfApplyPredictor lehnt jetzt /Colors über 64, /BitsPerComponent über 32 und /Columns über 2^24 rundweg ab, weil diese Kombinationen Zeilengeometrien beschreiben, die kein echter PDF-Erzeuger braucht, und hauptsächlich existieren, um einen Decoder dazu zu bringen, weit mehr Speicher zu allozieren, als die Eingabe-Bytes rechtfertigen
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;
Wissenswerte Grenzen
PDFiumPas' Predictor-Rekonstruktion hat zwei Grenzen, die es sich zu kennen lohnt, bevor Sie sich darauf verlassen. TIFF-Predictor-2-Rekonstruktion deckt nur den Fall mit 8 Bit pro Komponente ab; PDF erlaubt schmalere Packungen, aber Sub-Byte-TIFF-differenzierte Daten laufen unrekonstruiert durch, statt geraten zu werden, sodass ein Stream, der /Predictor 2 mit /BitsPerComponent 1, 2 oder 4 deklariert, über diesen Pfad heute nicht korrekt dekodiert. PNG-Prädiktion hat keine solche Einschränkung — jede Zeile liefert ihr eigenes Filter-Tag, und alle fünf definierten Typen werden rekonstruiert, unabhängig davon, welcher deklarierte /Predictor-Wert zwischen 10 und 15 zufällig vorliegt, was dazu passt, wie PNG-artige Filterung tatsächlich funktioniert: Der deklarierte Wert ist eher ein Hinweis darauf, was der Encoder größtenteils verwendet hat, als ein Versprechen über jede Zeile
PDFiums native Render-Engine rekonstruiert predictor-differenzierte Bild- und Content-Stream-Daten bereits korrekt, genau deshalb kann eine Datei in jedem gewöhnlichen Viewer perfekt rendern, während ein darauf aufgebauter Validator, Signierer oder Versionsprüfer auf Byte-Ebene dieselben Bytes falsch liest. Die hier beschriebene predictor-bewusste Dekodierung stützt die PDF/A-Validierungs-, strukturellen Scan- und Signierfunktionen von PDFiumPas, der nativen VCL-PDFium-Komponente für Delphi und C++Builder