Artykuł techniczny

Dodawanie zakładek do istniejących PDF w Delphi z HotPDF

HotPDF dodaje zakładki do istniejącego pliku PDF w Delphi przez AddLoadedOutline, które tworzy wpisy konspektu na dowolnym poziomie zagnieżdżenia we wczytanym dokumencie, oraz odczytuje i zapisuje nazwane miejsca docelowe przez ResolveLoadedNamedDestination i AddLoadedNamedDestination. Wczytaj plik, zbuduj drzewo, wywołaj SaveLoadedDocument, a pasek boczny czytnika, który kiedyś był pusty, niesie teraz działający spis treści

Scenariusz, który to wszystko motywuje, jest przygnębiająco powszechny. Scalasz tuzin umów w jeden pakiet do przeglądu albo zszywasz trzy instrukcje produktowe w jeden plik do dystrybucji, a wyjście jest strukturalnie w porządku: każda strona obecna, każda czcionka nietknięta. Potem ktoś otwiera je w Acrobacie, a panel zakładek jest pusty. Dokument na 400 stron bez nawigacji jest technicznie kompletny i praktycznie bezużyteczny, a do wersji 2.347.0 HotPDF potrafił odczytywać, modyfikować i usuwać zakładki we wczytanych dokumentach, lecz nigdy ich tworzyć. Ta luka jest już zamknięta, a ten artykuł prowadzi przez nową powierzchnię API i przez jeden kawałek pedeefowej ezoteryki, klucz /Count, który cię ugryzie, jeśli potraktujesz go jak prosty licznik wpisów

Czym naprawdę jest drzewo konspektu

Konspekt PDF to podwójnie wiązane drzewo słowników, a nie płaska lista, i ta struktura wyjaśnia każdy parametr w API. ISO 32000-1 §12.3.3 to definiuje: katalog dokumentu wskazuje na korzeń /Outlines, korzeń wskazuje swoje pierwsze i ostatnie dziecko przez /First i /Last, rodzeństwo łączy się w łańcuch przez /Next i /Prev, a każdy wpis wskazuje w górę przez /Parent. Każdy wpis niesie łańcuch /Title i zwykle /Dest mówiące, dokąd kliknięcie ma zabrać czytelnika

Miejsca docelowe występują w dwóch odmianach, a ISO 32000-1 §12.3.2 celowo trzyma je osobno. Miejsce jawne to wbudowana tablica taka jak [page /XYZ x y zoom]: bezpośrednie odwołanie do obiektu strony plus specyfikacja widoku. Miejsce nazwane przechowuje zamiast tego łańcuch nazwy, a właściwy cel mieszka w katalogu dokumentu w drzewie nazw /Names /Dests, jeden poziom pośredniości, który pozwala wielu odsyłaczom dzielić jeden cel i pozwala celowi się przesunąć bez dotykania odsyłaczy. HotPDF zapisuje jawne miejsca /XYZ, gdy tworzy zakładki, i daje ci osobne wywołania do odczytu i rozszerzania drzewa nazw

Diagram HotPDF podwójnie wiązanego drzewa konspektu PDF, które buduje AddLoadedOutline: katalog dokumentu wskazuje korzeń Outlines, First i Last korzenia sięgają skrajnych wpisów, rodzeństwo łączy się przez Next i Prev, każdy wpis przechowuje Parent, a drzewo Names Dests rozwiązuje nazwane miejsca docelowe
Konspekt to podwójnie wiązane drzewo — korzeń trzyma /First i /Last, rodzeństwo łączy się przez /Next i /Prev, każdy wpis przechowuje /Parent, a każdy cel kliknięcia jedzie w tablicy /Dest [page /XYZ]

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

Wywołaj AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) po LoadFromFile: podaj ParentIndex = -1 dla wpisu najwyższego poziomu albo liczony od zera indeks istniejącego wpisu najwyższego poziomu, by zagnieździć się pod nim. Funkcja tworzy korzeń /Outlines, jeśli dokument nigdy go nie miał, buduje słownik wpisu, zapisuje tablicę /Dest [page /XYZ x y zoom] wskazującą na liczony od zera DestPageIndex i wpina nowy wpis w łańcuch rodzeństwa. Zwraca 0 przy powodzeniu i -1 przy niepowodzeniu, na przykład gdy DestPageIndex jest poza zakresem

Jeden szczegół wiązania ma znaczenie dla kolejności: nowy wpis jest wstawiany na czoło łańcucha dzieci swojego rodzica, jako nowy /First. Więc jeśli dodasz „Chapter 1”, a potem „Chapter 2” jako wpisy najwyższego poziomu, Chapter 2 pojawi się w pasku bocznym nad Chapter 1, a Chapter 1 przesunie się na indeks 1 najwyższego poziomu. Praktyczny wzorzec to albo dodawanie wpisów w odwróconej kolejności czytania, albo dodanie każdego rodzica i natychmiastowe zapełnienie jego dzieci, dopóki wciąż siedzi na indeksie 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Dodaj rozdziały w odwróconej kolejności czytania: każdy nowy
      // wpis najwyższego poziomu staje się /First (indeks 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 jest teraz indeksem 0 najwyższego poziomu; zagnieźdź pod nim
      // sekcje (dzieci też trafiają na czoło, więc odwrotna kolejność).
      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 i Zoom odwzorowują się wprost na miejsce docelowe /XYZ i wszystkie domyślnie wynoszą 0. PDF umieszcza początek układu współrzędnych w lewym dolnym rogu, więc Y = 792 celuje w górę strony US Letter, a zgodnie z ISO 32000-1 wartość zerowa w gnieździe /XYZ mówi przeglądarce, by zachowała bieżące ustawienie, co jest dokładnie tym, czego chcesz dla powiększenia w niemal każdym przypadku. Wszystko dzieje się na wczytanym grafie obiektów w pamięci; nic nie dotyka dysku, dopóki nie uruchomi się SaveLoadedDocument, ten sam model edytuj-i-zapisz, którego API wczytanego dokumentu używa do przepisywania tytułu, autora i metadanych XMP we wczytanych plikach PDF

Przekierowywanie zakładek: skoki po stronach kontra akcje URI

Istniejące zakładki można przepiąć bez tworzenia ich od nowa, a HotPDF daje tym dwóm przypadkom dwa odrębne wywołania, ponieważ konstrukcje PDF pod spodem naprawdę się różnią. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) zastępuje /Dest wpisu najwyższego poziomu o danym Index świeżą tablicą /XYZ, czyli narzędzie na klasyczną awarię po scaleniu, gdy każda zakładka wciąż wskazuje numery stron oryginalnego pliku sprzed scalenia. SetLoadedOutlineURI(Index, URI) robi coś strukturalnie innego: dołącza słownik akcji /A z /S /URI, zamieniając zakładkę w odsyłacz sieciowy zamiast skoku wewnątrz dokumentu

Dwa sposoby, na jakie HotPDF przepina istniejącą zakładkę: SetLoadedOutlineDestination zapisuje wewnątrzdokumentową tablicę Dest XYZ dla skoków po stronach, a SetLoadedOutlineURI dołącza słownik akcji A z S URI otwierający stronę internetową
SetLoadedOutlineDestination przepisuje tablicę /Dest na potrzeby skoków wewnątrz dokumentu, czyli poprawkę na zakładki po scaleniu wciąż wskazujące strony sprzed scalenia, a SetLoadedOutlineURI podmienia ją na akcję /A /S /URI zarezerwowaną dla wpisów liściowych opuszczających dokument
// Scalanie przesunęło wszystko o 10 stron: przekieruj
// trzecią zakładkę najwyższego poziomu na stronę 12 (od zera 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Zamień ostatni wpis w odsyłacz zewnętrzny.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Trzymaj te dwa mechanizmy osobno, gdy projektujesz drzewo. /Dest to czysta nawigacja wewnątrz dokumentu; akcja URI opuszcza dokument całkowicie, a ISO 32000-1 oczekuje, że wpis konspektu nawiguje jednym mechanizmem albo drugim, nie oboma. Zarezerwuj więc SetLoadedOutlineURI dla wpisów, które nie niosą już miejsca docelowego na stronie, jakiegoś liścia w rodzaju „Strona produktu” albo „Zgłoś problem” na końcu drzewa, a nie dla przerobionego nagłówka rozdziału. Zapisywana tu akcja URI to ta sama konstrukcja /S /URI, której używają adnotacje odsyłaczy na treści strony, omówiona głębiej w przewodniku po tworzeniu hiperłączy w dokumentach PDF z HotPDF

Jak działają nazwane miejsca docelowe we wczytanym pliku PDF?

Nazwane miejsca docelowe to mechanizm stabilnych kotwic z ISO 32000-1 §12.3.2.3: zamiast osadzać odwołanie do strony w każdym odsyłaczu, dokument trzyma mapę nazwa-na-miejsce w drzewie nazw /Names /Dests katalogu, a odsyłacze odwołują się do wpisów po nazwie. ResolveLoadedNamedDestination(Name) szuka nazwy w tym drzewie i zwraca liczony od zera indeks strony, na którą wskazuje, albo -1, gdy nazwy nie ma. Resolwer obsługuje obie formy przechowywania, które specyfikacja dopuszcza dla odwzorowanej wartości: gołą tablicę miejsca w rodzaju [page /XYZ x y zoom] oraz formę słownikową opakowującą tę tablicę pod kluczem /D

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) idzie w drugą stronę: rejestruje nową kotwicę w drzewie /Names /Dests, tworząc samo drzewo, gdy dokument żadnego nie ma, i zwraca True przy powodzeniu. Razem ta para pokrywa proces, w którym scalony dokument musi honorować kotwice zapisane przez wcześniejsze narzędzia i publikować nowe, w które będą celować odsyłacze dalej w łańcuchu

var
  PageIdx: Integer;
begin
  // Uszanuj kotwicę, którą inne narzędzie wpisało do /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ę, w którą będą celować inne odsyłacze.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Uczciwe granice: miejsca docelowe, które HotPDF zapisuje, zarówno dla zakładek, jak i dla nazwanych kotwic, to miejsca /XYZ. Pozostałe typy jawne definiowane przez specyfikację, /Fit, /FitH, /FitB i pokrewne, nie są przez te wywołania emitowane, choć /XYZ z zerowym powiększeniem pokrywa dominujący przypadek użycia „idź w to miejsce, zachowaj moje powiększenie”. A GetLoadedBookmarkPageIndex(Title), wygodne wyszukiwanie przyjmujące tytuł zakładki i zwracające jej stronę docelową, przeszukuje najwyższy poziom drzewa konspektu, więc używaj go do weryfikowania właśnie utworzonych wpisów na poziomie rozdziałów, a nie głęboko zagnieżdżonych liści

Pułapka /Count: liczy potomków, a nie wpisy

Klucz /Count na korzeniu konspektu nie znaczy tego, co zakłada większość programistów, a błędne założenie produkuje awarie testów wyglądające jak uszkodzenie danych. ISO 32000-1 §12.3.3 definiuje /Count jako łączną liczbę widocznych potomków na wszystkich poziomach, a nie liczbę wpisów najwyższego poziomu. Na pojedynczym wpisie znaczenie niesie też znak: wartość dodatnia oznacza, że wpis jest otwarty i tylu potomków widać w pasku bocznym, natomiast wartość ujemna oznacza, że wpis jest zwinięty i |N| potomków jest w nim ukrytych

Wyszło to z własnego zestawu regresyjnego HotPDF w czasie budowania API zakładek dla wczytanych dokumentów. Dokument testowy trzymał trzy zakładki najwyższego poziomu, jedną z dzieckiem; po usunięciu jednego wpisu najwyższego poziomu GetLoadedOutlineCount, które odczytuje /Count korzenia, zgłosiło 1 zamiast oczekiwanych 2. Nic nie zostało uszkodzone: początkowa 3 nigdy nie oznaczała „trzech wpisów najwyższego poziomu”, a poprawne przeliczenie musi przejść łańcuch najwyższego poziomu, sumując każdy węzeł plus jego dodatni /Count i pomijając potomków węzłów zwiniętych. Lekcja dla twojego kodu jest wprost: nigdy nie stawiaj asercji na różnicach /Count po edycjach strukturalnych, ponieważ ta wartość porusza się nieliniowo względem widocznych potomków. Zamiast tego weryfikuj strukturę po treści

HotPDF: semantyka /Count w konspektach PDF: dodatni Count oznacza wpis otwarty, którego dzieci pozostają widoczne i policzone, ujemny Count zwija potomków, a usunięcie jednej zakładki najwyższego poziomu przesuwa liczbę korzenia z czterech na trzy, a nie o jeden na wiersz
Korzeniowy /Count sumuje widocznych potomków na wszystkich poziomach, więc o tym, jak się porusza, decydują znak i zagnieżdżenie — asercja na różnicy po usunięciach zawodzi, a weryfikacja po tytule przez GetLoadedBookmarkPageIndex jest uczciwym sprawdzeniem
// Po edycjach weryfikuj wyszukiwaniem po tytule, a nie różnicami /Count.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Gdzie to pasuje w zestawie narzędzi wczytanego dokumentu

Tworzenie zakładek i nazwanych miejsc docelowych domyka obraz, który API wczytanego dokumentu wypełnia od pewnego czasu: ten sam cykl wczytaj-edytuj-zapisz już przepisuje metadane dokumentu, dodaje adnotacje odsyłaczy i przeprowadza adnotacje w obie strony przez import i eksport XFDF. Wzorzec jest jednolity: LoadFromFile, sekwencja wywołań *Loaded* na grafie obiektów w pamięci, a potem jedno SaveLoadedDocument, co ułatwia wpięcie budowy konspektu w istniejący potok scalania albo stemplowania jako jeszcze jednego kroku przed zapisem

Omawiane tu API, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination i GetLoadedBookmarkPageIndex, są dostarczane w standardowym komponencie HotPDF dla Delphi oraz C++Builder, bez zewnętrznych zależności i bez potrzeby osobnego środowiska uruchomieniowego przeglądarki