Technischer Artikel

Spärlicher Lazy-PDF-Objektindex in Delphi mit PDFiumPas

Man will ein einziges Dictionary aus einer 2-GB-PDF, und das Werkzeug faltet zuerst die komplette Cross-Reference-Tabelle in ein Array auf, dessen Größe der Trailer über /Size vorgibt. PDFiumPas ersetzt diesen Schritt durch einen spärlichen lazy Objektindex: Er behält nur die Xref-Abschnittsdeskriptoren, löst eine einzelne Objektnummer bei Bedarf durch begrenzte Fenster auf und cached genau die Einträge, die man tatsächlich berührt hat

Die alte Gestalt dieses Codes in FPdfCompress war ehrlich, aber teuer. ApplyDefaultOpenAction las die komplette Datei in ein einziges TBytes und allozierte dann ein dichtes TPdfActiveXrefEntries-Array mit einem Slot je Objektnummer bis hinauf zu /Size. Bei Skalierung gingen zwei Dinge schief. Die Lesekosten wuchsen linear mit der Dokumentgröße, selbst wenn der Aufrufer vier Dictionarys wollte, und das dichte Array kollidierte mit dem Parser-Budget: TPdfParserResourceBudget.Default setzt MaxObjects auf 4.000.000, also wurde eine völlig valide Datei, deren höchste Objektnummer über dieser Grenze liegt, mit einem Speicher- statt einem Korrektheitsargument abgelehnt

Der spärliche lazy Objektindex von PDFiumPas in Delphi im Vergleich zu einem dichten Cross-Reference-Array: Der dichte Pfad liest die ganze Datei und alloziert einen Slot je Objektnummer bis zur Trailer-Größe, während der spärliche Pfad nur Abschnittsdeskriptoren behält
Nur Deskriptoren bleiben im Speicher, die Einträge bleiben in der Datei, und jeder Lesevorgang läuft durch ein begrenztes Ein-Mebibyte-Fenster

Warum beantwortet die öffentliche PDFium-API diese Frage nicht?

Weil die Information zwar in PDFium existiert, aber nie über die C-Grenze kommt. CPDF_Parser pflegt die Cross-Reference-Tabelle, die Objektstream-Zugehörigkeit und die Revisions-Präzedenz intern, doch die veröffentlichten Header legen keinen Einsprungpunkt offen, der eine Objektnummer nimmt und ihren Rohversatz, ihre Generation, welche Revision gewonnen hat oder in welchem ObjStm sie lebt, zurückgibt. Die Speicherseite ist genauso geschlossen: FPDF_SaveAsCopy und FPDF_SaveWithVersion reichen nur einen sequenziellen Schreib-Callback heraus. Jeder Byte-Patch am Catalog nach einem nativen Speichern muss daher in der Pascal-Schicht gebaut werden, weshalb PDFiumPas diese Strukturen selbst parst, statt die DLL wiederzuverwenden

Was behält der spärliche Index tatsächlich im Speicher?

Deskriptoren, keine Einträge. Für eine klassische Tabelle (ISO 32000-1 §7.5.4) speichert ein TPdfSparseXrefSubsection die erste Objektnummer, die Objektanzahl, den Byte-Versatz, an dem die Eintragszeilen beginnen, und die gemessene Eintragsbreite. Die Einträge selbst bleiben in der Datei. Die Breite wird an der ersten Zeile gemessen, nicht als 20 Bytes angenommen, weil sich die Erzeuger über Zeilenenden uneinig sind; PDFiumPas akzeptiert 18 bis 64 und weist alles außerhalb dieses Bandes zurück, dazu jede Subsection, deren deklarierte Anzahl über das Stream-Ende hinausliefe. Für einen Cross-Reference-Stream (§7.5.8) hält der Abschnitt die drei /W-Feldbreiten, jeweils auf 0 bis 8 begrenzt, die abgeflachten /Index-Paare und die dekodierten Eintragsbytes, deren erwartete Länge aus /W und /Index berechnet wird, bevor ein einziges Byte aufgeblasen wird

Der ganze Index wird von Initialize aus einem Endfenster von höchstens 1 MiB gebaut, in dem startxref gefunden wird, und jeder folgende Objektlesevorgang benutzt ein 1-MiB-Objektfenster. Die Roh-Stream-Obergrenze liegt bei 64 MiB, und eine einzelne Xref-Zeile darf 1024 Bytes nicht überschreiten. Wer unsere Notiz über das Validieren von Objekt- und Cross-Reference-Streams mit PDFiumPas gelesen hat, erkennt dieselbe Feldbreiten-Disziplin wieder, nur dass sie hier dazu dient, einen einzelnen Eintrag zu adressieren statt eine ganze Tabelle zu prüfen

uses
  FPdfCompress;

var
  Source: TFileStream;
  Revision: TPdfSparseRevisionInfo;
begin
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    { durchläuft nur startxref, die /Prev-Kette und das Catalog }
    if ReadPdfSparseRevisionInfo(Source, Revision) then
    begin
      Writeln('root      ', Revision.RootObjectNumber, ' ',
        Revision.RootGeneration);
      Writeln('max obj   ', Revision.MaximumObjectNumber);
      Writeln('xref str  ', Revision.UsesXrefStream);
      Writeln('encrypted ', Revision.HasEncrypt);
      Writeln(string(Revision.CatalogDictionary));
    end;
  finally
    Source.Free;
  end;
end;

Wie erreicht eine Suche ein einzelnes Objekt?

Durch Arithmetik, in beiden Layouts. Eine klassische Subsection hat Zeilen fester Breite, also ist die Adresse eines Eintrags der Subsection-Anfang plus der Objektversatz mal der gemessenen Breite; PDFiumPas liest dann diese eine Zeile, parst den zehnstelligen Versatz und die fünfstelligen Generation, prüft die Generation gegen die 65535-Grenze aus §7.5.4 und stuft das abschließende Schlüsselwort als axkDirect oder axkFree ein. Ein Cross-Reference-Stream braucht einen weiteren Schritt, weil die /Index-Subsections im dekodierten Byte-Run aneinanderhängen, also akkumuliert der Index die Anzahlen der vorhergehenden Subsections, bevor er mit der summierten /W-Breite multipliziert. Typ 1 liefert einen Versatz, Typ 2 eine Objektstream-Nummer und einen Member-Index, und alles andere wird axkUnknown statt einer Vermutung

{ klassische Tabelle, ISO 32000-1 Abschnitt 7.5.4 }
EntryOffset := Subsection.EntryOffset +
  Int64(ObjectNumber - Subsection.FirstObject) * Subsection.EntryWidth;

{ Cross-Reference-Stream, ISO 32000-1 Abschnitt 7.5.8 }
EntryWidth := Section.Widths[0] + Section.Widths[1] + Section.Widths[2];
EntryPosition := Integer((PriorCount + ObjectNumber -
  Section.IndexValues[I]) * EntryWidth);

Nichts in beiden Pfaden ist proportional zu /Size. Das ist der ganze Sinn der Neufassung: Der Trailer-Größenwert wird als Metadatum weitergereicht und beim Schreiben der inkrementellen Revision benutzt, treibt aber nie eine Allokation. Die Regressionssuite nagelt das mit einer Fixture fest, deren Seitenbaum bei den Objekten 1.000.000.000 und 1.000.000.001 unter einem Trailer liegt, der /Size 1000000002 deklariert. Die alte dichte Implementierung lehnte diese Datei ab; der spärliche Index löst beide Referenzen auf und bewahrt die deklarierte Größe im Ausgabe-Trailer

Wie PDFiumPas in Delphi eine einzelne Objektnummer auflöst: Eine klassische Cross-Reference-Tabelle multipliziert die gemessene Zeilenbreite, während ein Cross-Reference-Stream die Anzahlen der vorhergehenden Subsections akkumuliert, bevor er mit den summierten Feldbreiten aus dem /W-Array multipliziert
Beide Suchen sind reine Arithmetik, also ist keine proportional zur Objektanzahl, die der Trailer deklariert

Hybride Revisionen, /Prev-Ketten und die Wachen darum

An der Revisions-Präzedenz geht ein naiver lazy Index schief. PDFiumPas läuft die Kette ab startxref in Neueste-zuerst-Reihenfolge ab und stoppt eine Suche an dem ersten Abschnitt, der antwortet, was die Präzedenzregel reproduziert, ohne eine zusammengeführte Tabelle zu materialisieren. Hybrid-Referenz-Dateien (§7.5.8.4) werden im klassischen Zweig behandelt: Trägt der Trailer ein /XRefStm, wird der ergänzende Stream-Abschnitt vor der klassischen Sektion registriert, die ihn nannte, sodass komprimierte Objekte, die der schlichten Tabelle unsichtbar sind, trotzdem gefunden werden, während die klassischen Einträge ihren Rang behalten. Ältere Revisionen werden danach über /Prev verfolgt

Zwei Wachen begrenzen diesen Lauf, und beide zählen bei beschädigten Dateien. Jeder besuchte Versatz wird vermerkt, also terminiert ein /Prev, der zurück in die Kette zeigt, statt sich zu drehen, und die Traversierungstiefe ist durch MaxRecursionDepth gedeckelt, das standardmäßig 1024 beträgt. Das Verschlüsselungs-Flag wird über die gesamte Kette akkumuliert statt nur aus dem neuesten Trailer gelesen, denn ein Dokument, dessen letzter Trailer /Encrypt weglässt, kann weiter hinten trotzdem verschlüsselt sein; Aufrufer, die Revisionen anhängen, verlassen sich auf dieses Flag, um zu verweigern, Klartext-Objekte in eine verschlüsselte Datei zu schreiben

Wie PDFiumPas in Delphi eine hybride PDF-Revisionskette durchläuft: Abschnitte werden von startxref aus neueste-zuerst registriert, ein ergänzender XRefStm-Abschnitt rückt vor die klassische Tabelle, die ihn nannte, und der /Prev-Lauf ist durch besuchte Versätze und eine Tiefengrenze gebunden
Eine Suche stoppt am ersten Abschnitt, der antwortet, was die Revisions-Präzedenz reproduziert, ohne je eine zusammengeführte Tabelle zu materialisieren

Typ-2-Einträge: warum der Objektstream wartet

Ein Typ-2-Eintrag nennt einen Objektstream, und PDFiumPas rührt diesen Stream nicht an, bis ein Aufrufer nach einem seiner Member fragt. Wenn es so weit ist, wird /Type /ObjStm verifiziert, /N gegen das Objektbudget geprüft und /First gegen die Dekodierbytes-Grenze, und /N wird gegen /First auf Plausibilität geprüft, denn jedes Header-Paar braucht mindestens vier Bytes. Erst dann wird der Stream aufgeblasen, und der Header-Scan stoppt beim angeforderten Member und seinem Nachfolger, statt eine volle Member-Tabelle zu bauen. Genau ein dekodierter Objektstream wird gleichzeitig vorgehalten, was der richtige Kompromiss ist, wenn ein Seitenbaum-Ast in einem einzigen ObjStm klumpt; unser Beitrag über Objektstream- und Predictor-Dekodierung in Delphi deckt ab, was innerhalb dieses Inflate-Schritts passiert (§7.5.7)

var
  Reader: TPdfSparseDictionaryReader;
  Generation: Integer;
  Dict: AnsiString;
begin
  { ein vorgehaltener Index, viele generationsbewusste Lesevorgänge }
  Reader := TPdfSparseDictionaryReader.Create(Source);
  try
    if Reader.Valid and
       Reader.ReadLatestDictionary(PageObjectNumber, Generation, Dict) then
      HandlePage(PageObjectNumber, Generation, Dict);
  finally
    Reader.Free;  { die Quelle bleibt Eigentum des Aufrufers }
  end;
end;

Wo der Cache aufhört, Versprechen zu geben

Der Index ist ein Schnappschuss, und es lohnt sich, darüber klar zu sprechen. Abschnitte werden einmal in Initialize geparst; wird der zugrunde liegende Stream danach verändert, ist jeder gecachte Eintrag veraltet, und die Klasse wird es nicht bemerken. TPdfSparseDictionaryReader hält den Index über die vom Aufrufer bestimmte Lebensdauer der Quelle, was genau das ist, was ein rekursiver Lauf über einen Seitenbaum will, und genau das, was man über ein Umschreiben hinweg niemals tun darf. Der Eintrags-Cache ist ein flaches Array mit linearer Suche, das auch negative Ergebnisse speichert, also sind ein paar hundert Suchen billig und ein paar hunderttausend nicht. ReadDictionary verlangt eine exakte Generationsübereinstimmung, während ReadLatestDictionary die aktive auflöst, und dieser Unterschied ist absichtlich: Referenzauflösung braucht das erste, Catalog-Inspektion das zweite. Wo diese Grenzen nicht eingehalten werden können, fallen die umgebenden Units auf den Legacy-Ganzdatei-Parser zurück, statt die Menge der funktionierenden Dateien zu schmälern — ein Muster, das wir auch für bedarfsgesteuertes Streaming großer PDFs benutzen

Cross-Compiler-Regressionen decken dasselbe Verhalten auf allen drei Toolchains ab, einschließlich einer Behauptung, dass eine 2-MiB-Quelle nie einen einzigen Lesevorgang über 1 MiB sieht. Wer Delphi-, C++Builder- oder Lazarus-Code pflegt, der PDF-Struktur direkt anfasst, und es leid ist, Ganzdatei-Parse-Kosten für vier Dictionarys zu zahlen, findet den spärlichen Index und die öffentliche Naht darum in der PDFiumPas-Delphi-PDFium-Komponente