Artykuł techniczny

Odczyt akcji zakładek i adnotacji PDF w Delphi

Dostajesz w spadku folder z PDF-ami z jakiegoś wcześniejszego etapu i zadanie brzmi banalnie: powiedz, które zakładki skaczą do zewnętrznego URL, które uruchamiają JavaScript i gdzie naprawdę lądują te wewnętrzne. Potem otwierasz API reference i odkrywasz, że biblioteka potrafi utworzyć każdą z tych akcji, ale nie oferuje niczego do ich odczytu. Ta asymetria jest wszędzie w narzędziach PDF. Zapisanie zakładki otwierającej https://example.com to one-liner, ale zapytanie istniejącej zakładki „co robisz i do jakiego celu?” zwykle oznacza ręczne przechodzenie po surowym drzewie obiektów przez /A, /S, /Dest oraz cały rozgałęziony zestaw wariantów fit-type, który prawie nikomu nie wychodzi dobrze za pierwszym razem

PDFlibPas to natywna biblioteka PDF w Object Pascal dla Delphi i C++Builder i przez długi czas miała dokładnie tę samą lukę: bogate settery po stronie zapisu, a gettery zwracające jedynie surowe TPDFObject i zostawiające Cię z ręcznym spelunkingiem. Wersja v3.77.0 częściowo to domknęła, dodając niewielki zestaw typowanych wywołań introspekcyjnych, które raportują rodzaj akcji, jej payload i geometrię celu jako zwykłe rekordy. Ten artykuł wyjaśnia, jak te wywołania mapują się na model akcji i destinations z ISO 32000-1 oraz jakie trzy konkretne pułapki sprawiają, że własnoręcznie pisane wersje tego kodu po cichu działają źle

Dlaczego odczyt akcji jest trudniejszy niż ich zapis

Akcja w PDF to słownik z kluczem /S nazywającym jej podtyp: GoTo, GoToR, URI, Launch, Named, JavaScript i dłuższy ogon przypadków, które spotyka się rzadko, ISO 32000-1 §12.6.4. Problem polega na tym, że payload siedzi pod innym kluczem dla każdego podtypu i nie istnieje jednolite pole „daj mi target”. Akcja URI trzyma adres w /URI. Akcja GoToR albo Launch trzyma file specification w /F. Akcja JavaScript trzyma skrypt w /JS, które może być stringiem albo strumieniem. Akcja GoTo nie niesie żadnego własnego payloadu; jej celem jest destination zawieszony pod /D, które potem trzeba rozwiązać osobno

Kiedy zapisujesz akcję, z góry znasz jej rodzaj, więc nic z tego nie ma znaczenia. Kiedy ją odczytujesz, musisz najpierw rozgałęzić logikę po /S, potem sięgnąć do poprawnego klucza, a następnie obsłużyć fakt, że to samo pojęcie logiczne, czyli „to, na co wskazuje ta akcja”, jest kodowane na trzy niekompatybilne sposoby. Właśnie to rozgałęzienie przejmują typowane gettery. GetOutlineActionInfo oraz GetAnnotActionInfo zwracają rekord TPDFlibActionInfo:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Rekord mówi, które pola mają znaczenie, za pomocą Kind. Jeśli Kind wraca jako akURI, odczytujesz URI i ignorujesz resztę. Jeśli wraca jako akGoTo, żadne pola payloadu nie mają zastosowania i przechodzisz do destination, które jest osobnym wywołaniem omówionym dalej. akNone jest uczciwą odpowiedzią wtedy, gdy zakładka albo adnotacja nie ma żadnej akcji, zamiast zwracania zera, którego znaczenie trzeba zgadywać

Przechodzenie po drzewie outline, aby znaleźć zakładkę

Zanim zaczniesz analizować zakładkę, potrzebujesz jej handle. PDFlibPas identyfikuje węzły outline przez integer ID, a FindOutlineByTitle lokalizuje taki węzeł po widocznym tekście z jawną kontrolą nad tym, jak daleko ma sięgnąć wyszukiwanie:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

Argument Depth jest tutaj częścią wartą zatrzymania się. osdSiblingsOnly skanuje łańcuch rodzeństwa na poziomie węzła startowego i na tym kończy; znajdzie zakładkę równorzędną, ale nigdy nie zejdzie do dzieci sąsiada. osdChildrenOnly patrzy jeden poziom niżej, w bezpośrednie dzieci węzła startowego. osdFullSubTree rekurencyjnie przechodzi całą gałąź. Wybranie złej opcji kończy się cichym pudłem, a nie błędem: wyszukiwanie tylko po rodzeństwie dla tytułu siedzącego dwa poziomy głębiej po prostu zwraca zero i dochodzisz do wniosku, że zakładka nie istnieje, choć była tam cały czas. Przekaż GetFirstOutline jako start ID, aby szukać od korzenia dokumentu

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Dopasowanie działa po dokładnym stringu tytułu, porównywanym jako WideString, więc jest case-sensitive i respektuje tekst Unicode dokładnie tak, jak został zapisany. Jeśli źródłowe PDF-y pochodzą od niespójnych producentów, normalizuj szukany tytuł w taki sam sposób, w jaki dokument go przechowuje, inaczej będziesz ścigać fantomowe pudła

Rozwiązywanie akcji zakładki i jej celu

Mając już handle, GetOutlineActionInfo daje Ci widok typowany. Wzorzec jest taki: wywołujesz, przełączasz się po Kind i odczytujesz pole wypełniane przez dany rodzaj

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

To tutaj siedzi pierwsza prawdziwa pułapka i właśnie ją ujawnił feedback z testów podczas implementacji. Istnieje starszy getter, GetActionURL, i sięgnięcie po niego przy odczycie akcji URI jest błędem, który wygląda całkiem rozsądnie. GetActionURL rozwiązuje file specification przez klucz /F. To jest poprawne dla GoToR oraz Launch, których cele faktycznie są plikami, ale to zły klucz dla akcji URI. Akcja URI trzyma adres jako zwykły string pod własnym kluczem /URI, a nie jako file spec. Jeśli przepuścisz akcję URI przez ścieżkę file-spec, dostaniesz pusty albo bezsensowny wynik. Typed getter obsługuje to wewnętrznie, odczytując /URI bezpośrednio dla akURI i wywołując resolver file specification tylko dla akGoToR oraz akLaunch, czyli dokładnie to rozróżnienie, które ręcznie napisana wersja kodu najczęściej rozmywa

Typy dopasowania destination i stojąca za nimi geometria

Akcja akGoTo znaczy „nawiguj wewnątrz tego dokumentu”, ale nie mówi nic o tym, gdzie ani jak. To zadanie destination, a destinations niosą więcej niuansów, niż ludzie zwykle oczekują. Destination w PDF to nie tylko numer strony; to strona plus specyfikacja „fit”, mówiąca, jak viewer ma kadrować tę stronę, ISO 32000-1 §12.3.2.2. GetOutlineDestinationInfo zwraca to jako rekord:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Osiem rodzajów fit odpowiada na różne pytania kadrowania. dkXYZ ustawia konkretny punkt w lewym górnym rogu przy jawnym zoom, więc używa Left, Top oraz Zoom. dkFit dopasowuje całą stronę do okna i ignoruje współrzędne. dkFitH oraz dkFitV dopasowują szerokość albo wysokość strony z jedną istotną współrzędną, górną krawędzią albo lewą krawędzią. dkFitR jest tym ciekawym przypadkiem: dopasowuje wskazany prostokąt, więc wszystkie cztery krawędzie mają znaczenie. Rodzina dkFitB* robi to samo względem bounding box widocznej treści, a nie całej strony. Wiedza o tym, które pola są żywe dla danego rodzaju, decyduje o różnicy między poprawnym odczytem destination a wypisywaniem śmieciowych współrzędnych, które akurat mają wartość zero

PDF reader bookmark navigation panel showing a nested outline tree
Każda zakładka w tym panelu nawigacyjnym rozwiązuje się do akcji, a dla skoków wewnętrznych także do destination z własnym typem fit i współrzędnymi.

Pod spodem implementacja opiera się na celowym wyrównaniu, o którym warto wiedzieć, bo tłumaczy, dlaczego mapowanie jest wiarygodne. Wewnętrzne GetDestType zwraca integer 1..8 dla ośmiu rodzajów fit dokładnie w kolejności XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind jest zadeklarowane tak, aby ordinals układały się jeden do jednego: dkXYZ ma ordinal 1, dkFitBV ma ordinal 8, a dkNone siedzi na zerze. Dzięki temu konwersja jest bezpośrednim rzutowaniem ordinal z ochroną zakresu, a nie tabelą lookup, która może wyjść z synchronizacji wraz z rozrostem enum. To drobny detal, ale właśnie takie rzeczy zrobione naiwnie stają się błędem off-by-one przy pierwszym przestawieniu kolejności elementów enum

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Wartość Page równa zero jest sygnałem, że destination się nie rozwiązało, zwykle dlatego, że akcja nie niesie destination albo nie dało się znaleźć nazwanego celu. Sprawdź to, zanim zaufasz jakiejkolwiek współrzędnej. Zauważ też, że GetOutlineDestinationInfo sprawdza oba miejsca, w których destination może żyć: bezpośrednio pod /Dest zakładki oraz wewnątrz osadzonej akcji GoTo pod /D. Nie musisz wiedzieć, której formy użył producent

Akcje adnotacji i pułapka SelectPage

Adnotacje linków niosą akcje dokładnie tak samo jak zakładki, a GetAnnotActionInfo zwraca ten sam rekord TPDFlibActionInfo z tym samym wzorcem kind-then-payload. Jest tu jednak stanowa pułapka, która nie dotyczy outline i stanowi trzecią z wymienionych pułapek

Adnotacje należą do stron, a PDFlibPas wystawia adnotacje bieżącej strony przez stan, który staje się poprawny dopiero po wybraniu tej strony. Wywołaj GetAnnotActionInfo bez wcześniejszego SelectPage(N), a handle adnotacji będzie zerem; wywołanie zwróci akNone i błędnie uznasz, że strona nie ma żadnych adnotacji z akcją. Naprawa to jedna linijka, ale łatwo o niej zapomnieć podczas pętli po stronach:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Dwie rzeczy w tej pętli są celowe. Po pierwsze SelectPage(P) pojawia się przed każdym dostępem do adnotacji w każdej iteracji; stan adnotacji per-page się nie przenosi. Po drugie, test istnienia używa GetAnnotActionID(1) <> 0 zamiast CheckPageAnnots. To drugie raportuje obecność jako flagę w stylu boolean zamiast liczby, więc niezerowe action ID jest precyzyjniejszym sposobem zadania pytania „czy istnieje pierwsza adnotacja i czy niesie akcję, którą mogę odczytać?”. Warto zaznaczyć jeszcze jedną subtelność: dla adnotacji akcja JavaScript ma skrypt odczytywany bezpośrednio z /JS, z dekodowaniem strumienia, gdy skrypt jest zapisany w ten sposób, i odczytem stringu w przeciwnym razie, więc obsługiwane są oba najczęstsze kodowania

Gdzie pasuje introspekcja po stronie odczytu

Te gettery są celowo wąskie. To czyste odczyty zbudowane na istniejących w bibliotece warstwach akcji i destinations opartych na integer handle, więc nie dotykają żadnej ścieżki zapisu i nie dodają ryzyka do dokumentów, które jednocześnie edytujesz. Raportują to, co jest w pliku; nie walidują tego względem żadnej polityki i niczego nie przepisują. Jeśli Twoim celem jest przeciwieństwo, czyli budowanie zakładek i adnotacji linków, które niosą te akcje od początku, to temat należy do strony zapisu, a tekst uzupełniający o interaktywnych akcjach formularzy i JavaScript w Delphi prowadzi krok po kroku przez ich tworzenie. Jeśli chcesz wyciągać z PDF widoczną i strukturalną treść zamiast jego grafu nawigacji, zobacz wyodrębnianie tekstu, obrazów i fontów z PDFlibPas

Warto pamiętać o uczciwej granicy: introspekcja widzi tylko to, co producent faktycznie zapisał. Zakładka, której generator zostawił błędnie zbudowaną akcję, albo destination wskazujące nazwany target, którego nigdy nie zdefiniowano, ujawni się jako akNone albo strona zero zamiast wyjątku. To poprawne zachowanie dla API odczytu audytującego niezaufane pliki, ale oznacza to, że Twój kod powinien traktować takie zerowe wyniki jako „brakujące albo nierozwiązane”, a nie jako gwarancję poprawnie zbudowanego wejścia. Pokazana tutaj introspekcja typed action i destination jest częścią PDFlibPas, natywnej biblioteki PDF dla Delphi i C++Builder