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
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
ptviCyclegemeldet, sodass ein/Kids-Eintrag, der zurück nach oben in der Kette zeigt, terminiert statt zu rekursieren - Gemischte Knotenformen: Ein Dictionary, das
/Kidszusammen mit/Namesoder/Numsträgt, istptviInvalidNodeShape, ebenso ein leeres Nicht-Wurzel-Blatt - Unsorierte oder doppelte Schlüssel, gemeldet als
ptviUnsortedKeyundptviDuplicateKey, 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,ptviEntryLimitundptviDeadlineExceeded, begrenzt sowohl durchTPdfTreeEditOptionsals 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
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
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