Ist eine PDF-Cross-Reference-Tabelle unbrauchbar, besteht die Lösung darin, sie vollständig zu ignorieren und aus dem Dateikörper neu aufzubauen. Die PDFlibPas Delphi PDF Library tut dies mit einem Single-Pass-Token-Scanner, der jeden echten Header eines indirekten Objekts erfasst, dann das Trailer-Dictionary wiederherstellt und die rekonstruierte Tabelle an den normalen Loader übergibt
Was zerbricht zuerst, wenn ein PDF beschädigt ist?
Die Cross-Reference-Tabelle ist der fragilste Teil eines PDF, weil sie als einzige absolute Byte-Offsets speichert. ISO 32000-1 §7.5.4 definiert diese Einträge als zehnstellige Offsets vom Dateianfang, und §7.5.5 platziert das Schlüsselwort startxref nahe dem Dateiende, zeigend auf die Tabelle selbst. Jede dieser Zahlen wird durch jede Bearbeitung ungültig, die Bytes verschiebt. Eine FTP-Sitzung im Textmodus, die CRLF übersetzt hat, ein abgebrochener Download, ein defekter Sektor auf einem Netzlaufwerk, ein Batch-Tool, das angehängt hat, ohne ein Incremental Update korrekt zu schreiben: Sie alle lassen die Objektdaten vollkommen lesbar zurück und den Index auf Datenmüll zeigen
Deshalb ist "die Datei ist beschädigt und wird repariert" ein so gängiger Dialog. Die Bytes sind fast immer noch da. Verloren ist die Karte. Rekonstruktion ist daher keine forensische Wiederherstellung verlorener Daten, sondern der Neuaufbau eines Index, der aus dem Body abgeleitet werden kann, und sie gelingt weit häufiger als Nutzer erwarten, weil der teure Inhalt – Seitenbäume, Schriften, Bilder – unangetastet bleibt
Warum liefert die Suche nach N 0 obj falsche Treffer?
Ein naiver Rebuild durchsucht die Rohbytes nach dem Muster "Ganzzahl, Ganzzahl, obj" und erfasst jeden Treffer. Er findet zu viel. PDF ist ein Container-Format, und drei Bereiche einer Datei sind für die Objektgrammatik opak: Kommentare (§7.2), Strings (§7.3.4) und Stream-Daten (§7.3.8). Jeder von ihnen kann Bytes enthalten, die exakt wie ein Objekt-Header lesen, und keiner von ihnen ist einer. Eine Bildunterschrift in einem literalen String, ein liegen gebliebener Debug-Kommentar oder zwei Megabyte Flate- oder DCT-Output erzeugen bereitwillig etwas, das wie 99 0 obj aussieht
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
Jeder falsche Eintrag kostet doppelt. Er verunreinigt die rekonstruierte Tabelle mit einer nicht existierenden Objektnummer, und er kann ein echtes Objekt mit derselben Nummer überschatten, das später in der Datei erscheint. PDFlibPas führt daher überhaupt kein Pattern-Matching durch. Es tokenisiert, das heißt, es weiß immer, ob die Bytes unter dem Cursor Code oder Nutzlast sind, und Nutzlast wird übersprungen, ohne je interpretiert zu werden
Ein Single-Pass-Zustandsautomat über 64-KiB-Blöcke
PDFlibPas scannt die gesamte Datei genau einmal, in 64-KiB-Blöcken, mit einem auf den Token-Regeln von ISO 32000-1 §7.2 und der Syntax indirekter Objekte aus §7.3.10 aufgebauten Zustandsautomaten. Ein Token endet bei Whitespace oder einem der Trennzeichen, und ein Objekt-Header wird nur erfasst, wenn eine vollständige Sequenz aus einer positiven Objektnummer, einer nicht-negativen Generationsnummer und dem bloßen Schlüsselwort obj gesehen wurde. Der erfasste Offset ist der Beginn des Objektnummer-Tokens, worauf ein Cross-Reference-Eintrag zeigen muss, nicht die Position des obj-Schlüsselworts
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
Wichtig ist, dass Token-Zustand und String-Zustand eine Blockgrenze überstehen. Ein Header, der die 65536-Byte-Linie überspannt, wird trotzdem erkannt, weil das partielle Token, das ausstehende Ganzzahlpaar und die In-String-Flags allesamt in den nächsten Block übernommen werden. Die Puffer sind fest: 64 KiB für den Scan, 32 Bytes für das längste Token, das überhaupt eine Rolle spielen kann, und die einzigen mit der Datei wachsenden Arrays sind die Listen der Objektnummern, Generationsnummern und 64-Bit-Offsets, die proportional zur tatsächlichen Objektzahl sind, nicht zur Dateigröße. In der Praxis führt der Scan sequenzielle Reads und höchstens zwei explizite Seeks über das gesamte Dokument aus, was ihn erst auf den mehrere Hundert Megabyte großen Eingaben tragfähig macht, die im Artikel zu direktem Zugriff bei Merge und Split behandelt werden
Warum darf man einem Stream nicht trauen, bei endstream zu enden?
Weil Stream-Daten beliebige Bytes sind, und beliebige Bytes zufällig endstream ergeben können. Ein Stream, der nach dem Schlüsselwort stream beginnt, muss als opake Daten übersprungen werden, bis er tatsächlich endet, aber das erste Auftreten des schließenden Schlüsselworts ist nur ein Kandidat. PDFlibPas löst das durch Erfordernis einer Bestätigung: Ein endstream-Token wird nur dann als echtes Streamende akzeptiert, wenn das nächste Nicht-Whitespace-Token ein eigenständiges endobj ist – die von §7.3.8 rund um ein Stream-Objekt geforderte Sequenz. Ein zufälliger Treffer innerhalb komprimierter Daten hat fast nie diese Fortsetzung, sodass der Scanner im Stream bleibt und weiterläuft. Zwei kleinere Regeln sind ebenso wichtig. Das Schlüsselwort stream löst nur dann den Stream-Zustand aus, wenn es ein bloßes Schlüsselwort ist, sodass ein Namensobjekt wie /stream in einem Dictionary es nie auslöst. Und ein obj- oder trailer-Token wird nur akzeptiert, wenn das Token die 32-Byte-Obergrenze nicht überschritten hat und nicht mit einem Solidus begann. Ohne diese beiden Sicherungen würde ein Ressourcen-Dictionary mit den falschen Schlüsselnamen genügen, um den Scan zu entgleisen – genau die Klasse feindlicher Eingaben, die in den Hinweisen zum sicheren Parsen nicht vertrauenswürdiger PDFs behandelt wird
Das echte Ende des Trailer-Dictionary finden
Die Objekte wiederherzustellen ist nur die halbe Aufgabe, denn der Loader braucht weiterhin einen Trailer, um /Root zu finden. PDFlibPas merkt sich die Positionen der letzten 64 während des Scans gefundenen trailer-Schlüsselwörter und validiert sie rückwärts, das jüngste zuerst, sodass der neueste brauchbare Trailer gewinnt und ein verirrtes, nicht von einem Dictionary gefolgtes Schlüsselwort einfach die Validierung nicht besteht und zum vorherigen Kandidaten durchfällt. Jeder Kandidat wird mit einer Obergrenze von 1 MiB gelesen, und das Ende des Dictionary wird gefunden, indem die Verschachtelungstiefe von << und >> zusammen mit literalen String-Escapes, hexadezimalen Strings und Kommentaren mitgeführt wird
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
Tiefenverfolgung ist keine akademische Übung. Ein trunkierter Trailer, der /Encrypt verliert, macht aus einem wiederherstellbaren verschlüsselten Dokument ein unöffenbares, und der Verlust von /Info oder eines benutzerdefinierten Sub-Dictionary verwirft still Metadaten, von denen ein nachgelagertes System abhängen kann. Ist die Datei verschlüsselt, ist der wiederhergestellte Trailer das, was den normalen Credential-Pfad laufen lässt, und die Retry-Semantik ist dieselbe, die im Artikel zum Laden verschlüsselter Dokumente beschrieben ist
Was die Rekonstruktion nicht zurückgeben kann
Rekonstruktion ist ein Best-Effort-Verfahren, und ehrlich über seine Grenzen zu sein gehört zum Ausliefern dazu. Drei Fälle scheitern rundweg. Objekte, die innerhalb von Object Streams gepackt sind (§7.5.7), sind für einen Byte-Scan nicht einzeln sichtbar, sodass die darin enthaltenen Objekte nicht vom Rebuild indiziert werden, wenn ein Container überlebt, sein Cross-Reference-Stream (§7.5.8) aber nicht. Eine Datei, deren Body tatsächlich korrumpiert wurde, statt nur falsch indiziert zu sein, liefert Header, deren Inhalte sich nicht mehr parsen lassen. Und eine Datei ohne wiederherstellbares trailer-Schlüsselwort und ohne lesbaren Katalog hat nichts, an dem ein Dokumentbaum verankert werden könnte, egal wie viele Objekt-Header gefunden wurden
Doppelte Objektnummern sind der interessante Mittelfall. Eine inkrementell aktualisierte Datei enthält legitim mehrere Generationen derselben Objektnummer, und die überlebende Cross-Reference-Kette ist der einzige Nachweis darüber, welche davon aktuell war. Ein Rebuild hat diese Kette nicht, erfasst also jeden gesehenen Header in Dateireihenfolge und löst danach nach Objektnummer auf. Meist gewinnt die spätere Revision, was meist richtig ist, aber ein Dokument, das aktualisiert und dann teilweise zurückgerollt wurde, kann subtil anders zurückkommen als das, was der ursprüngliche Xref beschrieb. Linearisierte Dateien tragen denselben Vorbehalt aus der anderen Richtung: Das Erste-Seite-Layout und die Hint-Tabellen werden bedeutungslos, sobald der Index neu erzeugt wird, sodass eine reparierte Datei als schlichtes, nicht linearisiertes Dokument behandelt werden sollte
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
Der Fallback läuft automatisch: PDFlibPas führt den Raw-Scan aus, sobald die Cross-Reference-Kette nicht gelesen werden kann, und auch dann, wenn jeder In-Use-Eintrag Offset null beansprucht, was das Kennzeichen einer Tabelle ist, die geschrieben, aber nie befüllt wurde. GetDocumentRepaired liefert 1, wenn dieser Pfad gelaufen ist, und es lohnt sich, das zu loggen statt zu ignorieren, denn ein Dokument, das über Rekonstruktion geladen wurde, sollte in eine saubere Datei neu gespeichert statt in einer Pipeline belassen werden, als wäre nichts geschehen. Es zu speichern schreibt eine frische, konsistente Cross-Reference-Tabelle, die günstigstmögliche Korrektur für jeden nachgelagerten Konsumenten
Der Rekonstruktionspfad, das GetDocumentRepaired-Flag und der hier gezeigte Streaming-Loader sind Teil der PDFlibPas Delphi PDF Library, zusammen mit den an anderer Stelle in diesem Blog behandelten APIs für Parsing, Rendering und Signierung