Patch a leaf array to add one name and the entry can still read as missing. PDFium Component answers that with TPdfTreeEditor, a general editor for ISO 32000 name trees and number trees: it loads the tree with strict validation, edits entries in memory, rebuilds a balanced node set with correct /Limits, and writes a verified incremental revision
The failure that motivated this unit is the kind you only diagnose by reading bytes. A tool appends a key and value to a leaf /Names array, writes a new revision, and every hex dump confirms the key is in the file. Acrobat still says the attachment does not exist. Nothing is corrupt in the naive sense. The tree is simply no longer a tree that the lookup algorithm can descend
Why does a patched leaf array break lookup?
Because lookup is a binary descent through /Limits, not a scan of the leaves. ISO 32000-1 §7.9.6 defines a name tree as a root whose /Kids lead down to leaves holding /Names, an array of alternating key and value, with keys ordered by byte comparison across the entire tree, and every non-root node declaring /Limits as its own least and greatest key. A conforming reader compares the wanted key against each kid range and opens exactly one child. Append a key to a leaf and leave the old /Limits untouched and the descent walks straight past the node that actually holds it
Number trees (§7.9.7) have the same shape with /Nums and integer keys, and they carry real traffic: /PageLabels in §12.4.2 is a number tree, and the catalog /Names categories of §7.7.4 give you /Dests, /EmbeddedFiles and /JavaScript as name trees. So four invariants have to hold together, not one at a time. Keys must be strictly increasing in tree order, every level must publish limits that match its actual key range, sibling ranges must not overlap, and the root object number and generation must stay exactly where they were, because the catalog and any other referrer point at that root by indirect reference. Our note on working with PDF attachments in Delphi shows the consumer side of the same structure
What does TPdfTreeEditor refuse to load?
More than you might expect, and on purpose. TPdfTreeEditor.TryLoad walks the node graph through TPdfSparseDictionaryReader, resolving every child with ReadDictionary at the exact generation named by the reference rather than accepting whichever revision happens to be active. A tree that cannot be proven well formed is rejected before a single edit is allowed, which keeps a broken input from becoming a plausible-looking broken output
- Cycles and repeated references, both reported as
ptviCycle, so a/Kidsentry pointing back up the chain terminates instead of recursing - Mixed node shapes: a dictionary carrying
/Kidstogether with/Namesor/NumsisptviInvalidNodeShape, as is an empty non-root leaf - Unsorted or duplicate keys, reported as
ptviUnsortedKeyandptviDuplicateKey, checked across the whole traversal rather than inside one leaf - Limits that disagree with the keys actually present, reported as
ptviInvalidLimits - A raw value that is not exactly one complete PDF object, reported as
ptviInvalidValue - Budget overruns:
ptviDepthLimit,ptviNodeLimit,ptviEntryLimitandptviDeadlineExceeded, bounded by bothTPdfTreeEditOptionsand the central parser budget
TPdfTreeEditOptions.Default sets those tree-local bounds to 1,000,000 entries, 100,000 nodes, depth 64, 64 entries per leaf and 32 kids per node. The leaf and kid figures are not only guards; they are the fan-out used later when the tree is rebuilt, which is why the record validates that both are at least two
uses
FPdfCompress;
var
Options: TPdfTreeEditOptions;
RootRef: TPdfTreeObjectReference;
Editor: TPdfTreeEditor;
Report: TPdfTreeValidationReport;
begin
Options := TPdfTreeEditOptions.Default;
RootRef.ObjectNumber := EmbeddedFilesRootObject; { catalog /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
{ the value is the raw bytes of one complete PDF object }
Editor.UpsertName('invoice-2026-09',
'<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
Editor.RemoveName('draft-2025-11');
finally
{ Editor.Free after the save below }
end;
end;
Entry values stay as raw PDF object bytes
An entry value is stored verbatim, and this is the single design decision that makes the editor safe to point at files you did not produce. TPdfTreeEntry holds NameKey, NumberKey and RawValue, and RawValue is the raw byte run of one complete PDF object: a dictionary, an array, a string, a stream reference or a bare indirect reference. Nothing is decoded into a Delphi variant, a TStrings or a typed record. The payoff is lossless round-tripping of values the unit knows nothing about. A /Filespec carrying a vendor extension, a destination array with an unusual fit type, a /JavaScript action dictionary with private keys, all survive a rebuild byte for byte, because the editor never had an opinion about their contents. It also shrinks the modification API to something provable: UpsertName and UpsertNumber only have to verify that the supplied value parses as exactly one complete object, and RemoveName, RemoveNumber and Clear only touch the entry list
How is a balanced tree rebuilt without renumbering the root?
In two passes with one fixed point. BuildBalancedObjects first cuts the sorted entry list into leaves of at most MaxLeafEntries, then folds each level into parents of at most MaxKidsPerNode and repeats until a single node remains. Limits are computed bottom up, a leaf taking its first and last key, an internal node taking the lower limit of its first child and the upper limit of its last, so overlapping sibling ranges cannot occur by construction
The fixed point is identity. The final root always receives the object number and generation the tree was loaded with, so the catalog reference stays valid and nothing else in the document needs rewriting. Every other node reuses the reference of a previously loaded node only when the node kind matches and the full key range matches, and otherwise takes a fresh number allocated from the active maximum object number plus one. That combination keeps unchanged subtrees pointing at their existing objects while guaranteeing that any node whose content moved is written as a new object rather than silently reinterpreted. The same reference-reuse discipline drives the outline editor and its page remapping in the sibling unit
var
Serialized: TPdfTreeSerializedObjects;
I: Integer;
begin
{ FirstNewObjectNumber comes from the source revision }
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;
Write the revision, then read the result back
A save is only finished once the output has been reopened and compared. SaveIncremental reads the source revision facts, refuses a root that is the document catalog itself, serializes the balanced node set plus the catalog object, and hands everything to AppendPdfSparseUpdate, which appends a new revision per §7.5.6 while preserving the existing /Info reference and the document /ID array
Then comes the part worth copying into your own writers. The editor calls TPdfTreeEditor.TryLoad again, this time against the destination stream, with the same root reference and the same options, and compares the reloaded entry count and every key and RawValue against what it intended to write. If the reload fails or any comparison differs, the destination stream is reset instead of returned, so a partially written revision is never handed back as a success. Verification reads the output through the same bounded sparse path described in our write-up on the sparse lazy PDF object index, which is what keeps the check affordable on large files
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
{ destination already reset, nothing half written was kept }
Writeln('save refused: ', Report.ErrorMessage);
finally
Dest.Free;
Editor.Free;
end;
end;
Encrypted sources are refused, and that is honest
The current implementation rejects an encrypted source outright. The reason is specific rather than defensive: a tree rebuild introduces new string objects and can introduce new stream references, and §7.6 requires those to be encrypted per object with the document key and the owning object number and generation. Copying the /Encrypt trailer forward while appending plaintext strings would produce a file that opens and then reads as garbage exactly where your new keys are, which is worse than a clear refusal. Two further bounds are worth stating plainly. The editor works on a snapshot of the source stream, so the source must not be modified while an editor is alive over it, and it declines to treat the document catalog as a tree root, since the catalog is a dictionary that happens to contain trees rather than a tree node itself. If you maintain Delphi, C++Builder or Lazarus code that writes /EmbeddedFiles, /Dests, /JavaScript or /PageLabels and you have been patching leaf arrays by hand, the tree editor and its verified incremental save ship in the PDFiumPas Delphi PDFium component