Teknisk artikel

Redigering af PDF name trees og number trees i Delphi

Patch et leaf-array for at tilføje ét navn, og indgangen kan stadig læses som manglende. PDFium Component svarer på det med TPdfTreeEditor, en generel editor til ISO 32000 name trees og number trees: den indlæser træet med streng validering, redigerer indgange i hukommelsen, genopbygger et balanceret node-sæt med korrekte /Limits og skriver en verificeret inkrementel revision

Den fejl der motiverede denne unit, er af den slags du kun diagnosticerer ved at læse bytes. Et værktøj tilføjer en nøgle og en værdi til et leaf-/Names-array, skriver en ny revision, og hver hex dump bekræfter at nøglen er i filen. Acrobat siger stadig at vedhæftningen ikke findes. Intet er korrupt i den naive forstand. Træet er simpelthen ikke længere et træ opslagsalgoritmen kan dykke ned i

Hvorfor ødelægger et patchet leaf-array opslaget?

Fordi opslag er en binær nedstigning gennem /Limits, ikke en scanning af leaf'ene. ISO 32000-1 §7.9.6 definerer et name tree som en rod hvis /Kids fører ned til leaves der holder /Names, et array med skiftende nøgle og værdi, hvor nøglerne er ordnet efter byte-sammenligning på tværs af hele træet, og hvor enhver ikke-rod-node erklærer /Limits som sin egen mindste og største nøgle. En konform læser sammenligner den ønskede nøgle mod hvert kid-interval og åbner præcis ét barn. Tilføjer du en nøgle til et leaf og lader de gamle /Limits stå urørt, går nedstigningen lige forbi den node der faktisk holder den

Diagram af PDF-name-tree-opslag i PDFium Component: læseren dykker binært ned gennem kid-/Limits-intervaller ved byte-sammenligning, så en nøgle som invoice-2026-09 tilføjet til et leaf hvis forældede /Limits stadig slutter ved (f) aldrig åbnes, nedstigningen tager i stedet (g) (z)-kid'et, og vedhæftningen læses som manglende på trods af at den er i filen
Opslag åbner præcis ét barn ved at sammenligne den ønskede nøgle mod hvert kid-interval, så et leaf der fik en nøgle uden at opdatere /Limits, dykkes der lige forbi

Number trees (§7.9.7) har samme form med /Nums og heltals-nøgler, og de bærer ægte trafik: /PageLabels i §12.4.2 er et number tree, og katalogets /Names-kategorier fra §7.7.4 giver dig /Dests, /EmbeddedFiles og /JavaScript som name trees. Så fire invarianter skal holde sammen, ikke én ad gangen. Nøgler skal være strengt stigende i træ-rækkefølgen, hvert niveau skal offentliggøre limits der matcher dets faktiske nøgle-interval, søskende-intervaller må ikke overlappe, og rod-objektets nummer og generation skal blive præcis hvor de var, fordi kataloget og enhver anden referent peger på den rod via indirekte reference. Vores note om at arbejde med PDF-vedhæftninger i Delphi viser forbruger-siden af samme struktur

Hvad nægter TPdfTreeEditor at indlæse?

Mere end du måske forventer, og det er bevidst. TPdfTreeEditor.TryLoad gennemgår node-grafen gennem TPdfSparseDictionaryReader og opløser hvert barn med ReadDictionary ved præcis den generation referencen nævner, i stedet for at acceptere den revision der tilfældigvis er aktiv. Et træ der ikke kan bevises velformet, afvises før en eneste redigering tillades, hvilket forhindrer at et ødelagt input bliver til et plausibelt-udseende ødelagt output

  • Cykler og gentagne referencer, begge rapporteret som ptviCycle, så en /Kids-indgang der peger tilbage op i kæden terminerer i stedet for at rekursere
  • Blandede node-former: en dictionary der bærer /Kids sammen med /Names eller /Nums er ptviInvalidNodeShape, ligesom et tomt ikke-rod-leaf
  • Usorterede eller duplikerede nøgler, rapporteret som ptviUnsortedKey og ptviDuplicateKey, tjekket på tværs af hele gennemløbet snarere end inde i ét leaf
  • Limits der er uenige med de nøgler der faktisk er til stede, rapporteret som ptviInvalidLimits
  • En rå værdi der ikke er præcis ét komplet PDF-objekt, rapporteret som ptviInvalidValue
  • Budget-overskridelser: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit og ptviDeadlineExceeded, afgrænset af både TPdfTreeEditOptions og det centrale parser-budget

TPdfTreeEditOptions.Default sætter de træ-lokale grænser til 1.000.000 indgange, 100.000 noder, dybde 64, 64 indgange per leaf og 32 kids per node. Leaf- og kid-tallene er ikke kun vagter; de er den fan-out der bruges senere når træet genopbygges, hvilket er grunden til at record'en validerer at begge er mindst to

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
    { værdien er de rå bytes af ét komplet PDF-objekt }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free efter save'et nedenfor }
  end;
end;

Indgangsværdier forbliver som rå PDF-objekt-bytes

En indgangsværdi gemmes ordret, og det er den ene designbeslutning der gør editoren sikker at pege på filer du ikke selv har produceret. TPdfTreeEntry holder NameKey, NumberKey og RawValue, og RawValue er det rå byte-løb af ét komplet PDF-objekt: en dictionary, et array, en streng, en stream-reference eller en bar indirekte reference. Intet afkodes til en Delphi-variant, en TStrings eller en typer record. Gevinsten er lossless round-tripping af værdier unit'en intet kender til. En /Filespec med en vendor-extension, et destinations-array med en usædvanlig fit type, en /JavaScript-action-dictionary med private nøgler — alt sammen overlever en genopbygning byte for byte, fordi editoren aldrig havde en mening om deres indhold. Det krymper også modificerings-API'et til noget beviseligt: UpsertName og UpsertNumber skal kun verificere at den leverede værdi parser som præcis ét komplet objekt, og RemoveName, RemoveNumber og Clear rører kun indgangs-listen

Hvordan genopbygges et balanceret træ uden at rodnummeret ændres?

I to gennemløb med ét fast punkt. BuildBalancedObjects skærer først den sorterede indgangs-liste i leaves af højst MaxLeafEntries, folder så hvert niveau ind i forældre af højst MaxKidsPerNode og gentager indtil én enkelt node er tilbage. Limits beregnes nedefra og op: et leaf tager sin første og sidste nøgle, en intern node tager nedre limit fra sit første barn og øvre limit fra sit sidste, så overlappende søskende-intervaller ikke kan opstå ved konstruktion

Det faste punkt er identitet. Den endelige rod modtager altid det objektnummer og den generation træet blev indlæst med, så katalog-referencen forbliver gyldig og intet andet i dokumentet skal omskrives. Enhver anden node genbruger referencen fra en tidligere indlæst node kun når node-typen matcher og hele nøgle-intervallet matcher, og tager ellers et friskt nummer allokeret fra den aktive maksimale objekts-nummer plus én. Den kombination holder uændrede subtræer pegende på deres eksisterende objekter, mens den garanterer at enhver node hvis indhold flyttede sig skrives som et nyt objekt frem for lydløst at blive omfortolket. Den samme reference-genbrugs-disciplin driver outline-editoren og dens page-remapping i søster-unit'en

Diagram af TPdfTreeEditor-genopbygningen i PDFium Component: BuildBalancedObjects skærer den sorterede indgangs-liste i leaves af højst MaxLeafEntries-nøgler, folder hvert niveau ind i forældre af højst MaxKidsPerNode, beregner /Limits nedefra og op fra første og sidste nøgle og giver den færdige rod præcis det objektnummer og den generation træet blev indlæst med
Leaves tager deres limits fra deres første og sidste nøgle, interne noder fra deres yderste børn, og den endelige rod beholder det objektnummer og den generation træet blev indlæst med
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber kommer fra kilderevisionen }
  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;

Skriv revisionen, og læs så resultatet tilbage

Et save er først færdigt når outputtet er genåbnet og sammenlignet. SaveIncremental læser kilderevisionens fakta, nægter en rod der er selve dokumentkataloget, serialiserer det balancerede node-sæt plus katalog-objektet og overdrager alting til AppendPdfSparseUpdate, som tilføjer en ny revision per §7.5.6 mens den bevarer den eksisterende /Info-reference og dokumentets /ID-array

Så kommer delen der er værd at kopiere ind i dine egne writers. Editoren kalder TPdfTreeEditor.TryLoad igen, denne gang mod destinations-streamen, med samme rod-reference og samme options, og sammenligner den genindlæste indgangs-tæller samt hver nøgle og RawValue mod det den havde til hensigt at skrive. Fejler genindlæsningen, eller afviger nogen sammenligning, nulstilles destinations-streamen i stedet for at blive returneret, så en delvist skrevet revision aldrig udleveres som en succes. Verifikationen læser outputtet gennem den samme afgrænsede sparse-sti beskrevet i vores write-up om det sparse lazy PDF-objekt-indeks, hvilket er det der holder checken overkommelig på store filer

Diagram af den verificerede inkrementelle save i PDFium Component: SaveIncremental serialiserer det balancerede node-sæt, AppendPdfSparseUpdate tilføjer en ny revision der bevarer /Info og dokumentets /ID-array, og derefter læser TPdfTreeEditor.TryLoad destinations-streamen igen og sammenligner indgangs-tælleren og hver nøgle og RawValue, hvor streamen nulstilles i stedet for at returnere en halvt skrevet revision
Save'et er først færdigt når outputtet er genåbnet gennem samme afgrænsede sti og sammenlignet indgang for indgang, og en mislykket sammenligning nulstiller destinations-streamen
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
      { destinationen er allerede nulstillet, intet halvt skrevet blev beholdt }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Krypterede kilder afvises, og det er ærligt

Den nuværende implementering afviser en krypteret kilde blankt. Grunden er specifik snarere end defensiv: en træ-genopbygning introducerer nye streng-objekter og kan introducere nye stream-referencer, og §7.6 kræver at de krypteres per objekt med dokument-nøglen og det ejende objekts nummer og generation. At kopiere /Encrypt-traileren med fremad, mens man tilføjer plaintext-strenge, ville producere en fil der åbner og så læses som skrald præcis der hvor dine nye nøgler er, hvilket er værre end et klart afslag. To yderligere grænser er værd at nævne lige ud. Editoren arbejder på et snapshot af kilde-streamen, så kilden må ikke modificeres mens en editor lever over den, og den afslår at behandle dokumentkataloget som en træ-rod, da kataloget er en dictionary der tilfældigvis indeholder træer snarere end en træ-node selv. Vedligeholder du Delphi-, C++Builder- eller Lazarus-kode der skriver /EmbeddedFiles, /Dests, /JavaScript eller /PageLabels, og har du patchet leaf-arrays i hånden, leveres træ-editoren og dens verificerede inkrementelle save i PDFiumPas Delphi PDFium component