PDFlibPas löst rekursive Form-XObject-Aufrufe in Delphi-PDF-Content-Streams auf, indem es die aktive Aufrufkette verfolgt, nicht eine globale Besucht-Menge, sodass TPDFlib.EnumPageContentStatesEx dieselbe Form, mehrfach auf einer Seite aufgerufen, durchlaufen kann, ohne legitime Wiederverwendung mit einem Zyklus zu verwechseln. Ein Stempel-Form-XObject in einer Rechnungsvorlage ist der typische Fall: Dasselbe Objekt wird aus dem Kopfbereich, dem Fußbereich und einer Wasserzeichen-Ebene auf einer Seite aufgerufen, und nur eine Aufrufkette, die auf sich selbst zurückschleift, ist ein echter Zyklus
ISO 32000-1 §8.10 definiert ein Form-XObject als eigenständigen Content-Stream, den eine Seite oder eine andere Form mit dem Do-Operator aufruft, komplett mit eigenem Koordinatensystem in /Matrix, einer Clip-Grenze in diesem Koordinatensystem in /BBox, und optional einem eigenen Ressourcen-Wörterbuch. Nichts in der Spezifikation begrenzt, wie oft eine Form aufgerufen werden kann oder wie tief Formen einander aufrufen können, sodass ein konformer Parser legitime Wiederverwendung und legitime Verschachtelung akzeptieren muss, während er sich gleichzeitig gegen die eine Anordnung wappnet, die die Spezifikation tatsächlich verbietet: eine Form, deren Content-Stream sich direkt oder transitiv selbst aufruft. PDFlibPas meldet diese Unterscheidung über die TPDFlibContentFormTraversalStatus-Werte, die an jeden Do-Schnappschuss angehängt werden, vor allem ftsEnumerated für einen erfolgreichen Abstieg und ftsCycle für den einen Fall, der tatsächlich eine Schleife ist
Warum löst die Wiederverwendung desselben Form-XObjects keinen falschen Zyklus aus?
Eine wiederholte Form-XObject-Referenz ist für sich allein kein Beweis dafür, dass etwas falsch ist. ISO 32000-1 erlaubt, dasselbe Form-Objekt von so vielen Stellen in einem Content-Stream aufzurufen, wie der Autor möchte, genau so wird ein Logo-Stempel, eine Briefkopf-Vorlage oder eine Seitenzahl-Fußzeile über eine Seite hinweg wiederverwendet, ohne ihren Content-Stream mehrfach zu duplizieren. Die naive Absicherung gegen außer Kontrolle geratene Rekursion ist eine einzelne, nach Objektnummer indizierte Besucht-Menge: Sieht ein Durchläufer Form-Objekt 12 zum ersten Mal, markiert er 12 als gesehen und weigert sich, es anderswo im Baum erneut zu betreten. Dieser Ansatz bricht in dem Moment, in dem derselbe Stempel in zwei unabhängigen Ecken einer Seite erscheint, weil der zweite, vollkommen legitime Aufruf ankommt, nachdem die Objektnummer bereits als gesehen markiert wurde, und als wäre er eine Schleife abgelehnt wird
PDFlibPas vermeidet diesen falsch-positiven Fall, indem es Zyklenerkennung auf die aktuelle Aufrufkette begrenzt statt auf das ganze Dokument. EnumPageContentStatesEx schiebt den aufgelösten Form-Stream unmittelbar vor dem Abstieg in ihn auf die aktive Aufrufkette und entfernt genau diesen Eintrag wieder, sobald der Abstieg zurückkehrt, erfolgreich oder nicht. Ein Geschwister-Aufruf desselben Streams beginnt erst, nachdem der erste bereits entfernt wurde, sodass die Aufrufkette frei von diesem Stream ist, bis der Geschwister-Aufruf ihn prüft, und der Durchläufer zählt ihn genau so auf wie jede andere Form. Ein echter Zyklus sieht auf derselben Kette anders aus: Form A ruft Form B auf, B ist noch auf der Kette offen, wenn ihr eigener Inhalt zurück in A ruft, und A sitzt noch auf der Kette vom äußeren Aufruf, der noch nicht zurückgekehrt ist — das ist die einzige Form, die ftsCycle meldet, ein Form-Stream, der irgendwo früher auf der aktuellen Aufrufkette noch offen ist, nicht bloß anderswo auf der Seite vorhanden
Wie tief kann Form-XObject-Rekursion gehen, bevor PDFlibPas sie stoppt?
Zyklenerkennung und Tiefenbegrenzung lösen zwei unterschiedliche Probleme, und PDFlibPas hält sie genau deshalb als zwei unterschiedliche TPDFlibContentFormTraversalStatus-Ergebnisse. Eine Kette von zwanzig unterschiedlichen Formen, jede ruft die nächste auf, keine wiederholt sich, ist nach keiner Definition ein Zyklus — die Aktive-Kette-Prüfung findet nie einen wiederholten Stream —, aber zwanzig ehrliche Verschachtelungsebenen sind trotzdem zwanzig Ebenen an Parsen, Matrix-Verkettung und Ressourcen-Auflösung, die ein fehlerhaftes oder feindseliges PDF beliebig höher treiben könnte, wenn nichts anderes es stoppt. EnumPageContentStatesEx nimmt genau aus diesem Grund einen MaxFormDepth-Parameter entgegen und klemmt jeden übergebenen Wert auf ein Maximum von 64, unabhängig davon, worum der Aufrufer bittet. Eine Tiefe von null ist ein Sonderfall, den es sich für sich zu kennen lohnt: Sie deaktiviert Form-Rekursion vollständig und reproduziert das flache, nur-Seiten-Verhalten der älteren Methode EnumPageContentStates, weshalb jeder Do-Schnappschuss in diesem Modus ftsNotRequested meldet, statt irgendetwas zu versuchen
var
Lib: TPDFlib;
States: array of TPDFlibContentGraphicsState;
Count, I: Integer;
begin
Lib:= TPDFlib.Create;
try
if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
Exit;
Lib.SelectPage(1);
Count:= Lib.EnumPageContentStatesEx(True, 8, States); // count only
SetLength(States, Count);
Lib.EnumPageContentStatesEx(True, 8, States); // fill
for I:= 0 to Count- 1 do
if States[I].FormTraversalStatus= ftsCycle then
LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
finally
Lib.Free;
end;
end;
Ein Sub-Tracker pro Aufruf: Grafikzustand isolieren
Jeder Abstieg in ein Form-XObject bekommt seinen eigenen Grafikzustand-Tracker, statt sich den zu teilen, der bereits die Seite durchläuft, weil ein Form-Content-Stream verpflichtet ist, den Grafikzustand genau so zu verlassen, wie er ihn vorgefunden hat, und PDFlibPas nicht annehmen kann, dass jedes von ihm geöffnete PDF diese Anforderung tatsächlich einhält. Der Kind-Tracker startet mit einem Schnappschuss dessen, welche CTM, Farbzustand und Textparameter an der aufrufenden Do-Anweisung aktiv waren, und setzt dann seinen eigenen Speicher-und-Wiederherstellen-Stack sowie die Aktueller-Pfad-Verfolgung auf leer zurück, bevor er auch nur eine einzige Anweisung der Form ausführt. Ein unausgeglichenes q ohne passendes Q innerhalb einer nachlässigen oder beschädigten Form, keine seltene Sache in PDFs, die von älterem Tooling erzeugt wurden, bleibt innerhalb des Trackers dieses einen Aufrufs eingeschlossen und sickert nie in den Seiten-Tracker oder in einen Geschwister-Aufruf desselben Stempels, der eine Zeile später im Content-Stream sitzt
Die /Matrix einer Form kombiniert sich mit der bei Do geltenden CTM auf dieselbe Weise wie ein cm-Operator, linksmultipliziert gegen die aktuelle Transformation statt für sie ersetzt, und PDFlibPas verwendet absichtlich denselben Codepfad wieder, statt eine zweite Formel zu pflegen, da zwei unabhängige Implementierungen derselben Matrixalgebra genau die Art von Duplizierung sind, die nach ein paar Runden von Skalieren, Rotieren und Scheren-Komposition still auseinanderdriftet. /BBox clippt dann im eigenen Koordinatenraum der Form, nachdem die Matrix bereits angewendet wurde, und alle vier Ecken dieser Box werden einzeln transformiert statt nur der gegenüberliegenden Ecken, da eine rotierte oder gescherte Form sonst eine Bounding-Box melden kann, die echten Inhalt verfehlt, der in dem sitzt, was einmal eine extreme Ecke war, bevor die Transformation ihn woanders hinbewegte. Die Schleife aus dem vorherigen Beispiel über dasselbe States-Array zu erweitern liest diese Felder direkt
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
States[I].FormBBoxKnown then
Writeln('Form ', States[I].XObjectResource, ' matrix ',
States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);
Teilen sich zwei Formen mit demselben Ressourcennamen eine Schriftart?
Nein. Ein Ressourcenname wie /F1 bedeutet nur etwas relativ zum an der Verwendungsstelle aktiven Ressourcen-Wörterbuch, und zwei unterschiedliche Form-XObjects dürfen unter diesem identischen Namen zwei vollkommen unterschiedliche Schriftarten definieren. PDFlibPas löst das, indem es neben jedem Ressourcennamen einen Ressourcen-Gültigkeitsbereich verfolgt: Trägt eine Form ihr eigenes /Resources-Wörterbuch, wird dieses Wörterbuch zum vollständigen Ressourcen-Gültigkeitsbereich für alles darin, ohne Pro-Schlüssel-Rückfall auf das Seiten- oder Aufrufer-Wörterbuch für das, was das eigene Wörterbuch der Form zufällig auslässt. Nur eine Form ganz ohne /Resources-Schlüssel, ein Muster, das noch von manchen älteren PDF-Generatoren erzeugt wird, erbt das aufrufende Wörterbuch vollständig, und das ist eine bewusste Kompatibilitäts-Ausnahme, keine allgemeine Regel, auf die man sich bei neuer Ausgabe verlassen sollte. Font-Identität in einem TPDFlibContentGraphicsState-Schnappschuss ist daher das Paar aus FontResource und FontResourceScope, nicht der Name allein, mit FontObjectNumber verfügbar, um zu bestätigen, auf welches indirekte Objekt genau ein gegebenes /F1 in diesem bestimmten Gültigkeitsbereich aufgelöst wurde
Derselbe Gültigkeitsbereich gilt für jede andere benannte Ressource, die eine Form tragen kann, ExtGState-Einträge und verschachtelte XObject-Einträge eingeschlossen, da der zugrunde liegende Auflösungsmechanismus Fonts nicht als Sonderfall behandelt — der Font-Fall zählt zufällig am meisten, weil eine fehlerhaft zugeordnete Font-Identität still die falschen Glyphen erzeugt statt eines offensichtlichen Fehlschlags. Extraktionscode, der Textläufe allein nach Font-Namen gruppiert, ohne auch nach Ressourcen-Gültigkeitsbereich zu gruppieren, führt zwei visuell unterschiedliche Fonts zusammen, die zufällig einen Namen teilen, und der Fehler wird nicht auftauchen, bis jemand bemerkt, dass Ziffern aus der falschen Schriftart in dem sitzen, was als eine konsistente Schriftart gelesen werden sollte
for I:= 0 to Count- 1 do
if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
States[I].FontObjectNumber, States[I].ContentDepth);
FormTraversalStatus in Ihrer eigenen Pipeline lesen
FormTraversalStatus macht jeden Do-Schnappschuss von sich aus zu einem kleinen Diagnosebericht, und eine Pipeline, die ihn ignoriert, wirft genau die Information weg, die eine unvollständige Extraktion erklären würde. ftsNotApplicable bedeutet, die Anweisung war von vornherein nie ein aufgelöster Form-Aufruf; ftsNotRequested bedeutet, Rekursion war für diesen Aufruf abgeschaltet; ftsEnumerated bedeutet, die Form wurde erfolgreich geparst und durchlaufen; ftsDepthLimit und ftsCycle markieren die zwei Arten, auf die ein Abstieg absichtlich abgeschnitten wird; und ftsMalformed deckt alles andere ab, das den Durchlauf gestoppt hat — eine unauflösbare Stream-Referenz, ein /Matrix oder /BBox, das nicht geparst werden konnte, oder eine Exception, die beim Ausführen des eigenen Inhalts der Form ausgelöst wurde. Dieser letzte Fall zählt operativ, weil ein gescheiterter verschachtelter Durchlauf jede partielle Ausgabe, die er für diesen Zweig bereits erzeugt hatte, zurückrollt, sodass ein Aufrufer nie raten muss, ob eine Form tatsächlich leer war oder einfach zwei Anweisungen in ihren Content-Stream hinein explodiert ist
var
Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
Status: TPDFlibContentFormTraversalStatus;
begin
for Status:= Low(Tally) to High(Tally) do
Tally[Status]:= 0;
for I:= 0 to Count- 1 do
Inc(Tally[States[I].FormTraversalStatus]);
if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;
Grenzen, Kosten und wo das hineinpasst
Der Content-Stream einer Form wird pro Enumerationsaufruf genau einmal dekodiert und geparst, unabhängig davon, wie oft die Form aufgerufen wird, weil PDFlibPas die geparste Anweisungsliste gegen das zugrunde liegende Stream-Objekt zwischenspeichert, statt sie bei jedem Geschwister-Aufruf neu zu parsen — der Drei-Ecken-Stempel aus dem einleitenden Beispiel wird einmal dekodiert und dreimal durchlaufen, nicht dreimal dekodiert. Was bei jedem einzelnen Aufruf tatsächlich neu aufgebaut wird, ist alles, was sich legitimerweise von einer Aufrufstelle zur nächsten unterscheidet: der Kind-Tracker, die verkettete CTM, der geschnittene Clip und der Ressourcen-Gültigkeitsbereich. Diese Pro-Aufruf-CTM- und Clip-Buchhaltung ist dieselbe Maschinerie hinter PDFlibPas' Content-Stream-CTM- und Clipping-Zustandstracker, es lohnt sich, ihn neben diesem hier zu lesen, für jeden Content-Stream-Durchlauf, der über die reine Form-Rekursion hinausgeht
Zwei Grenzen lohnt es sich, mit Erwartungen zu versehen, bevor diese API in eine größere Pipeline geht. Die 64-Ebenen-Tiefenobergrenze ist kein Einstellregler für legitim tiefe Dokumente, da echte Rechnungen, Kontoauszüge und Report-Vorlagen im Wesentlichen nie Formen mehr als drei oder vier Ebenen tief verschachteln — ein Dokument, das tatsächlich ftsDepthLimit trifft, ist weit wahrscheinlicher fehlerhaft oder feindselig als ungewöhnlich aufwendig, und es lohnt sich, das als Datenqualitäts-Signal zu protokollieren, statt es still mit einer größeren Zahl erneut zu versuchen. EnumPageContentStatesEx ist auch eine Lese-Seiten-Analyse-API: Sie meldet, was ein Content-Stream tut, nicht ob eine Form überhaupt sichtbar sein sollte, was eine separate Frage ist, beantwortet durch den Sichtbarkeitszustand von Optional-Content-Gruppen, wenn ein Stempel oder eine Wasserzeichen-Form hinter einer Ebene sitzt, die ein Viewer möglicherweise abgeschaltet hat. Zyklenerkennung per Aufrufkette, Pro-Aufruf-Isolation und Ressourcen-Gültigkeitsbereich zusammen bilden eine Ecke der Content-Stream-Inspektionsoberfläche in der PDFlibPas-Komponente für Delphi und C++Builder