Artykuł techniczny

Dodawanie zakładek do istniejących plików PDF w Delphi za pomocą HotPDF

Komponent HotPDF dodaje zakładki do istniejącego pliku PDF w Delphi za pomocą metody AddLoadedOutline, która tworzy wpisy spisu treści (outline) na dowolnym poziomie zagnieżdżenia w załadowanym dokumencie. Umożliwia również odczyt i zapis nazwanych miejsc docelowych (named destinations) za pomocą ResolveLoadedNamedDestination oraz AddLoadedNamedDestination. Wczytaj plik, zbuduj drzewo spisu, wywołaj SaveLoadedDocument i gotowe — panel boczny czytnika, który dotąd pozostawał pusty, zawiera teraz działający spis treści

Scenariusz motywujący te zmiany jest wyjątkowo powszechny. Łączysz kilkanaście umów w jeden pakiet do recenzji lub zestawiasz trzy instrukcje obsługi w jeden plik dystrybucyjny. Rezultat jest strukturalnie poprawny: każda strona na swoim miejscu, każda czcionka nienaruszona. Jednak gdy ktoś otwiera plik w programie Acrobat, panel zakładek okazuje się pusty. 400-stronicowy dokument bez nawigacji jest technicznie kompletny, lecz w praktyce nieużyteczny. Do wersji 2.347.0 HotPDF potrafił odczytywać, modyfikować i usuwać zakładki w załadowanych dokumentach, ale nie pozwalał na ich tworzenie. Ta luka została teraz uzupełniona. W tym artykule omawiamy nowe API oraz jeden ze specyficznych elementów specyfikacji PDF — klucz /Count, który może sprawić niespodziankę, jeśli potraktujesz go jako zwykły licznik wpisów

Czym w rzeczywistości jest drzewo spisu treści

Spis treści w pliku PDF (outline) to podwójnie powiązane drzewo słowników, a nie płaska lista. Ta struktura tłumaczy każdy parametr w API. Standard ISO 32000-1 §12.3.3 definiuje to następująco: katalog główny dokumentu (catalog) wskazuje na korzeń /Outlines, ten z kolei odsyła do pierwszego i ostatniego elementu podrzędnego za pomocą kluczy /First oraz /Last, rodzeństwo łączy się poprzez /Next i /Prev, a każdy wpis wskazuje w górę na rodzica przez /Parent. Każda pozycja posiada ciąg tekstowy /Title oraz, najczęściej, cel /Dest określający, dokąd kliknięcie ma przenieść czytelnika

Cele (destinations) występują w dwóch odmianach, które standard ISO 32000-1 §12.3.2 celowo rozróżnia. Cel jawny (explicit destination) to wbudowana tablica, np. [page /XYZ x y zoom]: bezpośrednia referencja do obiektu strony wraz ze specyfikacją widoku. Z kolei cel nazwany (named destination) przechowuje identyfikator tekstowy, a rzeczywisty punkt docelowy znajduje się w katalogu dokumentu w drzewie nazw /Names /Dests. Ten poziom pośrednictwa pozwala wielu odnośnikom współdzielić jeden cel, co umożliwia modyfikację punktu docelowego bez ingerencji w same linki. HotPDF zapisuje jawne cele /XYZ przy tworzeniu zakładek oraz udostępnia dedykowane funkcje do odczytu i rozbudowy drzewa nazw

Jak dodać zakładki do istniejącego pliku PDF w Delphi?

Wywołaj metodę AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) po wczytaniu pliku przez LoadFromFile: przekaż ParentIndex = -1 dla wpisu głównego lub indeks (liczony od 0) istniejącej zakładki, aby utworzyć pod nią pozycję zagnieżdżoną. Funkcja tworzy korzeń /Outlines, jeśli dokument go nie posiadał, buduje słownik wpisu, zapisuje tablicę /Dest [page /XYZ x y zoom] wskazującą na stronę o indeksie DestPageIndex (liczonym od 0) i dołącza nową pozycję do łańcucha rodzeństwa. Funkcja zwraca 0 w przypadku powodzenia lub -1 przy niepowodzeniu (na przykład gdy indeks strony DestPageIndex jest poza zakresem)

Ważny dla porządku zakładek jest szczegół łączenia: nowa pozycja jest wstawiana na początku listy dzieci swojego rodzica jako nowy klucz /First. Jeśli więc dodasz „Rozdział 1”, a potem „Rozdział 2” jako pozycje główne, Rozdział 2 pojawi się nad Rozdziałem 1 na panelu bocznym, a Rozdział 1 przesunie się na indeks 1. W praktyce najwygodniej jest dodawać wpisy w kolejności odwrotnej do czytania lub dodawać rodzica i od razu uzupełniać jego dzieci, gdy rodzic wciąż znajduje się na indeksie 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Dodawaj rozdziały w kolejności odwrotnej do czytania: każdy nowy
      // wpis główny staje się pozycją /First (indeks 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Rozdział 1 ma teraz indeks główny 0; zagnieżdżaj sekcje
      // pod nim (dzieci również są wstawiane na początku, stąd kolejność odwrotna).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Parametry X, Y oraz Zoom odpowiadają bezpośrednio właściwościom celu /XYZ i domyślnie przyjmują wartość 0. Plik PDF umieszcza punkt początkowy współrzędnych w lewym dolnym rogu strony, więc wartość Y = 792 wskazuje na górną krawędź strony formatu Letter. Zgodnie z ISO 32000-1 zerowa wartość w polu parametru /XYZ informuje przeglądarkę o konieczności zachowania bieżącego powiększenia, co w większości przypadków jest pożądanym zachowaniem. Wszystkie modyfikacje są dokonywane na grafie obiektów w pamięci; dane są zapisywane na dysku dopiero po uruchomieniu SaveLoadedDocument — to ten sam schemat edycji i zapisu, który API załadowanego dokumentu stosuje przy nadpisywaniu tytułu, autora oraz metadanych XMP w załadowanych plikach PDF

Zmiana celów zakładek: skoki do stron kontra akcje URI

Istniejące zakładki mogą być modyfikowane bez konieczności ich ponownego tworzenia. HotPDF udostępnia do tego dwie różne funkcje, ponieważ struktury PDF pod nimi są odmienne. Metoda SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) zastępuje parametr /Dest wpisu głównego o indeksie Index nową tablicą /XYZ. Jest to narzędzie przydatne przy klasycznym problemie po łączeniu dokumentów, gdy zakładki wciąż wskazują na numery stron pierwotnych plików przed połączeniem. Z kolei funkcja SetLoadedOutlineURI(Index, URI) wykonuje odmienną modyfikację strukturalną: dołącza słownik akcji /A z wartością /S /URI, przekształcając zakładkę w odnośnik internetowy zamiast skoku wewnątrz dokumentu

// Połączenie przesunęło wszystko o 10 stron: wskaż nowy cel
// dla trzeciej głównej zakładki na stronę 12 (indeks 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Zamień ostatnią pozycję w link zewnętrzny.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Rozróżniaj oba te mechanizmy przy projektowaniu struktury drzewa. Parametr /Dest służy wyłącznie do nawigacji wewnątrz dokumentu; akcja URI wyprowadza czytelnika poza dokument, a standard ISO 32000-1 zakłada, że wpis spisu treści korzysta z jednego z tych mechanizmów, a nie z obu jednocześnie. Zarezerwuj więc funkcję SetLoadedOutlineURI dla pozycji, które nie posiadają celu na stronie dokumentu — np. dla odnośników typu „Strona domowa produktu” lub „Zgłoś błąd” na końcu spisu, a nie dla nagłówków rozdziałów. Zapisywana tu akcja URI to ta sama konstrukcja /S /URI, którą stosuje się przy adnotacjach linków na zawartości stron, co opisano szerzej w przewodniku po tworzeniu hiperłączy w dokumentach PDF za pomocą HotPDF

Jak działają nazwane cele w załadowanym pliku PDF?

Nazwane cele (named destinations) to mechanizm stałych kotwic opisany w standardzie ISO 32000-1 §12.3.2.3: zamiast osadzać referencję do strony w każdym linku, dokument przechowuje mapowanie nazw w drzewie nazw /Names /Dests w katalogu głównym, a linki odwołują się do tych wpisów po nazwie. Funkcja ResolveLoadedNamedDestination(Name) wyszukuje nazwę w tym drzewie i zwraca indeks strony (liczony od 0), na którą wskazuje cel, lub -1 w przypadku braku nazwy. Mechanizm obsługuje obie formy zapisu dozwolone przez specyfikację: czystą tablicę docelową, np. [page /XYZ x y zoom], oraz postać słownikową zawierającą tablicę pod kluczem /D

Funkcja AddLoadedNamedDestination(Name, DestPageIndex, X, Y) działa w drugą stronę: rejestruje nową kotwicę w drzewie /Names /Dests, tworząc samo drzewo, jeśli dokument go nie posiadał, i zwraca wartość True w przypadku powodzenia. Wspólnie obie metody pozwalają na obsługę scenariusza, w którym połączony dokument musi uwzględniać kotwice utworzone przez inne narzędzia i publikować nowe na potrzeby linków zewnętrznych

// Uwzględnij kotwicę zapisaną przez inne narzędzie w /Names /Dests.
PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
if PageIdx >= 0 then
  Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

// Opublikuj nową kotwicę dla innych linków.
if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
  Pdf.SaveLoadedDocument('anchored.pdf');

Szczere ograniczenia: cele zapisywane przez HotPDF, zarówno dla zakładek, jak i kotwic nazwanych, to wyłącznie cele typu /XYZ. Inne typy zdefiniowane w specyfikacji, takie jak /Fit, /FitH czy /FitB, nie są generowane przez te funkcje, chociaż zapis /XYZ z zerowym powiększeniem pokrywa większość zastosowań typu „przejdź do tego miejsca, zachowaj powiększenie”. Z kolei pomocnicza funkcja wyszukiwania GetLoadedBookmarkPageIndex(Title), która przyjmuje tytuł zakładki i zwraca docelową stronę, skanuje wyłącznie główny poziom drzewa zakładek, stąd używaj jej do weryfikacji wpisów na poziomie rozdziałów, a nie głęboko zagnieżdżonych pozycji

Pułapka /Count: zliczanie potomków, a nie pozycji

Klucz /Count w korzeniu spisu treści nie oznacza tego, co zakłada większość programistów, a błędne założenia prowadzą do błędów testów sugerujących uszkodzenie danych. Norma ISO 32000-1 §12.3.3 definiuje klucz /Count jako łączną liczbę vidocznych potomków na wszystkich poziomach, a nie liczbę wpisów głównych. Przy pojedynczym wpisie znak ma również znaczenie: wartość dodatnia oznacza, że wpis jest rozwinięty i wskazana liczba potomków jest widoczna na panelu, natomiast wartość ujemna oznacza, że wpis jest zwinięty i |N| potomków pozostaje ukrytych

Problem ten ujawnił się w testach regresyjnych HotPDF podczas prac nad API zakładek załadowanego dokumentu. Testowy dokument zawierał trzy zakładki główne, z których jedna miała pozycję podrzędną. Po usunięciu jednego wpisu głównego, funkcja GetLoadedOutlineCount (odczytująca klucz /Count) zwróciła wartość 1 zamiast oczekiwanej 2. Dane nie zostały uszkodzone: początkowa wartość 3 nie oznaczała liczby wpisów głównych, a poprawne przeliczenie wymaga przejścia po głównym łańcuchu spisu, sumując każdy węzeł oraz jego dodatnią wartość /Count, z pominięciem dzieci pozycji zwiniętych. Wniosek dla Twojego kodu: nigdy nie opieraj asercji na zmianach wartości /Count po edycji spisu, ponieważ wartość ta zmienia się nieliniowo względem widocznych potomków. Zamiast tego weryfikuj strukturę na podstawie zawartości

// Po edycji weryfikuj po tytule, a nie na podstawie różnic /Count.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Miejsce zakładek w pakietach narzędzi dla załadowanych plików PDF

Budowanie spisu treści uzupełnia możliwości, które od pewnego czasu oferuje API dla załadowanych dokumentów: ten sam cykl wczytaj-edytuj-zapisz pozwala na modyfikację metadanych, dodawanie linków oraz wymianę adnotacji poprzez import i eksport XFDF. Schemat jest jednolity: wywołanie LoadFromFile, sekwencja metod *Loaded* na grafie obiektów w pamięci, a na koniec SaveLoadedDocument. Ułatwia to wdrożenie tworzenia spisu treści jako kolejnego etapu przed zapisem w istniejących procesach łączenia czy znakowania plików

Opisane tu funkcje API: AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination oraz GetLoadedBookmarkPageIndex wchodzą w skład standardowej wersji HotPDF Component dla Delphi i C++Builder, bez zewnętrznych zależności i bez konieczności uruchamiania osobnego silnika przeglądarki