Artykuł techniczny

Edycja drzew nazw i drzew liczb PDF w Delphi

Załataj tablicę liścia, żeby dodać jedną nazwę, a wpis wciąż może czytać się jako brakujący. PDFium Component odpowiada na to TPdfTreeEditor, ogólnym edytorem drzew nazw i drzew liczb ISO 32000: ładuje drzewo ze ścisłą walidacją, edytuje wpisy w pamięci, przebudowuje zbalansowany zestaw węzłów z poprawnymi /Limits i zapisuje zweryfikowaną rewizję inkrementalną

Awaria, która zmotywowała ten unit, jest z tego rodzaju, który diagnozuje się tylko czytaniem bajtów. Narzędzie dopisuje klucz i wartość do liściowej tablicy /Names, zapisuje nową rewizję, a każdy hex dump potwierdza, że klucz jest w pliku. Acrobat wciąż mówi, że załącznik nie istnieje. Nic nie jest zepsute w naiwnym sensie. Drzewo po prostu nie jest już drzewem, po którym algorytm wyszukiwania potrafi zejść

Czemu poprawiona łata tablicy liścia psuje wyszukiwanie?

Bo wyszukiwanie to binarny zstęp przez /Limits, a nie skan liści. ISO 32000-1 §7.9.6 definiuje drzewo nazw jako korzeń, którego /Kids prowadzą w dół do liści trzymających /Names, tablicę naprzemiennych kluczy i wartości, z kluczami uporządkowanymi porównaniem bajtowym w całym drzewie, a każdy węzeł niebędący korzeniem deklaruje /Limits jako swój najmniejszy i największy klucz. Zgodny czytnik porównuje szukany klucz z każdym zakresem dziecka i otwiera dokładnie jedno dziecko. Dopisz klucz do liścia, zostaw stare /Limits nietknięte, a zstęp przechodzi obok węzła, który faktycznie go trzyma

Diagram wyszukiwania w drzewie nazw PDF w komponencie PDFium: czytnik zstępuje binarnie przez zakresy /Limits dzieci porównaniem bajtowym, więc klucz taki jak invoice-2026-09 dopisany do liścia, którego nieświeże /Limits wciąż kończą się na (f), nigdy nie zostaje otwarty, zstęp idzie do dziecka (g) (z) zamiast tego, a załącznik czyta się jako brakujący, mimo że jest w pliku
Wyszukiwanie otwiera dokładnie jedno dziecko, porównując szukany klucz z każdym zakresem dziecka, więc liść, który zyskał klucz bez odświeżenia /Limits, jest mijany w zstępie

Drzewa liczb (§7.9.7) mają ten sam kształt z /Nums i kluczami całkowitymi i niosą prawdziwy ruch: /PageLabels w §12.4.2 to drzewo liczb, a kategorie /Names katalogu z §7.7.4 dają ci /Dests, /EmbeddedFiles i /JavaScript jako drzewa nazw. Cztery niezmienniki muszą więc trzymać się razem, nie pojedynczo. Klucze muszą być ściśle rosnące w porządku drzewa, każdy poziom musi publikować limity pasujące do swojego faktycznego zakresu kluczy, zakresy rodzeństwa nie mogą na siebie nachodzić, a numer obiektu i generacja korzenia muszą zostać dokładnie tam, gdzie były, bo katalog i każdy inny odwołujący się wskazuje ten korzeń referencją pośrednią. Nasza notka o pracy z załącznikami PDF w Delphi pokazuje konsumencką stronę tej samej struktury

Czego TPdfTreeEditor odmawia załadowania?

Więcej, niż byś się spodziewał, i celowo. TPdfTreeEditor.TryLoad przechodzi graf węzłów przez TPdfSparseDictionaryReader, rozwiązując każde dziecko przez ReadDictionary na dokładnej generacji wskazanej referencją, zamiast przyjmować tę rewizję, która akurat jest aktywna. Drzewo, którego nie da się dowieść jako poprawnie uformowane, jest odrzucane, zanim wolno będzie jakąkolwiek edycję, co powstrzymuje zepsute wejście przed staniem się wiarygodnie wyglądającym zepsutym wyjściem

  • Cykle i powtórzone referencje, oba raportowane jako ptviCycle, więc wpis /Kids wskazujący w górę łańcucha kończy się zamiast rekursji
  • Mieszane kształty węzłów: słownik niosący /Kids razem z /Names lub /Nums to ptviInvalidNodeShape, podobnie jak pusty liść niebędący korzeniem
  • Nieposortowane lub zduplikowane klucze, raportowane jako ptviUnsortedKey i ptviDuplicateKey, sprawdzane przez cały przebieg, nie wewnątrz jednego liścia
  • Limits niezgodne z faktycznie obecnymi kluczami, raportowane jako ptviInvalidLimits
  • Surowa wartość, która nie jest dokładnie jednym kompletnym obiektem PDF, raportowana jako ptviInvalidValue
  • Przekroczenia budżetu: ptviDepthLimit, ptviNodeLimit, ptviEntryLimit i ptviDeadlineExceeded, ograniczane zarówno przez TPdfTreeEditOptions, jak i centralny budżet parsera

TPdfTreeEditOptions.Default ustawia te lokalne dla drzewa limity na 1 000 000 wpisów, 100 000 węzłów, głębokość 64, 64 wpisy na liść i 32 dzieci na węzeł. Liczby liści i dzieci to nie tylko strażnicy; to fan-out używany później przy przebudowie drzewa, dlatego rekord waliduje, że obie są co najmniej równe dwa

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
    { wartość to surowe bajty jednego kompletnego obiektu PDF }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free po zapisie poniżej }
  end;
end;

Wartości wpisów pozostają surowymi bajtami obiektów PDF

Wartość wpisu jest przechowywana wiernie i to jedyna decyzja projektowa, która czyni edytor bezpiecznym do wskazywania na pliki, których nie wyprodukowałeś. TPdfTreeEntry trzyma NameKey, NumberKey i RawValue, a RawValue to surowy ciąg bajtów jednego kompletnego obiektu PDF: słownika, tablicy, stringa, referencji do strumienia albo gołej referencji pośredniej. Nic nie jest dekodowane do varianta Delphi, TStrings czy typowanego rekordu. Zysk to lossless round-trip wartości, o których unit nic nie wie. /Filespec z rozszerzeniem od producenta, tablica miejsca docelowego z nietypowym typem dopasowania, słownik akcji /JavaScript z prywatnymi kluczami — wszystko przetrwa przebudowę bajt po bajcie, bo edytor nigdy nie miał opinii o ich zawartości. To też zwęża API modyfikacji do czegoś dowodliwego: UpsertName i UpsertNumber muszą tylko sprawdzić, że dostarczona wartość parsuje się jako dokładnie jeden kompletny obiekt, a RemoveName, RemoveNumber i Clear tylko ruszają listę wpisów

Jak przebudowuje się zbalansowane drzewo bez przenumerowania korzenia?

W dwóch przebiegach z jednym punktem stałym. BuildBalancedObjects najpierw tnie posortowaną listę wpisów na liście o co najwyżej MaxLeafEntries, potem składa każdy poziom w rodziców o co najwyżej MaxKidsPerNode i powtarza, aż zostanie jeden węzeł. Limity są liczone od dołu: liść bierze swój pierwszy i ostatni klucz, węzeł wewnętrzny dolną granicę pierwszego dziecka i górną granicę ostatniego, więc nachodzące zakresy rodzeństwa nie mogą powstać z konstrukcji

Punkt stały to tożsamość. Finalny korzeń zawsze dostaje numer obiektu i generację, z którymi drzewo zostało załadowane, więc referencja katalogu zostaje ważna i nic innego w dokumencie nie wymaga przepisania. Każdy inny węzeł reużywa referencji wcześniej załadowanego węzła tylko wtedy, gdy rodzaj węzła się zgadza i pełny zakres kluczy się zgadza, a w przeciwnym razie dostaje świeży numer przydzielony z aktywnego maksymalnego numeru obiektu plus jeden. Ta kombinacja trzyma niezmienione poddrzewa wskazujące swoje istniejące obiekty, gwarantując zarazem, że każdy węzeł, którego zawartość się przesunęła, jest zapisany jako nowy obiekt, a nie po cichu zinterpretowany ponownie. Ta sama dyscyplina reużycia referencji napędza edytor konspektu i jego remapowanie stron w siostrzanym unitcie

Diagram przebudowy TPdfTreeEditor w komponencie PDFium: BuildBalancedObjects tnie posortowaną listę wpisów na liście o co najwyżej MaxLeafEntries kluczy, składa każdy poziom w rodziców o co najwyżej MaxKidsPerNode, liczy /Limits od dołu z pierwszego i ostatniego klucza i daje gotowemu korzeniowi dokładnie ten numer obiektu i generację, z którymi drzewo zostało załadowane
Liście biorą swoje limity z pierwszego i ostatniego klucza, węzły wewnętrzne ze skrajnych dzieci, a finalny korzeń zachowuje numer obiektu i generację, z którymi drzewo zostało załadowane
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber pochodzi z rewizji źródłowej }
  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;

Zapisz rewizję, potem przeczytaj wynik z powrotem

Zapis jest skończony dopiero, gdy wynik został ponownie otwarty i porównany. SaveIncremental czyta fakty rewizji źródłowej, odmawia korzenia będącego samym katalogiem dokumentu, serializuje zbalansowany zestaw węzłów plus obiekt katalogu i przekazuje wszystko do AppendPdfSparseUpdate, który dopisuje nową rewizję zgodnie z §7.5.6, zachowując istniejącą referencję /Info i tablicę /ID dokumentu

Potem przychodzi część, którą warto przepisać do własnych pisarzy. Edytor wywołuje TPdfTreeEditor.TryLoad ponownie, tym razem na strumieniu docelowym, z tą samą referencją korzenia i tymi samymi opcjami, i porównuje ponownie załadowaną liczbę wpisów oraz każdy klucz i RawValue z tym, co zamierzał zapisać. Jeśli przeładowanie się nie powiedzie albo którekolwiek porównanie się różni, strumień docelowy jest resetowany zamiast zwracany, więc częściowo zapisana rewizja nigdy nie wraca jako sukces. Weryfikacja czyta wynik przez tę samą ograniczoną ścieżkę sparse, opisaną w naszym tekście o sparse lazy indeksie obiektów PDF, i to właśnie trzyma sprawdzanie tanim na dużych plikach

Diagram weryfikowanego zapisu inkrementalnego w komponencie PDFium: SaveIncremental serializuje zbalansowany zestaw węzłów, AppendPdfSparseUpdate dopisuje nową rewizję, zachowując /Info i tablicę /ID dokumentu, potem TPdfTreeEditor.TryLoad czyta ponownie strumień docelowy i porównuje liczbę wpisów oraz każdy klucz i RawValue, resetując strumień zamiast zwracać na wpół zapisaną rewizję
Zapis jest skończony dopiero, gdy wynik został ponownie otwarty tą samą ograniczoną ścieżką i porównany wpis po wpisie, a nieudane porównanie resetuje strumień docelowy
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
      { strumień docelowy już zresetowany, nic na wpół zapisanego nie zostało }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

Zaszyfrowane źródła są odrzucane i to jest uczciwe

Obecna implementacja odrzuca zaszyfrowane źródło wprost. Powód jest konkretny, a nie obronny: przebudowa drzewa wprowadza nowe obiekty stringów i może wprowadzić nowe referencje do strumieni, a §7.6 wymaga, żeby były szyfrowane per obiekt kluczem dokumentu oraz numerem obiektu i generacją właściciela. Skopiowanie wpisu /Encrypt z traila do przodu przy dopisywaniu jawnych stringów dałoby plik, który otwiera się, a potem czyta jako śmieci dokładnie tam, gdzie są twoje nowe klucze, co jest gorsze niż jasna odmowa. Warto wprost podać jeszcze dwie granice. Edytor pracuje na migawce strumienia źródłowego, więc źródła nie wolno modyfikować, dopóki żyje nad nim edytor, i odmawia traktowania katalogu dokumentu jako korzenia drzewa, bo katalog to słownik, który akurat zawiera drzewa, a nie sam węzeł drzewa. Jeśli utrzymujesz kod w Delphi, C++Builderze albo Lazarusie, który zapisuje /EmbeddedFiles, /Dests, /JavaScript lub /PageLabels, i łatałeś tablice liści ręcznie, edytor drzew i jego weryfikowany zapis inkrementalny są w komponencie PDFiumPas dla Delphi PDFium