Technischer Artikel

PDF-Name-Trees und Number-Trees in Delphi bearbeiten

Sie patchen ein Blatt-Array, hängen einen Namen ein, und der Eintrag liest sich trotzdem als fehlend. PDFium Component kontert das mit TPdfTreeEditor, einem allgemeinen Editor für Name Trees und Number Trees nach ISO 32000: Er lädt den Baum mit strikter Validierung, bearbeitet Einträge im Speicher, baut einen balancierten Knotensatz mit korrekten /Limits wieder auf und schreibt eine verifizierte inkrementelle Revision

Der Fehler, der zu diesem Unit geführt hat, ist von der Sorte, die man nur diagnostiziert, indem man Bytes liest. Ein Tool hängt einen Schlüssel und einen Wert an ein Blatt-/Names-Array, schreibt eine neue Revision, und jeder Hex-Dump bestätigt: Der Schlüssel steht in der Datei. Acrobat sagt trotzdem, der Anhang existiere nicht. Im naiven Sinne ist nichts beschädigt. Der Baum ist schlicht kein Baum mehr, den der Lookup-Algorithmus absteigen kann

Warum zerstört ein gepatchtes Blatt-Array den Lookup?

Weil Lookup ein binärer Abstieg durch die /Limits ist und kein Scan der Blätter. ISO 32000-1 §7.9.6 definiert einen Name Tree als Wurzel, deren /Kids hinunter zu Blättern führen, die /Names halten — ein Array aus abwechselnd Schlüssel und Wert, die Schlüssel über den gesamten Baum per Byte-Vergleich sortiert, und jeder Nicht-Wurzel-Knoten deklariert /Limits als seinen eigenen kleinsten und größten Schlüssel. Ein konformer Reader vergleicht den gesuchten Schlüssel mit jedem Kid-Bereich und öffnet genau ein Kind. Hängen Sie einen Schlüssel an ein Blatt, lassen die alten /Limits unangetastet, und der Abstieg läuft direkt an dem Knoten vorbei, der ihn tatsächlich hält

Diagramm des PDF-Name-Tree-Lookups in PDFium Component: Der Reader steigt per Byte-Vergleich binär durch die Kid-/Limits-Bereiche ab, sodass ein an ein Blatt angehängter Schlüssel wie invoice-2026-09, dessen veraltete /Limits weiter bei (f) enden, nie geöffnet wird, der Abstieg stattdessen den (g)-(z)-Kid nimmt und der Anhang als fehlend gelesen wird, obwohl er in der Datei steht
Lookup öffnet genau ein Kind, indem er den gesuchten Schlüssel mit jedem Kid-Bereich vergleicht, sodass ein Blatt, das ohne aufgefrischte /Limits einen Schlüssel dazubekommen hat, beim Abstieg einfach übersprungen wird

Number Trees (§7.9.7) haben dieselbe Form, nur mit /Nums und Integer-Schlüsseln, und sie tragen echten Verkehr: /PageLabels in §12.4.2 ist ein Number Tree, und die /Names-Kategorien des Katalogs aus §7.7.4 liefern Ihnen /Dests, /EmbeddedFiles und /JavaScript als Name Trees. Vier Invarianten müssen also zusammenhalten, nicht nur eine nach der anderen. Die Schlüssel müssen in Baumreihenfolge streng aufsteigend sein, jede Ebene muss Limits veröffentlichen, die zu ihrem tatsächlichen Schlüsselbereich passen, Geschwisterbereiche dürfen sich nicht überlappen, und Objektnummer sowie Generation der Wurzel müssen exakt dort bleiben, wo sie waren, weil der Katalog und jeder andere Referenzierer per indirekter Referenz auf diese Wurzel zeigen. Unser Hinweis zum Arbeiten mit PDF-Anhängen in Delphi zeigt die Konsumentenseite derselben Struktur

Was weigert sich TPdfTreeEditor zu laden?

Mehr, als Sie erwarten würden, und das mit Absicht. TPdfTreeEditor.TryLoad läuft den Knotengraphen über TPdfSparseDictionaryReader ab und löst jedes Kind mit ReadDictionary an genau der Generation auf, die die Referenz nennt, statt zu akzeptieren, welche Revision gerade aktiv ist. Ein Baum, dessen Wohlgeformtheit nicht beweisbar ist, wird abgelehnt, bevor auch nur eine einzige Bearbeitung erlaubt wird — so wird aus kaputtem Input kein plausibel aussehender kaputter Output

  • Zyklen und wiederholte Referenzen, beide als ptviCycle gemeldet, sodass ein /Kids-Eintrag, der zurück nach oben in der Kette zeigt, terminiert statt zu rekursieren
  • Gemischte Knotenformen: Ein Dictionary, das /Kids zusammen mit /Names oder /Nums trägt, ist ptviInvalidNodeShape, ebenso ein leeres Nicht-Wurzel-Blatt
  • Unsorierte oder doppelte Schlüssel, gemeldet als ptviUnsortedKey und ptviDuplicateKey, geprüft über den gesamten Traversal statt innerhalb eines einzelnen Blatts
  • Limits, die mit den tatsächlich vorhandenen Schlüsseln nicht übereinstimmen, gemeldet als ptviInvalidLimits
  • Ein Raw-Value, der nicht exakt ein vollständiges PDF-Objekt ist, gemeldet als ptviInvalidValue
  • Budget-Überschreitungen: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit und ptviDeadlineExceeded, begrenzt sowohl durch TPdfTreeEditOptions als auch durch das zentrale Parser-Budget

TPdfTreeEditOptions.Default setzt diese baumlokalen Grenzen auf 1.000.000 Einträge, 100.000 Knoten, Tiefe 64, 64 Einträge pro Blatt und 32 Kids pro Knoten. Die Blatt- und Kid-Zahlen sind nicht nur Schutzschranken; sie sind der Fan-out, der später beim Wiederaufbau des Baums verwendet wird — deshalb validiert der Record, dass beide mindestens zwei betragen

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { Katalog /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { der Wert ist das rohe Byte-Fragment eines vollständigen PDF-Objekts }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free nach dem Speichern unten }
  end;
end;

Eintragswerte bleiben rohe PDF-Objekt-Bytes

Ein Eintragswert wird wortwörtlich gespeichert, und genau das ist die eine Design-Entscheidung, die den Editor sicher auf Dateien loslässt, die Sie nicht selbst erzeugt haben. TPdfTreeEntry hält NameKey, NumberKey und RawValue, und RawValue ist das rohe Byte-Fragment eines vollständigen PDF-Objekts: ein Dictionary, ein Array, ein String, eine Stream-Referenz oder eine nackte indirekte Referenz. Nichts wird in eine Delphi-Variante, ein TStrings oder einen typisierten Record dekodiert. Der Gewinn ist verlustfreies Round-Tripping von Werten, von denen das Unit nichts weiß. Ein /Filespec mit einer Vendor-Extension, ein Destination-Array mit einem ungewöhnlichen Fit-Typ, ein /JavaScript-Action-Dictionary mit privaten Keys — alles überlebt einen Rebuild Byte für Byte, weil der Editor nie eine Meinung zu deren Inhalt hatte. Außerdem schrumpft das die Modifikations-API auf etwas Beweisbares: UpsertName und UpsertNumber müssen nur verifizieren, dass der gelieferte Wert als exakt ein vollständiges Objekt parst, und RemoveName, RemoveNumber und Clear fassen nur die Eintragsliste an

Wie wird ein balancierter Baum neu aufgebaut, ohne die Wurzel neu zu nummerieren?

In zwei Durchläufen mit einem Fixpunkt. BuildBalancedObjects schneidet zuerst die sortierte Eintragsliste in Blätter von höchstens MaxLeafEntries, faltet dann jede Ebene in Eltern von höchstens MaxKidsPerNode und wiederholt das, bis ein einzelner Knoten übrig bleibt. Die Limits werden bottom-up berechnet: Ein Blatt nimmt seinen ersten und letzten Schlüssel, ein interner Knoten die untere Grenze seines ersten Kindes und die obere Grenze seines letzten — überlappende Geschwisterbereiche können so konstruktionsbedingt nicht entstehen

Der Fixpunkt ist die Identität. Die finale Wurzel bekommt immer die Objektnummer und Generation, mit denen der Baum geladen wurde, also bleibt die Katalogreferenz gültig und nichts anderes im Dokument muss umgeschrieben werden. Jeder andere Knoten verwendet die Referenz eines zuvor geladenen Knotens nur dann wieder, wenn Knotenart und kompletter Schlüsselbereich passen, und nimmt sonst eine frische Nummer, die aus der aktiven maximalen Objektnummer plus eins vergeben wird. Diese Kombination lässt unveränderte Unterbäume auf ihre vorhandenen Objekte zeigen und garantiert zugleich, dass jeder Knoten, dessen Inhalt sich bewegt hat, als neues Objekt geschrieben statt stillschweigend umgedeutet wird. Dieselbe Referenz-Wiederverwendungs-Disziplin treibt den Outline-Editor und sein Page-Remapping im Schwester-Unit an

Diagramm des TPdfTreeEditor-Rebuilds in PDFium Component: BuildBalancedObjects schneidet die sortierte Eintragsliste in Blätter von höchstens MaxLeafEntries Keys, faltet jede Ebene in Eltern von höchstens MaxKidsPerNode, berechnet die /Limits bottom-up aus ersten und letzten Schlüsseln und gibt der fertigen Wurzel exakt die Objektnummer und Generation, mit denen der Baum geladen wurde
Blätter nehmen ihre Limits aus ihren ersten und letzten Schlüsseln, interne Knoten aus ihren äußersten Kindern, und die finale Wurzel behält die Objektnummer und Generation, mit denen der Baum geladen wurde
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber stammt aus der Quellrevision }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;

Revision schreiben, dann das Ergebnis zurücklesen

Ein Save ist erst fertig, wenn der Output wieder geöffnet und verglichen wurde. SaveIncremental liest die Fakten der Quellrevision, lehnt eine Wurzel ab, die der Dokumentkatalog selbst ist, serialisiert den balancierten Knotensatz plus das Katalogobjekt und übergibt alles an AppendPdfSparseUpdate, das gemäß §7.5.6 eine neue Revision anhängt und dabei die vorhandene /Info-Referenz und das Dokument-/ID-Array erhält

Und jetzt kommt der Teil, den Sie in Ihre eigenen Writer übernehmen sollten. Der Editor ruft TPdfTreeEditor.TryLoad erneut auf, diesmal gegen den Ziel-Stream, mit derselben Wurzelreferenz und denselben Optionen, und vergleicht die neu geladene Eintragsanzahl sowie jeden Schlüssel und RawValue mit dem, was er schreiben wollte. Schlägt das Neuladen fehl oder weicht irgendein Vergleich ab, wird der Ziel-Stream zurückgesetzt statt zurückgegeben — eine halb geschriebene Revision wird also nie als Erfolg ausgegeben. Die Verifikation liest den Output über denselben begrenzten Sparse-Pfad, den unser Artikel zum Sparse-Lazy-PDF-Objektindex beschreibt, und genau das hält die Prüfung auch bei großen Dateien erschwinglich

Diagramm des verifizierten inkrementellen Saves in PDFium Component: SaveIncremental serialisiert den balancierten Knotensatz, AppendPdfSparseUpdate hängt eine neue Revision an und erhält /Info und das Dokument-/ID-Array, dann liest TPdfTreeEditor.TryLoad den Ziel-Stream erneut und vergleicht Eintragsanzahl sowie jeden Schlüssel und RawValue, wobei der Stream zurückgesetzt statt eine halb geschriebene Revision zurückgegeben wird
Der Save ist erst fertig, wenn der Output über denselben begrenzten Pfad wieder geöffnet und Eintrag für Eintrag verglichen wurde; ein fehlgeschlagener Vergleich setzt den Ziel-Stream zurück
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { Ziel bereits zurückgesetzt, nichts Halbgeschriebenes wurde behalten }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Verschlüsselte Quellen werden abgelehnt, und das ist ehrlich

Die aktuelle Implementierung lehnt eine verschlüsselte Quelle kategorisch ab. Der Grund ist konkret statt defensiv: Ein Tree-Rebuild führt neue String-Objekte ein und kann neue Stream-Referenzen einführen, und §7.6 verlangt, dass diese pro Objekt mit dem Dokument-Key und der besitzenden Objektnummer und Generation verschlüsselt werden. Den /Encrypt-Trailer nach vorne zu kopieren, während man Klartext-Strings anhängt, ergäbe eine Datei, die sich öffnet und dann genau dort als Müll gelesen wird, wo Ihre neuen Keys stehen — schlimmer als eine klare Ablehnung. Zwei weitere Grenzen sind eine klare Ansage wert. Der Editor arbeitet auf einem Snapshot des Quell-Streams, die Quelle darf also nicht verändert werden, solange ein Editor über ihr lebt, und er weigert sich, den Dokumentkatalog als Baumwurzel zu behandeln, weil der Katalog ein Dictionary ist, das zufällig Bäume enthält, statt selbst ein Baumknoten zu sein. Wenn Sie Delphi-, C++Builder- oder Lazarus-Code pflegen, der /EmbeddedFiles, /Dests, /JavaScript oder /PageLabels schreibt, und bisher Blatt-Arrays von Hand gepatcht haben: Der Tree-Editor und sein verifizierter inkrementeller Save stecken in der PDFiumPas Delphi PDFium component