Technický článek

Editace jmenných a číselných stromů PDF v Delphi

Záplatujete pole listu, abyste přidali jedno jméno, a záznam se pořád může číst jako chybějící. PDFium Component na to odpovídá TPdfTreeEditor, obecným editorem jmenných a číselných stromů ISO 32000: načte strom s přísnou validací, edituje záznamy v paměti, přestaví vyváženou sadu uzlů se správnými /Limits a zapíše ověřenou inkrementální revizi

Selhání, které tenhle unit motivovalo, je druh chyby, kterou diagnostikujete jen čtením bajtů. Nástroj připojí klíč a hodnotu k poli /Names listu, zapíše novou revizi a každý hex dump potvrdí, že klíč v souboru je. Acrobat stejně říká, že příloha neexistuje. Nic není poškozené v naivním smyslu. Strom prostě už není strom, po kterém umí sestoupit vyhledávací algoritmus

Proč záplatované pole listu rozbije vyhledávání?

Protože vyhledávání je binární sestup přes /Limits, ne sken listů. ISO 32000-1 §7.9.6 definuje jmenný strom jako kořen, jehož /Kids vedou dolů k listům držícím /Names, pole střídajících se klíčů a hodnot, s klíči seřazenými porovnáním bajtů napříč celým stromem, a každý nekořenový uzel deklaruje /Limits jako svůj vlastní nejmenší a největší klíč. Konformní čtenář porovná hledaný klíč s každým rozsahem kidu a otevře přesně jedno dítě. Připojíte klíč k listu a necháte staré /Limits nedotčené a sestup projde rovnou kolem uzlu, který ho skutečně drží

Diagram vyhledávání ve jmenném stromu PDF v PDFium Component: čtenář binárně sestupuje přes rozsahy /Limits kidů porovnáním bajtů, takže klíč jako invoice-2026-09 připojený k listu, jehož zastaralé /Limits pořád končí u (f), se nikdy neotevře, sestup místo toho vezme kid (g) (z) a příloha se čte jako chybějící, přestože v souboru je
Vyhledávání otevře přesně jedno dítě porovnáním hledaného klíče s každým rozsahem kidu, takže list, který získal klíč bez obnovení /Limits, se sestupem rovnou mine

Číselné stromy (§7.9.7) mají stejný tvar s /Nums a celočíselnými klíči a nesou skutečný provoz: /PageLabels v §12.4.2 je číselný strom a kategorie /Names katalogu z §7.7.4 vám dávají /Dests, /EmbeddedFiles a /JavaScript jako jmenné stromy. Čtyři invarianty tedy musí držet pohromadě, ne každý zvlášť. Klíče musí být v pořadí stromu přísně rostoucí, každá úroveň musí publikovat limity odpovídající jejímu skutečnému rozsahu klíčů, rozsahy sourozenců se nesmějí překrývat a číslo objektu a generace kořene musí zůstat přesně tam, kde byly, protože katalog a každý jiný odkazovatel míří na ten kořen nepřímým odkazem. Naše poznámka o práci s přílohami PDF v Delphi ukazuje konzumní stranu téže struktury

Co TPdfTreeEditor odmítne načíst?

Víc, než byste čekali, a záměrně. TPdfTreeEditor.TryLoad prochází graf uzlů přes TPdfSparseDictionaryReader, přičemž každé dítě řeší přes ReadDictionary v přesné generaci jmenované odkazem, ne tím, že by přijal kteroukoli zrovna aktivní revizi. Strom, u kterého nelze prokázat, že je dobře tvarovaný, se odmítne dřív, než je povolena jediná editace, což brání tomu, aby se z rozbitého vstupu stal věrohodně vypadající rozbitý výstup

  • Cykly a opakované odkazy, obojí hlášené jako ptviCycle, takže záznam v /Kids mířící zpátky nahoru po řetězci skončí místo rekurze
  • Smíšené tvary uzlů: slovník nesoucí /Kids společně s /Names nebo /Nums je ptviInvalidNodeShape, stejně jako prázdný nekořenový list
  • Nesetříděné nebo duplicitní klíče, hlášené jako ptviUnsortedKey a ptviDuplicateKey, kontrolované napříč celým průchodem, ne uvnitř jednoho listu
  • Limity nesouhlasící s klíči, které tam skutečně jsou, hlášené jako ptviInvalidLimits
  • Hodnota, která není přesně jedním kompletním objektem PDF, hlášená jako ptviInvalidValue
  • Překročení rozpočtu: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit a ptviDeadlineExceeded, omezené jak TPdfTreeEditOptions, tak centrálním rozpočtem parseru

TPdfTreeEditOptions.Default nastavuje ty stromové meze na 1 000 000 záznamů, 100 000 uzlů, hloubku 64, 64 záznamů na list a 32 kidů na uzel. Čísla listů a kidů nejsou jen stráže; je to fan-out použitý později při přestavbě stromu, a proto record validuje, že obě jsou alespoň dvě

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
    { hodnotou jsou syrové bajty jednoho kompletního PDF objektu }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free po uložení níže }
  end;
end;

Hodnoty záznamů zůstávají jako surové bajty objektu PDF

Hodnota záznamu se ukládá doslova, a to je jediné designové rozhodnutí, které dělá editor bezpečným pro ukázání na soubory, které jste nevyrobili. TPdfTreeEntry drží NameKey, NumberKey a RawValue a RawValue je surový běh bajtů jednoho kompletního objektu PDF: slovník, pole, řetězec, odkaz na stream nebo holý nepřímý odkaz. Nic se nedekóduje do Delphi varianty, TStrings nebo typovaného záznamu. Výsledkem je bezztrátový round trip hodnot, o kterých unit nic neví. /Filespec nesoucí rozšíření dodavatele, cílová pole s neobvyklým typem fit, slovník akce /JavaScript s privátními klíči – to všechno přežije přestavbu bajt po bajtu, protože editor nikdy neměl názor na jejich obsah. Zároveň to smrskne modifikační API na něco prokazatelného: UpsertName a UpsertNumber se musí jen ujistit, že dodaná hodnota se parsuje jako přesně jeden kompletní objekt, a RemoveName, RemoveNumber a Clear sahají jen na seznam záznamů

Jak se přestaví vyvážený strom bez přečíslování kořene?

Ve dvou průchodech s jedním pevným bodem. BuildBalancedObjects nejdřív rozřeže setříděný seznam záznamů na listy o maximálně MaxLeafEntries prvcích, pak skládá každou úroveň do rodičů o maximálně MaxKidsPerNode a opakuje, dokud nezůstane jediný uzel. Limity se počítají zdola nahoru, list bere svůj první a poslední klíč, vnitřní uzel bere dolní mez svého prvního dítěte a horní mez svého posledního, takže překrývající se rozsahy sourozenců nemohou vzniknout už konstrukcí

Pevný bod je identita. Konečný kořen vždy dostane číslo objektu a generaci, se kterými byl strom načten, takže odkaz z katalogu zůstává platný a nic jiného v dokumentu nepotřebuje přepis. Každý jiný uzel znovu použije odkaz dříve načteného uzlu jen tehdy, když se shoduje druh uzlu a celý rozsah klíčů, jinak dostane nové číslo alokované z aktivního maximálního čísla objektu plus jedna. Tahle kombinace drží nezměněné podstromy ukazující na jejich stávající objekty a zároveň garantuje, že každý uzel, jehož obsah se přesunul, se zapíše jako nový objekt, ne potichu reinterpretuje. Stejná disciplína znovupoužití odkazů pohání editor osnovy s přemapováním stránek v sousedním unitu

Diagram přestavby TPdfTreeEditor v PDFium Component: BuildBalancedObjects rozřeže setříděný seznam záznamů na listy o maximálně MaxLeafEntries klíčích, skládá každou úroveň do rodičů o maximálně MaxKidsPerNode, počítá /Limits zdola nahoru z prvních a posledních klíčů a hotovému kořenu dá přesně číslo objektu a generaci, se kterými byl strom načten
Listy berou limity ze svých prvních a posledních klíčů, vnitřní uzly z nejvzdálenějších dětí a konečný kořen si drží číslo objektu a generaci, se kterými byl strom načten
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber pochází ze zdrojové revize }
  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;

Zapište revizi a pak si výsledek přečtěte zpátky

Uložení je hotové teprve tehdy, když se výstup znovu otevře a porovná. SaveIncremental přečte fakta zdrojové revize, odmítne kořen, kterým je sám katalog dokumentu, serializuje vyváženou sadu uzlů plus objekt katalogu a předá vše AppendPdfSparseUpdate, která připojí novou revizi podle §7.5.6 a přitom zachová stávající odkaz na /Info a pole /ID dokumentu

Pak přichází část, kterou stojí za to si zkopírovat do vlastních writerů. Editor zavolá TPdfTreeEditor.TryLoad znovu, tentokrát proti cílovému streamu, se stejným odkazem na kořen a stejnými volbami, a porovná počet znovunačtených záznamů a každý klíč a RawValue proti tomu, co chtěl zapsat. Když se znovunačtení nepovede nebo se jakékoli porovnání liší, cílový stream se resetuje místo vrácení, takže částečně zapsaná revize se nikdy nevydává za úspěch. Ověření čte výstup přes stejnou ohraničenou sparse cestu popsanou v našem článku o sparse líném indexu objektů PDF, což drží kontrolu levnou i u velkých souborů

Diagram ověřeného inkrementálního uložení v PDFium Component: SaveIncremental serializuje vyváženou sadu uzlů, AppendPdfSparseUpdate připojí novou revizi se zachováním /Info a pole /ID dokumentu, pak TPdfTreeEditor.TryLoad znovu přečte cílový stream a porovná počet záznamů a každý klíč a RawValue, přičemž stream resetuje místo vrácení polovičaté revize
Uložení je hotové teprve tehdy, když se výstup znovu otevře přes stejnou ohraničenou cestu a porovná se záznam po záznamu, a neúspěšné porovnání resetuje cílový 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
      { cíl už resetován, nic napůl zapsaného se neuchovalo }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Šifrované zdroje se odmítají, a to je poctivé

Současná implementace odmítá šifrovaný zdroj bez okolků. Důvod je konkrétní, ne defenzivní: přestavba stromu zavádí nové řetězcové objekty a může zavést nové odkazy na streamy a §7.6 vyžaduje, aby se tyhle objekty šifrovaly po jednom klíčem dokumentu a číslem objektu a generací vlastníka. Zkopírovat trailer /Encrypt dopředu a přitom připojovat plaintextové řetězce by vyrobilo soubor, který se otevře a pak čte jako odpad přesně tam, kde jsou vaše nové klíče, což je horší než jasné odmítnutí. Další dvě meze stojí za to říct na rovinu. Editor pracuje na snapshotu zdrojového streamu, takže zdroj se nesmí měnit, dokud nad ním editor žije, a odmítá zacházet s katalogem dokumentu jako s kořenem stromu, protože katalog je slovník, který náhodou obsahuje stromy, ne uzel stromu sám. Pokud udržujete kód v Delphi, C++Builderu nebo Lazarusu, který zapisuje /EmbeddedFiles, /Dests, /JavaScript nebo /PageLabels, a ručně jste záplatovali pole listů, editor stromů s ověřeným inkrementálním uložením se dodává v PDFiumPas Delphi PDFium component