Range-Check-Fehler in Delphi-PDF-Bibliotheken haben den Ruf, schwer fassbar zu sein, da sie keinem einheitlichen Eingabemuster folgen. Dasselbe Dokument löst sie auf einem Computer aus und auf einem anderen nicht; derselbe Codepfad wirft die Ausnahme bei einer 3-seitigen Datei, läuft aber bei einer 12-seitigen fehlerfrei durch. Diese Inkonsistenz lässt sich fast immer auf eine einzige Ursache zurückführen: PDF-Seitenobjekte werden nicht in Dateireihenfolge gespeichert. Wenn die Bibliothek ihr internes Seiten-Array aufbaut, indem sie Objekte sequenziell scannt, anstatt den vom Katalog deklarierten Seitenbaum zu durchlaufen, erstellt sie einen Index, dessen gültiger Bereich nicht dem entspricht, was die Aufrufer erwarten, und die Bereichsprüfung (Range Checking) erkennt diese Diskrepanz im denkbar ungünstigsten Moment
Wie das Range Checking in Delphi funktioniert
Wenn die Compiler-Direktive {$R+} aktiv ist (Standard in der Debug-Konfiguration), validiert die Delphi-RTL zur Laufzeit jeden Array-Index, String-Index und jede enumerierte Zuweisung. Ein Zugriff außerhalb der Grenzen löst einen ERangeError aus, anstatt stillschweigend benachbarten Speicher zu lesen. Dieses Verhalten ist wertvoll: Es bringt versteckte Fehler frühzeitig ans Licht, anstatt zuzulassen, dass sie eine Datenstruktur beschädigen, die erst hundert Zeilen später fehlschlägt. Der frustrierende Teil ist, dass die Ausnahme an der Zugriffsstelle auftritt, nicht an dem Punkt, an dem der Index falsch berechnet wurde. Wenn der Aufrufstapel eine tief verschachtelte Methode in einer PDF-Unit anzeigt, liegt der tatsächliche Fehler meist mehrere Frames zurück
Zusammengesetzte boolesche Bedingungen verschlimmern dies. Delphi wertet and-Ausdrücke von links nach rechts mit Kurzschluss-Semantik (Short-Circuit-Evaluation) aus, aber der Kurzschluss überspringt die Auswertung nur, wenn die linke Seite False ist. Ein Ausdruck wie:
if FDocStarted and (DestIndex < Length(PageArr)) and
(PageArr[DestIndex].PageObj <> nil) then
sieht sicher aus, schützt aber nur vor einem Index außerhalb des gültigen Bereichs, wenn FDocStarted True ist und DestIndex nicht negativ ist. Die Überprüfung DestIndex < Length(PageArr) bewirkt nichts, wenn DestIndex negativ ist, da der Vergleich einer negativen Ganzzahl mit einer nicht negativen Länge in vorzeichenbehafteter Arithmetik True zurückgibt und der anschließende Array-Zugriff immer noch den Range-Error auslöst. Das Verschieben der Bereichsprüfung an die äußerste Position ist die korrekte Lösung:
if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
Result := PageArr[DestIndex].PageObj
else
Result := nil;
end
else
raise ERangeError.CreateFmt(
'Page index %d is out of range (0..%d)',
[DestIndex, Length(PageArr) - 1]);
Dies ist die mechanische Lösung. Sie stoppt den Absturz. Sie erklärt jedoch nicht, warum DestIndex überhaupt einen Wert außerhalb des gültigen Bereichs erhalten hat
Die eigentliche Ursache: Objekt-Reihenfolge versus Seiten-Reihenfolge
ISO 32000-1 §7.7.3 definiert den Seitenbaum als einen Baum von Pages-Knoten, deren Kids-Arrays Seitenobjekte in der Anzeigereihenfolge auflisten. Die Datei speichert diese Objekte an den Offsets, die der Ersteller zufällig gewählt hat; Objekt Nummer 20 kann im Byte-Stream physisch vor Objekt Nummer 3 liegen. Eine Bibliothek, die ihre Seitenliste aufbaut, indem sie die Kreuzreferenztabelle in der Reihenfolge der Objektnummern iteriert, anstatt der Kids-Kette zu folgen, erzeugt eine Sequenz, die von den Erwartungen des Benutzers abweicht. Bei Dokumenten, bei denen der Generator die Seiten zufällig in der richtigen Reihenfolge geschrieben hat, funktioniert alles. Bei Dokumenten, bei denen dies nicht der Fall war, führt die Diskrepanz zwischen der Seitennummerierung der Bibliothek und der Seitennummerierung des Aufrufers zu Indizes, die außerhalb von PageArr liegen
Der korrekte Ansatz besteht darin, vom Katalog auszugehen, die indirekte Referenz /Pages aufzulösen und das Kids-Array rekursiv zu durchlaufen. Für ein flaches Dokument ohne dazwischenliegende Pages-Knoten ist die Durchquerung einfach:
procedure BuildPageIndexFromTree(
const KidsArray: THPDFArray;
var PageArr: TPageObjArray);
var
i, Idx: Integer;
Child: THPDFObject;
ChildType: string;
begin
for i := 0 to KidsArray.Count - 1 do
begin
Child := KidsArray.GetIndirectObject(i);
if Child = nil then
Continue;
ChildType := Child.GetNameValue('/Type');
if ChildType = 'Page' then
begin
Idx := Length(PageArr);
SetLength(PageArr, Idx + 1);
PageArr[Idx].PageObj := Child;
end
else if ChildType = 'Pages' then
begin
// intermediate node: recurse into its Kids
BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
end;
end;
end;
Nachdem dies ausgeführt wurde, ist PageArr[0] die erste Seite, die ein Viewer anzeigen würde, unabhängig davon, wo sich dieses Objekt im Byte-Stream befindet. Indizes, die von Aufrufern übergeben werden und die Anzeigereihenfolge annehmen, werden nun korrekt zugeordnet, und die Range-Fehler hören auf
Fest codierte Workarounds verschärfen das Problem
In Codebasen, in denen die eigentliche Ursache nie identifiziert wurde, findet man häufig heuristische Patches: Vertauschen der ersten und letzten Seite, wenn die Gesamtzahl 3 entspricht, Rotieren des Index für Dokumente von einem bestimmten Generator, Anwenden eines Offsets, wenn die erste Objektnummer einen Schwellenwert überschreitet. Jeder dieser Patches passt genau zu dem Satz von Testdateien, die beim Schreiben zur Hand waren. Fügt man eine andere PDF-Quelle hinzu, wird einer der Patches zur falschen Zeit ausgelöst und erzeugt einen Index, der nun doppelt falsch ist: falsch, weil er aus einem ungeordneten Array berechnet wurde, und nochmals falsch, weil eine unzutreffende Zuordnung obendrauf angewendet wurde. Der Range Checker fängt dies irgendwo weiter unten ab und der Stack-Trace führt ins Leere
Der einzige produktive Weg besteht darin, jede heuristische Zuordnung zu entfernen und die Konstruktion des Seiten-Arrays durch einen sauberen Baumdurchlauf zu ersetzen. Sobald die Indizes konstruktionsbedingt korrekt sind, werden keine Patches mehr benötigt und der Range Checker wird zu einem Gewinn statt zu einem Hindernis
Wenn Sie eine Bibliothek verwalten, die dieses Muster aufweist, aktivieren Sie die Bereichsprüfung in einem Release-Build vorübergehend und führen Sie sie gegen ein vielfältiges Korpus von PDFs aus: Dokumente, die von Word, von LaTeX, von Scanner-Firmware oder von PDF-zu-PDF-Split-Dienstprogrammen erstellt wurden. Die Dateien, die Ausnahmen auslösen, sind diejenigen, deren Speicherreihenfolge der Seitenobjekte von der Durchlaufreihenfolge abweicht, die Ihr Code annimmt. Jede von ihnen ist ein Datenpunkt, kein separater Fehler
Für neuen Code, der eine Delphi-PDF-Bibliothek aufruft, lautet der praktische Rat, die Seitenanzahl der Bibliothek als verbindlich zu betrachten und niemals einen Index zu übergeben, der aus Arithmetik mit externen Daten abgeleitet wurde, ohne vorher zu bestätigen, dass er in 0..PageCount - 1 fällt. Die HotPDF-Komponente legt die aufgelöste Seitenanzahl über THotPDF.PageCount nach BeginDoc oder nach dem Laden eines Dokuments offen; dieser Wert spiegelt immer den Durchlauf des Seitenbaums wider und kann sicher als Obergrenze für jede Index-Arithmetik verwendet werden