Artykuł techniczny

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

HotPDF Delphi Component usuwa stronę z wczytanego PDF przez THotPDF.DeletePage i od wersji 2.751.0 to wywołanie przycina także każde odwołanie na poziomie dokumentu, które wciąż wskazuje na tę stronę: nazwane miejsca docelowe w drzewie /Names /Dests, odziedziczony słownik /Dests w katalogu, akcje /GoTo zakładek, elementy struktury pod /StructTreeRoot, ParentTree, wpisy OBJR dla adnotacji oraz adnotacje odsyłaczy na stronach, które zostają. Drzewo stron jest odbudowywane na końcu, gdy nic innego nie może już dotrzeć do usuwanego obiektu

Awaria, której to zapobiega, jest łatwa do odtworzenia i trudna do zdiagnozowania. Usuń stronę okładki z otagowanego raportu, zapisz i otwórz wynik: Acrobat pokazuje właściwą liczbę stron, ale zakładka „Contents” trafia teraz donikąd, sprawdzanie dostępności zgłasza element struktury bez strony, a rygorystyczny walidator wymienia odwołanie do zwolnionego obiektu. W drzewie stron wszystko jest w porządku. Problem w tym, że strona PDF to nie tylko liść /Pages; to cel, na który wskazuje połowa katalogu, a usunięcie liścia zostawia każdy z tych wskaźników wiszącym

Dlaczego usunięcie strony z /Kids nie wystarcza?

Bo ISO 32000-1 pozwala co najmniej siedmiu niezależnym strukturom trzymać odwołanie do obiektu strony, a tylko jedna z nich to drzewo stron. Usunięcie strony z /Kids i zmniejszenie /Count spełnia §7.7.3, a każde inne odwołanie staje się wskaźnikiem do obiektu, który albo jest zwolniony w xref, albo po prostu nieobecny w przepisanym pliku. Czytnik, który pójdzie za jednym z tych wskaźników, dostaje null, a co z tym nullem zrobi, to już jego sprawa

  • 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, trzymany bezpośrednio w katalogu, zawiera ten sam rodzaj tablic kluczowanych nazwą
  • Elementy konspektu (§12.3.3) docierają do strony albo przez wbudowane /Dest, albo przez akcję /A z /S /GoTo i tablicą /D
  • Elementy struktury (§14.7.2) noszą klucz /Pg wskazujący stronę, na której żyje ich treść oznaczona, a ich dzieci /K mogą być odwołaniami do treści oznaczonych i odwołaniami do obiektów (§14.7.4.3) powią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 żyć, nie pojawiając się w ogóle na łańcuchu /K od korzenia
  • Adnotacje odsyłaczy na innych stronach (§12.5.6.5) noszą /Dest albo akcję /GoTo celującą w tę 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 sprząta, zanim dotknie drzewa stron?

THotPDF.DeletePage(PageIndex) na wczytanym dokumencie przeprowadza najpierw cały przegląd odwołań, potem oznacza obiekt strony jako usunięty przez DeleteObj, odłącza adnotacje widgetów od drzewa pól AcroForm, przesuwa wewnętrzną tablicę stron i na końcu woła RebuildLoadedPageTree, żeby przepisać /Kids, /Count i /Parent każdej ocalałej strony. Przegląd odwiedza katalog w ustalonej kolejności: drzewo nazw /Names /Dests, stary słownik /Dests, /OpenAction, drzewo konspektu, /StructTreeRoot wraz z jego ParentTree i na końcu tablice /Annots każdej strony, która zostaje. Każdy krok decyduje, czy odwołanie zostaje usunięte, przekierowane, czy nietknięte, stosownie do tego, co specyfikacja pozwala tej strukturze robić bez tej strony. Przed tym wszystkim działają dwa zabezpieczenia: DeletePage rzuca Invalid page number przy indeksie poza zakresem i odmawia usunięcia ostatniej strony, bo węzeł /Pages bez ani jednego dziecka nie jest poprawnym PDF-em, a DeletePages przyjmuje tę samą liczoną od jedynki notację "1,3-5,7-" co pozostałe operacje na stronach wczytanego dokumentu i iteruje od najwyższego wybranego indeksu w dół, żeby wypisane indeksy pozostawały poprawne w trakcie pracy

Ustalony przegląd odwołań, który THotPDF.DeletePage wykonuje, zanim dotknie drzewa stron: zabezpieczenia odrzucają indeks poza zakresem albo ostatnią stronę, potem /Names /Dests i odziedziczony /Dests są przycinane, /OpenAction odrzucane, konspekt przekierowywany na NearestRetainedPage, StructTreeRoot i ParentTree przycinane, odsyłacze na zachowanych stronach usuwane, a RebuildLoadedPageTree biegnie na końcu
Każda struktura dostaje to, na co pozwala specyfikacja: nazwy znikają, zakładki lądują na najbliższej zachowanej stronie, elementy struktury tracą /Pg albo znikają, a przepisanie /Kids następuje dopiero wtedy, gdy nic innego nie może dotrzeć do usuwanego obiektu
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Liczone od zera: usuń stronę okładki. Nazwane miejsca
      // docelowe, zakładki, drzewo struktury, ParentTree i adnotacje
      // odsyłaczy, które na nią wskazywały, są przycinane, zanim
      // drzewo /Pages zostanie odbudowane.
      Pdf.DeletePage(0);
      // Liczona od jedynki składnia zakresów dla partii, najwyższy indeks
      // pierwszy wewnętrznie, żeby wcześniejsze indeksy pozostały poprawne.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Czym różni się obsługa nazwanych miejsc docelowych i zakładek?

Nazwane miejsca docelowe są usuwane, a zakładki przekierowywane, bo nazwa, która już nie istnieje, jest do przyjęcia, natomiast zakładka bez celu to widoczny defekt. W drzewie /Names /Dests HotPDF przechodzi każdy węzeł, testuje każde miejsce docelowe, zarówno w postaci gołej tablicy, jak i słownika z kluczem /D, względem usuwanej strony i usuwa parę nazwa-wartość, gdy pierwszym elementem tablicy jest ta strona. Węzeł, którego /Names i /Kids są oba puste, jest oznaczany jako usunięty i odpinany od rodzica, żeby drzewo nigdy nie trzymało pustych liści. Ten sam test biegnie po starym słowniku /Dests w katalogu, a katalogowe /OpenAction jest po prostu odrzucane, jeśli otwierało usuniętą stronę. Jedna granica tutaj: gdy węzeł drzewa nazw traci wpisy, HotPDF usuwa jego parę /Limits, zamiast przeliczać nowe najniższe i najwyższe klucze, i choć czytniki rozwiązują nazwy bez tego bez problemu, rygorystyczny walidator zgodności czytający ISO 32000-1 §7.9.6 może zgłosić węzeł inny niż korzeń, któremu brakuje /Limits

Elementy konspektu idą w drugą stronę. RetargetOutlineDestinations przechodzi /First i /Next od korzenia konspektu, z listą odwiedzonych i limitem głębokości 128, żeby uszkodzone, cykliczne drzewo nie zawiesiło wywołania, i przy każdej tablicy /Dest albo tablicy /D akcji /GoTo wycelowanej w tę stronę zastępuje pierwszy element przez NearestRetainedPage: stronę, która nastąpiła po usuniętej, albo tę przed nią, gdy usuwana strona była ostatnia. Parametry widoku po odwołaniu do strony zostają takie, jakie były. Zakładka wskazująca na usunięty początek rozdziału ląduje więc na pierwszej stronie tego, co zostało, zamiast zniknąć z paska bocznego, a takiego zachowania recenzenci oczekują od okrojonego dokumentu. Test miejsc docelowych dopasowuje jednak tylko jawne tablice: element konspektu, którego /Dest jest łańcuchem nazwy, który wcześniej rozwiązywał się do usuwanej strony, nie jest przekierowywany, bo wpis w drzewie nazw zniknął i odwołanie rozwiązuje się teraz do niczego, a nie do zwolnionego obiektu, więc czytelnik traktuje je jak martwą zakładkę. Mechanika samego drzewa konspektu, /First, /Next i nieoczywista semantyka /Count, jest opisana w przewodniku po dodawaniu zakładek i nazwanych miejsc docelowych do wczytanego PDF

// Sprawdź przegląd, zamiast mu wierzyć.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Zakładka, która celowała w okładkę, rozwiązuje się teraz do
// strony, która po niej następowała (liczony od zera indeks 0 po usunięciu).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Co się dzieje z drzewem struktury i ParentTree?

Elementy struktury istniejące wyłącznie z powodu usuwanej strony są usuwane, a elementy rozciągające się na kilka stron tracą klucz /Pg, ale zachowują dzieci. PruneStructureElement schodzi po łańcuchu /K od /StructTreeRoot na głębokość 128, obsługując i postać tablicową, i postać pojedynczego słownika /K, na którą pozwala §14.7.2. Dla każdego elementu najpierw przycina dzieci, a potem ocenia sam element: jeśli przycinanie opróżniło jego /K, element jest oznaczany jako usunięty i jego rodzic go odrzuca. Jeśli własne /Pg elementu wskazuje usuwaną stronę, a element ma jeszcze dzieci i rodzica /P, usuwane jest tylko /Pg, bo /Pg na elemencie jest domyślną stroną dla jego dzieci treści oznaczonych, a te dzieci mogą jawnie odwoływać się do innych stron. Usuwany wprost jest tylko element, którego /Pg to usuwana strona i pod którym nic już nie zostało

ParentTree dostaje to samo traktowanie, a powód jest ten, który dał się we znaki w trakcie prac: element struktury może być osiągalny z ParentTree i skądinąd nigdzie. Drzewo liczb mapuje liczby całkowite /StructParents na pojedynczy element albo tablicę elementów, a PruneParentTreeNode przepuszcza PruneStructureElement po każdej znalezionej wartości, usuwa wartości, które zostały przycięte, kasuje parę /Nums, gdy jej tablica wartości jest pusta, i odczepia węzeł, którego /Nums i /Kids oba zniknęły. Przycinanie samych potomków /K zostawiłoby te osierocone elementy wskazujące na zwolnioną stronę przez /Pg i na zwolnione odwołania treści oznaczonych przez ich dzieci /MCR. Jeśli wyciągasz tekst w kolejności struktury, ma to bezpośrednie znaczenie: ekstrakcja tekstu w kolejności struktury przechodzi dokładnie te drzewa, a element z pustym /Pg to akapit, który po cichu wypada z kolejności czytania

Które adnotacje odsyłaczy na zachowanych stronach są usuwane?

Każda adnotacja odsyłacza na zachowanej stronie, której tablica /Dest albo akcja /GoTo wskazuje usuwaną stronę, jest usuwana razem ze swoją własnością w drzewie struktury. RemoveRetainedPageDestinationAnnotations przechodzi tablicę /Annots każdej strony poza celem, stosuje ten sam test miejsc docelowych co dla konspektu, oznacza pasującą adnotację jako usuniętą, wyrzuca ją z tablicy, a potem woła PruneAnnotationReferencesInStructureTree, żeby słownik OBJR, którego /Obj wskazywał tę adnotację, został usunięty ze swojego elementu struktury, a sam element usunięty, jeśli OBJR był jego jedynym dzieckiem. Zostawienie OBJR na miejscu łamałoby §14.7.4.3, który wymaga, by /Obj wskazywał istniejący obiekt, i wyszłoby w sprawdzaniu PDF/UA jako otagowany odsyłacz bez adnotacji pod spodem. Zwróć uwagę na asymetrię wobec zakładek: odsyłacze są usuwane, a nie przekierowywane. Odsyłacz w treści mówiący „zobacz stronę 3” jest błędny, gdy strony 3 już nie ma, a wskazanie go na stronę 4 byłoby kłamstwem w sposób, w jaki zakładka lądująca na najbliższym rozdziale nie jest, więc jeśli twój proces wymaga zachowania tych odsyłaczy, przekieruj je sam przed wywołaniem DeletePage

Dlaczego usuniętego /MCR albo /OBJR nigdy nie wolno rejestrować jako wolnego?

Bo odwołania do treści oznaczonych i odwołania do obiektów są zwykle bezpośrednimi słownikami wewnątrz tablicy /K swojego elementu nadrzędnego, a rejestr zmian przyrostowych rozwiązuje obiekt bezpośredni do najbliższego obiektu pośredniego, który go zawiera. Kiedy RemoveArrayItem wyrzuca dziecko z tablicy /K, zwalnia obiekt w pamięci tylko wtedy, gdy był to THPDFLink albo wartość niepośrednia, a MarkRemovedObject rejestruje obiekt na liście wolnych tylko wtedy, gdy jego numer obiektu jest większy od zera. Pierwsza wersja tego przeglądu nie robiła takiego rozróżnienia, a efekt przy zapisie przyrostowym był dokładnie tym, do czego rejestr jest zaprojektowany: RegisterIncrementalChange szedł od bezpośredniego /MCR w górę do swojego korzenia transakcji grafu, którym był zachowany element struktury będący jego właścicielem, i wypisywał ten element jako null. Dokument, który stracił jedną stronę, wracał z treścią oznaczoną na pozostałych stronach po cichu pozbawioną tagów. Jedynym poprawnym ruchem dla bezpośredniego dziecka jest oznaczenie jego kontenera jako brudnego przez TouchContainer, żeby kontener został przepisany, i zostawienie listy wolnych w spokoju

Dlaczego usuniętego dziecka /MCR albo OBJR nigdy nie wolno rejestrować jako wolnego w HotPDF: rejestr zmian przyrostowych rozwiązuje bezpośredni słownik do najbliższego pośredniego kontenera, więc pierwsza wersja wypisywała zachowany element struktury jako null i po cichu zdejmowała tagi z ocalałych stron, a TouchContainer przepisuje teraz kontener i zostawia listę wolnych w spokoju
Zwalnianie dziecka w pamięci jest zarezerwowane dla THPDFLink albo wartości niepośrednich oraz dla numerów obiektów większych od zera, więc zapis przyrostowy dopisuje tylko tknięte kontenery i zwolniony obiekt strony
// Aktualizacja przyrostowa: tylko tknięte kontenery i
// zwolniony obiekt strony trafiają do dopisanej sekcji.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Zachowane elementy struktury, których /K straciło bezpośredni /MCR,
  // są przepisywane w miejscu, nigdy nie są wypisywane jako null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Ta sama ostrożność kształtuje to, czego DeletePage celowo nie zwalnia na wczytanym dokumencie. Strumienie treści, XObjects i adnotacje inne niż widgety na usuwanej stronie zostają jako obiekty, bo wczytany plik może dzielić którykolwiek z nich ze stroną, która zostaje, a taniego sposobu, żeby dowieść, że tak nie jest, w chwili usuwania nie ma. Usunięcie odwołania w drzewie stron wystarcza do poprawności; bajty, które te obiekty nadal zajmują, to osobne pytanie, a graf zależności obiektów i analiza zatrzymanych bajtów to narzędzie do mierzenia, co okrojony dokument jeszcze niesie

DeletePage czy DeleteLoadedPage: które wywołać?

Wywołuj DeletePage przy każdym usunięciu strony widocznym dla użytkownika, a DeleteLoadedPage zachowaj na przypadek, gdy cały dokument jest przerabiany i żadne odwołanie na poziomie dokumentu nie jest warte zachowania. THotPDF.DeleteLoadedPage(PageIndex), dodane w wersji 2.508.0, to wariant lekki: przesuwa wewnętrzną tablicę stron, woła RebuildLoadedKidsArray, żeby przepisać /Kids i /Count, unieważnia cache wyrenderowanych stron i wywołuje OnLoadedDocumentModified. Nie przechodzi drzewa nazw, konspektu, drzewa struktury ani adnotacji innych stron i nie oznacza obiektu strony jako usuniętego. To właściwe narzędzie w impozycji N-up, gdzie HotPDF dokłada świeżo złożone arkusze, a potem usuwa każdą oryginalną stronę przez DeleteLoadedPage(0): strony źródłowe są wymieniane hurtowo, a treść arkusza odwołuje się do ich zasobów, a nie do obiektów stron. Przy zwykłym zadaniu usuń stronę 7 z tej umowy DeletePage jest jedynym wywołaniem, które zostawia otagowany, opatrzony zakładkami i posieciowany odsyłaczami dokument na tyle spójny, żeby przeszedł walidator, zarówno przy pełnym przepisaniu przez SaveLoadedDocument, jak i przy aktualizacji przyrostowej przez SaveIncrementalUpdate. Obie metody są w HotPDF Delphi Component dla Delphi i C++Buildera, bez wymagania zewnętrznego runtime'u czytnika ani żadnej zależności