Technisch artikel

PDF Name Tree en Number Tree bewerken in Delphi

Patch een leaf array om één naam toe te voegen en de entry kan nog steeds als ontbrekend worden gelezen. PDFium Component beantwoordt dat met TPdfTreeEditor, een algemene editor voor ISO 32000 name trees en number trees: hij laadt de boom met strikte validatie, bewerkt entries in het geheugen, herbouwt een gebalanceerde nodeset met correcte /Limits, en schrijft een geverifieerde incrementele revisie

De mislukking die deze unit motiveerde is van het soort dat je alleen diagnosticeert door bytes te lezen. Een tool voegt een key en value toe aan een leaf-/Names-array, schrijft een nieuwe revisie, en elke hex dump bevestigt dat de key in het bestand staat. Acrobat zegt nog steeds dat de bijlage niet bestaat. Niets is corrupt in de naïeve zin. De boom is simpelweg geen boom meer waar het lookup-algoritme in kan afdalen

Waarom breekt een gepatchte leaf array de lookup?

Omdat lookup een binaire afdaling door /Limits is, geen scan van de leaves. ISO 32000-1 §7.9.6 definieert een name tree als een root waarvan de /Kids afdalen naar leaves die /Names bevatten, een array van afwisselend key en value, met keys geordend per byte-vergelijking over de hele boom, en elke niet-root-node die /Limits declareert als zijn eigen kleinste en grootste key. Een conforme reader vergelijkt de gezochte key met elk kid-bereik en opent precies één child. Voeg een key toe aan een leaf en laat de oude /Limits ongemoeid, en de afdaling loopt recht langs de node die hem eigenlijk bevat

Diagram van PDF name tree lookup in PDFium Component: de reader daalt binair af door kid-/Limits-bereiken per byte-vergelijking, dus een key zoals invoice-2026-09 die aan een leaf is toegevoegd waarvan de verouderde /Limits nog op (f) eindigen wordt nooit geopend, de afdaling neemt in plaats daarvan het (g) (z)-kid, en de bijlage leest als ontbrekend ondanks dat hij in het bestand staat
Lookup opent precies één child door de gezochte key met elk kid-bereik te vergelijken, dus een leaf die een key heeft gekregen zonder /Limits te verversen wordt recht voorbij gedaaald

Number trees (§7.9.7) hebben dezelfde vorm met /Nums en integer keys, en ze dragen echt verkeer: /PageLabels in §12.4.2 is een number tree, en de catalogus-/Names-categorieën van §7.7.4 geven je /Dests, /EmbeddedFiles en /JavaScript als name trees. Dus vier invarianten moeten samen standhouden, niet één voor één. Keys moeten strikt oplopend zijn in boomvolgorde, elk niveau moet limits publiceren die overeenkomen met zijn werkelijke key-bereik, sibling-bereiken mogen niet overlappen, en het objectnummer en de generatie van de root moeten precies blijven waar ze waren, omdat de catalogus en elke andere verwijzer met een indirecte referentie naar die root wijzen. Onze notitie over werken met PDF-bijlagen in Delphi toont de consumer-kant van dezelfde structuur

Wat weigert TPdfTreeEditor te laden?

Meer dan je zou verwachten, en met opzet. TPdfTreeEditor.TryLoad wandelt door de node-graaf via TPdfSparseDictionaryReader, en lost elk child op met ReadDictionary op de exacte generatie die de referentie noemt in plaats van welke revisie er toevallig actief is. Een boom die niet als welgevormd kan worden bewezen wordt afgewezen voordat er één edit wordt toegestaan, wat voorkomt dat een kapotte input een aannemelijk ogende kapotte output wordt

  • Cycles en herhaalde referenties, beide gemeld als ptviCycle, zodat een /Kids-entry die terug omhoog in de keten wijst eindigt in plaats van te recurseren
  • Gemengde node-vormen: een dictionary die /Kids samen met /Names of /Nums draagt is ptviInvalidNodeShape, net als een lege niet-root-leaf
  • Ongesorteerde of dubbele keys, gemeld als ptviUnsortedKey en ptviDuplicateKey, gecheckt over de hele traversal in plaats van binnen één leaf
  • Limits die het oneens zijn met de werkelijk aanwezige keys, gemeld als ptviInvalidLimits
  • Een ruwe waarde die niet precies één compleet PDF-object is, gemeld als ptviInvalidValue
  • Budgetoverschrijdingen: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit en ptviDeadlineExceeded, begrensd door zowel TPdfTreeEditOptions als het centrale parser-budget

TPdfTreeEditOptions.Default zet die boom-lokale grenzen op 1.000.000 entries, 100.000 nodes, diepte 64, 64 entries per leaf en 32 kids per node. De leaf- en kid-getallen zijn niet alleen guards; het zijn de fan-out die later wordt gebruikt wanneer de boom wordt herbouwd, en daarom valideert de record dat beide minstens twee zijn

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
    { de waarde is de ruwe bytes van één compleet 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 na de save hieronder }
  end;
end;

Entry-waarden blijven als ruwe PDF-objectbytes

Een entry-waarde wordt verbatim opgeslagen, en dit is de enige ontwerpbeslissing die de editor veilig maakt om op bestanden te richten die jij niet hebt geproduceerd. TPdfTreeEntry houdt NameKey, NumberKey en RawValue vast, en RawValue is de ruwe byte-reeks van één compleet PDF-object: een dictionary, een array, een string, een stream-referentie of een kale indirecte referentie. Er wordt niets gedecodeerd naar een Delphi-variant, een TStrings of een getypeerde record. De beloning is lossless round-tripping van waarden waar de unit niets van weet. Een /Filespec met een vendor-extensie, een destination-array met een ongebruikelijk fit type, een /JavaScript-actiedictionary met private keys, alles overleeft een rebuild byte voor byte, omdat de editor nooit een mening over hun inhoud heeft gehad. Het krimpt ook de wijzigings-API tot iets bewijsbaars: UpsertName en UpsertNumber hoeven alleen te verifiëren dat de aangeleverde waarde als precies één compleet object parst, en RemoveName, RemoveNumber en Clear raken alleen de entry-lijst aan

Hoe wordt een gebalanceerde boom herbouwd zonder de root te hernummeren?

In twee passes met één vast punt. BuildBalancedObjects snijdt eerst de gesorteerde entry-lijst in leaves van hoogstens MaxLeafEntries, vouwt dan elk niveau in parents van hoogstens MaxKidsPerNode en herhaalt tot er één node overblijft. Limits worden bottom-up berekend, een leaf neemt zijn eerste en laatste key, een interne node neemt de ondergrens van zijn eerste child en de bovengrens van zijn laatste, dus overlappende sibling-bereiken kunnen door constructie niet voorkomen

Het vaste punt is identiteit. De uiteindelijke root krijgt altijd het objectnummer en de generatie waarmee de boom is geladen, dus de catalogus-referentie blijft geldig en niets anders in het document hoeft herschreven te worden. Elke andere node hergebruikt de referentie van een eerder geladen node alleen wanneer het node-type overeenkomt en het volledige key-bereik overeenkomt, en neemt anders een vers nummer dat is toegewezen uit het actieve maximum objectnummer plus één. Die combinatie houdt ongewijzigde subtrees naar hun bestaande objecten wijzen, terwijl gegarandeerd wordt dat elke node waarvan de inhoud is verplaatst als nieuw object wordt geschreven in plaats van stilzwijgend geherinterpreteerd. Dezelfde referentie-hergebruikdiscipline drijft de outline editor en zijn page remapping in de zusterunit

Diagram van de TPdfTreeEditor-rebuild in PDFium Component: BuildBalancedObjects snijdt de gesorteerde entry-lijst in leaves van hoogstens MaxLeafEntries keys, vouwt elk niveau in parents van hoogstens MaxKidsPerNode, berekent /Limits bottom-up uit eerste en laatste keys, en geeft de afgewerkte root het exacte objectnummer en de generatie waarmee de boom is geladen
Leaves nemen hun limits uit hun eerste en laatste keys, interne nodes uit hun buitenste children, en de uiteindelijke root houdt het objectnummer en de generatie waarmee de boom is geladen
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber komt uit de bronrevisie }
  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;

Schrijf de revisie, en lees het resultaat dan terug

Een save is pas afgerond zodra de output opnieuw is geopend en vergeleken. SaveIncremental leest de feiten van de bronrevisie, weigert een root die de documentcatalogus zelf is, serialiseert de gebalanceerde nodeset plus het catalogusobject, en geeft alles door aan AppendPdfSparseUpdate, die een nieuwe revisie toevoegt volgens §7.5.6 met behoud van de bestaande /Info-referentie en de /ID-array van het document

Dan komt het deel dat het waard is om naar je eigen writers over te nemen. De editor roept TPdfTreeEditor.TryLoad opnieuw aan, dit keer tegen de destination stream, met dezelfde root-referentie en dezelfde opties, en vergelijkt het herladen entry-aantal en elke key en RawValue met wat hij van plan was te schrijven. Als het herladen faalt of een vergelijking verschilt, wordt de destination stream gereset in plaats van geretourneerd, zodat een gedeeltelijk geschreven revisie nooit als succes wordt teruggegeven. Verificatie leest de output door hetzelfde begrensde sparse-pad dat in onze write-up over de sparse lazy PDF object index wordt beschreven, en dat is wat de check betaalbaar houdt op grote bestanden

Diagram van de geverifieerde incrementele save in PDFium Component: SaveIncremental serialiseert de gebalanceerde nodeset, AppendPdfSparseUpdate voegt een nieuwe revisie toe met behoud van /Info en de /ID-array van het document, waarna TPdfTreeEditor.TryLoad de destination stream opnieuw leest en het entry-aantal en elke key en RawValue vergelijkt, waarbij de stream wordt gereset in plaats van een halfgeschreven revisie terug te geven
De save is pas afgerond zodra de output door hetzelfde begrensde pad opnieuw is geopend en entry voor entry is vergeleken, en een mislukte vergelijking reset de destination stream
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 al gereset, er is niets halfgeschreven bewaard }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Versleutelde bronnen worden geweigerd, en dat is eerlijk

De huidige implementatie wijst een versleutelde bron ronduit af. De reden is specifiek in plaats van defensief: een tree-rebuild introduceert nieuwe string-objecten en kan nieuwe stream-referenties introduceren, en §7.6 vereist dat die per object worden versleuteld met de documentkey en het bezittende objectnummer en de generatie. De /Encrypt-trailer naar voren kopiëren terwijl plaintext-strings worden toegevoegd, zou een bestand opleveren dat opent en dan precies daar waar je nieuwe keys zitten als rommel leest, wat erger is dan een duidelijke weigering. Twee verdere grenzen verdienen het om ronduit te worden gesteld. De editor werkt op een snapshot van de bronstream, dus de bron mag niet worden gewijzigd zolang er een editor overheen leeft, en hij weigert de documentcatalogus als tree-root te behandelen, want de catalogus is een dictionary die toevallig bomen bevat, geen tree-node zelf. Als je Delphi-, C++Builder- of Lazarus-code onderhoudt die /EmbeddedFiles, /Dests, /JavaScript of /PageLabels schrijft en je hebt leaf arrays met de hand gepatcht, zitten de tree editor en zijn geverifieerde incrementele save in de PDFiumPas Delphi PDFium component