Artykuł techniczny

Usuwanie stron PDF w Delphi bez wiszących odwołań z HotPDF

Otagowany raport z wyciętą stroną okładki może otwierać się z właściwą liczbą stron i mimo to być uszkodzony w trzech miejscach: zakładka „Contents" nie reaguje na kliknięcie, tester dostępności zgłasza element struktury nienależący do żadnej strony, a ścisły walidator wyłapuje odwołanie do obiektu wolnego. Drzewo stron jest w porządku. ISO 32000-1 §7.7.3 żąda tylko, aby strona zniknęła z /Kids i aby /Count zmniejszył się o jeden, i plik może spełniać ten punkt, gdy połowa jego katalogu wciąż wskazuje na brakującą stronę

Siedem struktur może trzymać odwołanie do jednej strony

Tylko jedna z nich to drzewo stron. Reszta rozciąga się na katalog, konspekt, strukturę logiczną i adnotacje innych stron, i każda z nich rozwiązuje się do null, gdy tylko obiekt strony zniknie z pliku. Co przeglądarka zrobi z tym nullem, to jej wybór, więc ten sam przycięty plik może zachowywać się różnie w dwóch czytnikach

  • Drzewo nazw pod /Names /Dests (§7.7.4, §12.3.2.3) mapuje nazwy na tablice miejsc docelowych, których pierwszym elementem jest strona
  • Słownik /Dests sprzed wersji 1.2, wprost w katalogu, trzyma tablice tego samego rodzaju, kluczowane nazwą
  • Elementy konspektu (§12.3.3) sięgają strony przez wprost podane /Dest albo przez akcję /A z /S /GoTo i tablicą /D
  • Elementy struktury (§14.7.2) niosą klucz /Pg nazywający stronę, na której żyje ich oznaczona zawartość, a ich dzieci w /K mogą być odwołaniami do oznaczonej zawartości i odwołaniami do obiektów (§14.7.4.3) związanymi z tą stroną
  • ParentTree (§14.7.4.4) mapuje numery /StructParents stron i adnotacji z powrotem na elementy struktury, a element może tam siedzieć, nie pojawiając się nigdzie w łańcuchu /K od korzenia
  • Adnotacje odsyłaczy na innych stronach (§12.5.6.5) niosą /Dest albo akcję /GoTo celującą w stronę, a katalogowe /OpenAction może robić to samo
Dlaczego usunięcie strony HotPDF z /Kids nie wystarcza: ISO 32000-1 pozwala drzewu nazw /Names /Dests, odziedziczonemu słownikowi /Dests w katalogu, elementom konspektu, elementom struktury z /Pg, ParentTree, adnotacjom odsyłaczy i /OpenAction trzymać odwołanie do tego samego obiektu strony, a odbudowywane jest tylko drzewo stron
Strona PDF to cel, na który wskazuje połowa katalogu: usunięcie liścia zadowala drzewo stron, a każdy inny wskaźnik rozwiązuje się do nulla, więc okrojony raport traci zakładkę Contents i oblewa sprawdzanie dostępności

Co THotPDF.DeletePage robi, zanim dotknie /Kids

THotPDF.DeletePage(PageIndex) na wczytanym dokumencie najpierw przeczesuje każdą z tych struktur, a drzewo stron przepisuje na końcu. Indeks jest liczony od zera. Zanim cokolwiek się zmieni, działają dwa zabezpieczenia: indeks poza zakresem podnosi Invalid page number, a żądanie usunięcia jedynej pozostałej strony też kończy się wyjątkiem, bo węzeł /Pages bez żadnego dziecka nie opisuje poprawnego dokumentu

Kolejność przeczesywania jest stała: drzewo /Names /Dests, legacy słownik /Dests w katalogu, /OpenAction, konspekt, /StructTreeRoot razem z jego ParentTree, a na końcu tablice /Annots każdej strony, która zostaje. Dopiero potem obiekt strony jest oznaczany jako usunięty, jego widgety są odczepiane od drzewa pól AcroForm, a /Kids, /Count i /Parent każdej zachowanej strony są przepisywane. Po stronie formularzy pole nadrzędne, które po zniknięciu widgetu nie ma już dzieci, również jest usuwane, aż po /Fields włącznie, więc przycięty formularz nie zostawia wpisu pola z niczym na ekranie

Stała kolejność przeczesywania odwołań, którą THotPDF.DeletePage uruchamia przed dotknięciem drzewa stron: zabezpieczenia odrzucają indeks poza zakresem i ostatnią stronę, potem przycinane są /Names /Dests i legacy /Dests, usuwane jest /OpenAction, miejsca docelowe konspektu trafiają na najbliższą zachowaną stronę, przycinane są StructTreeRoot i ParentTree, usuwane są łącza z zachowanych stron, a na końcu przepisywane są /Kids, /Count i /Parent
Każda struktura dostaje traktowanie, na jakie pozwala specyfikacja: nazwy znikają, zakładki lądują na najbliższej zachowanej stronie, elementy struktury tracą /Pg albo znikają, a przepisanie /Kids odbywa się dopiero wtedy, gdy nic innego nie sięga już usuniętego obiektu

DeletePages przyjmuje oparty na jedynkach zakres w postaci łańcucha, taki jak '1,3-5,7-' — notację, której używają pozostałe operacje na stronach wczytanego dokumentu — i usuwa od najwyższej wybranej strony w dół. Przechodzenie z góry na dół utrzymuje ważność wpisanych numerów, choć strony znikają spod nich. Zakres obejmujący wszystkie strony kończy się wyjątkiem z tego samego powodu, dla którego DeletePage odmawia ostatniej strony, a funkcja zwraca liczbę usuniętych stron

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile zwraca liczbę wczytanych stron
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // Indeksy od zera: strona 0 to okładka
      Pdf.DeletePage(0);
      // Zakres liczony od jedynki, względem dokumentu takim, jakim jest teraz
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Nazwy znikają, zakładki zmieniają cel

Nazwane miejsca docelowe celujące w usuwaną stronę są usuwane. Zakładki celujące w nią dostają nowy cel. W drzewie /Names /Dests odwiedzony zostaje każdy węzeł, a każde miejsce docelowe jest testowane w obu formach dozwolonych przez specyfikację: gołej tablicy i słownika z kluczem /D; para nazwa/wartość znika, gdy pierwszym elementem tablicy jest usuwana strona. Węzeł pośredni, w którym nie zostaje ani /Names, ani /Kids, jest odczepiany od rodzica, więc drzewo nie trzyma wydrążonych liści. Legacy słownik /Dests w katalogu dostaje ten sam test, klucz po kluczu, a katalogowe /OpenAction, które otwierało dokument na usuwanej stronie, jest usuwane — widok początkowy zostaje domyślnemu zachowaniu przeglądarki

Węzeł, który traci wpisy, traci też swoją tablicę /Limits. Nowych skrajnych kluczy nie wylicza się od nowa, a przeglądarki rozwiązują nazwy bez nich. §7.9.6 jednak wymaga /Limits na węzłach pośrednich i liściach, więc jeśli preflight w twoim pipeline ściśle egzekwuje tę regułę, przepuść przycięty plik przez niego w tym samym zadaniu

Konspekt jest przechodzony przez /First i /Next z listą odwiedzonych i limitem głębokości 128, więc uszkodzony łańcuch /Next zapętlony w sobie nie zawiesi wywołania. Dla każdej tablicy /Dest albo tablicy /D akcji /GoTo celującej w usuwaną stronę pierwszy element jest zastępowany stroną, która następowała po niej, albo stroną poprzedzającą, gdy usuwana strona była ostatnia. Parametry widoku za odwołaniem do strony zostają bez zmian, więc skok /XYZ zachowuje swoje wartości left, top i zoom i stosuje je do nowej strony

Asymetria jest zamierzona. Nazwa, która nie rozwiązuje się do niczego, jest legalna i nieszkodliwa; GoTo wskazujące nieistniejące miejsce docelowe po prostu nic nie robi. Zakładka z miejscem docelowym null jest gorsza: wciąż siedzi na pasku bocznym, wygląda klikalnie i zawodzi. Lądowanie na najbliższej zachowanej stronie to lepszy z dwóch niedoskonałych wyników. Zmiana celu obejmuje tylko jawne tablice miejsc docelowych. Element konspektu, którego /Dest jest łańcuchem nazwy, rozwiązuje się przez drzewo nazw, a gdy przeczesywanie usunęło już tę nazwę, zakładka nie prowadzi nigdzie — dokumenty intensywnie używające nazwanych zakładek zasługują po usunięciu na sprawdzenie. Test poniżej czyta nazwy przez ResolveLoadedNamedDestination, które patrzy wyłącznie na tablicę /Names węzła korzenia; daje sensowną odpowiedź dla płaskiego drzewa nazw, natomiast drzewo podzielone na /Kids zwraca -1 dla każdej nazwy. Sama struktura konspektu, łącznie z nieoczywistą semantyką /Count, jest opisana w przewodniku po dodawaniu zakładek i nazwanych miejsc docelowych we wczytanym PDF

// Sprawdź wynik zamiast go zakładać.
// Memo1 to TMemo na twoim formularzu.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination czyta tylko korzeniową tablicę /Names,
// więc ten test ma sens dla płaskiego drzewa nazw; na drzewie
// z /Kids zwraca -1 dla każdej nazwy, usuniętej czy nie
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Zakładka, która wskazywała okładkę, rozwiązuje się teraz do strony,
// która następowała po niej, czyli indeksu 0 po usunięciu
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Przycinanie drzewa struktury i ParentTree

Elementy struktury, które istniały wyłącznie dla usuwanej strony, są usuwane, a elementy rozciągające się na kilka stron tracą klucz /Pg, zachowując dzieci. Przejście startuje od /StructTreeRoot, schodzi po /K do głębokości 128 i akceptuje zarówno formę tablicową, jak i jednosłownikową formę /K, na którą pozwala §14.7.2. Dzieci każdego elementu przycina się, zanim osądzony zostanie sam element. Jeśli przycięcie opróżniło jego /K, element jest usuwany, a rodzic go zrzuca. Jeśli jego własny /Pg nazywa usuwaną stronę, ale ma jeszcze dzieci i rodzica /P, znika tylko /Pg, bo /Pg na elemencie to jedynie domyślna strona dla dzieci z oznaczonej zawartości, które nie nazywają własnej, a te dzieci mogą mieszkać na innych stronach. Element, którego /Pg to usuwana strona i który nie ma nic pod sobą, jest usuwany całkowicie; słowniki odwołań do oznaczonej zawartości, których /Pg nazywa tę stronę, podpadają pod tę samą regułę

ParentTree dostaje to samo traktowanie, i to z powodu, który łatwo przeoczyć: element może być osiągalny z ParentTree i znikąd indziej. To drzewo numeryczne mapuje liczby całkowite /StructParents na pojedynczy element albo tablicę elementów. Każda wartość jest przycinana według reguł powyżej, usunięte wartości wypadają ze swoich tablic, para /Nums z pustą tablicą jest usuwana, a węzeł bez /Nums i bez /Kids jest odczepiany. Pomiń ten etap, a sieroty wciąż będą wskazywać uwolnioną stronę przez /Pg. Dla kogoś, kto wyciąga tekst w kolejności tagów, to oczywiste: wyodrębnianie tekstu w kolejności struktury przechodzi dokładnie te drzewa, a element z /Pg równym null to akapit, który wypada z kolejności czytania

Dlaczego łącza na zachowanych stronach są usuwane, a nie przepinane?

Adnotacja odsyłacza na zachowanej stronie, której tablica /Dest albo akcja /GoTo celuje w usuwaną stronę, jest usuwana z tablicy /Annots tej strony, a jej tag idzie za nią. Słownik OBJR, którego /Obj wskazywało tę adnotację, jest wyciągany z jej elementu struktury; jeśli ten OBJR był jedynym dzieckiem elementu, usuwany jest też element, a ParentTree jest przycinane ponownie. Pozostawienie OBJR złamałoby §14.7.4.3, który oczekuje, że /Obj wskazuje prawdziwy obiekt, a checker PDF/UA zobaczyłby element Link, którego odwołanie do obiektu nie prowadzi nigdzie

Zakładki zmieniają cel; łącza nie. Odsyłacz w treści, który mówi „zobacz stronę 3", jest nieprawdziwy, gdy strona 3 znika, a po cichu przepięcie go na stronę 4 zamienia zepsute łącze w mylące. Zakładka lądująca na następnym rozdziale to nawigacja; łącze w treści to twierdzenie o zawartości. Usunięcie jest bezpieczniejszym domyśłem. Jeśli twój workflow potrzebuje, by te łącza przetrwały, przepisz ich miejsca docelowe przed wywołaniem DeletePage, bo po wywołaniu są już poza tablicami /Annots

Zapisy przyrostowe i pułapka bezpośredniego dziecka

DeletePage zachowuje się tak samo między BeginIncrementalUpdate a SaveIncrementalUpdate. Dopisywana sekcja trzyma obiekty zmienione przez przeczesywanie plus wpisy wolne dla usuniętych obiektów, które miały własne numery obiektów, jak strona i usunięte adnotacje odsyłaczy. Wszystko powyżej pierwotnego %%EOF zostaje bajt w bajt, jak wymaga §7.5.6

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Elementy struktury, które straciły bezpośrednie dziecko /MCR,
  // są dopisywane w przepisanej formie z krótszą tablicą /K
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

To jest miejsce, w którym ręcznie pisany pruner się potyka. Odwołania do oznaczonej zawartości (/Type /MCR) i odwołania do obiektów (/Type /OBJR) to typowo bezpośrednie słowniki siedzące w tablicy /K elementu rodzica. Obiekt bezpośredni nie ma numeru obiektu ani wpisu xref, więc nie da się go uwolnić osobno. Oczywisty ruch to mimo wszystko „uwolnienie" każdego usuniętego dziecka — i każdy writer, który mapuje obiekt bezpośredni na obiekt pośredni go trzymający, uwolni wtedy trzymacza, czyli zachowany element struktury. Ten element wychodzi jako null, a otagowana zawartość na stronach, których nikt nie ruszał, traci tagi. HotPDF wysyła usunięty obiekt na listę wolnych tylko wtedy, gdy ma własny numer obiektu; dla bezpośredniego dziecka oznacza zawierający go element jako zmieniony, więc element jest przepisywany bez dziecka, a lista wolnych zostaje czysta

Dlaczego usunięte dziecko /MCR albo OBJR nie może być zarejestrowane jako wolne w aktualizacji przyrostowej: to bezpośredni słownik wewnątrz tablicy /K swojego elementu, więc jego uwolnienie oznaczałoby uwolnienie zawierającego go elementu struktury, który zostałby zapisany jako null i zostawił zachowane strony bez tagów, podczas gdy poprawny ruch przepisuje kontener i zostawia listę wolnych w spokoju
Na listę wolnych trafiają wyłącznie obiekty z własnym numerem obiektu, więc zapis przyrostowy dopisuje przepisane kontenery plus wpisy wolne dla strony i pozostałych usuniętych obiektów pośrednich

Ta sama ostrożność decyduje o tym, czego DeletePage nie rusza na wczytanym dokumencie. Strumienie zawartości, XObjecty i adnotacje inne niż widgety usuwanej strony zostają jako obiekty, bo wczytany plik może dzielić każdy z nich ze stroną, która zostaje, a w momencie usuwania nie ma taniego sposobu, by udowodnić inaczej. Zrzucenie odwołania w drzewie stron wystarcza, by plik był poprawny. Ile bajtów kosztują te pozostałości, to osobny pomiar, a graf zależności obiektów i analiza zatrzymanych bajtów to narzędzie do niego

DeletePage czy DeleteLoadedPage?

Używaj DeletePage do każdego usunięcia strony, o które poprosił użytkownik. THotPDF.DeleteLoadedPage(PageIndex) to wersja lekka: stosuje te same dwa zabezpieczenia, przesuwa listę wczytanych stron, przepisuje /Kids i /Count, unieważnia cache wyrenderowanych stron i odpala OnLoadedDocumentModified. Nie dotyka drzewa nazw, konspektu, drzewa struktury ani adnotacji innych stron i nie oznacza obiektu strony jako usuniętego. ComposeLoadedPagesNUp to robota, do której pasuje: dopisuje świeżo złożone arkusze rysujące strony źródłowe jako form XObject, po czym zrzuca każdą oryginalną przez DeleteLoadedPage(0), bo arkusze odwołują się do zawartości stron, nie do obiektów stron. Przy „usuń stronę 7 z tej umowy" na pliku otagowanym, z zakładkami i łączami krzyżowymi, DeletePage to wywołanie, które zostawia każde odwołanie spójne — czy zapisujesz przez SaveLoadedDocument, czy dopisujesz przez SaveIncrementalUpdate

Obie metody, zakresowy DeletePages i użyta wyżej ścieżka aktualizacji przyrostowej, są częścią HotPDF Delphi Component, który edytuje wczytane PDF-y z kodu Delphi i C++Builder bez runtime'u przeglądarki na maszynie