Technical Article

Čtení akcí záložek a anotací PDF v Delphi

Dědíte složku PDF odněkud upstream a úkol zní triviálně: řekněte mi, které záložky skáčou na externí URL, které spouštějí JavaScript a kam ve skutečnosti míří ty interní. Pak otevřete referenci API a zjistíte, že knihovna umí každou z těchto akcí vytvořit, ale nenabízí žádný způsob, jak je přečíst zpět. Tahle asymetrie je v nástrojích pro PDF všude. Zapsat záložku, která otevře https://example.com je jednorázová věc; ptát se existující záložky „co děláš a na jaký cíl?“ obvykle znamená ručně projít syrový strom objektů přes /A, /S, /Dest a rozvětvení variant fit, které napoprvé téměř nikdo netrefí správně.

PDFlibPas je nativní PDF knihovna v Object Pascalu pro Delphi a C++Builder a dlouho měla stejnou mezeru: bohaté zapisovací settery, gettery, které vám vrátily jen holý TPDFObject a nechaly vás v tom hrabat. Vydání v3.77.0 tu část uzavřelo malou sadou typovaných introspekčních volání, která vracejí typ akce, datovou část akce i geometrii cíle jako prosté záznamy. Tento článek ukazuje, jak se tato volání mapují na model akcí a cílů podle ISO 32000-1 a na tři konkrétní nástrahy, kvůli nimž ručně psané verze tohoto kódu tiše selhávají.

Proč je čtení akcí těžší než jejich zápis

Akce v PDF je slovník s /S klíčem, který pojmenovává její podtyp: GoTo, GoToR, URI, Launch, Named, JavaScript, a delším ocasem, se kterým se téměř nesetkáte (ISO 32000-1 §12.6.4). Problém je v tom, že datová část je pro každý podtyp uložená v jiném klíči a neexistuje žádná jednotná přihrádka "dejte mi cíl". Akce URI uchovává svou adresu v /URI. Akce GoToR nebo Launch uchovává specifikaci souboru v /F. Akce JavaScript uchovává svůj skript v /JS, který může být buď řetězec, nebo stream. Akce GoTo nenese žádnou vlastní datovou část, jejím cílem je cíl visící na /D, který pak musíte vyřešit zvlášť.

Když akci zapisujete, znáte její typ předem, takže na tom nezáleží. Když ji čtete, musíte nejdřív větvit podle /S a potom sáhnout do správného klíče, a pak si poradit s tím, že stejný logický pojem ("věc, na kterou tato akce míří") je zakódovaný třemi vzájemně neslučitelnými způsoby. Přesně to větvení pohlcují typované gettery. GetOutlineActionInfo a GetAnnotActionInfo oba vracejí TPDFlibActionInfo záznam:

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;

Záznam vám řekne, která pole jsou smysluplná pomocí Kind. Pokud Kind se vrátí akURI, čtěte URI a zbytek ignorujte. Pokud se vrátí akGoTo, v tom případě se neuplatní žádné z polí nákladu a pokračuješ na cíl, což je samostatné volání popsané níže. akNone je poctivá odpověď, když záložka nebo poznámka nemá vůbec žádnou akci, místo nuly, u které bys musel hádat význam.

Procházení stromu osnovy při hledání záložky

Než můžeš záložku zkoumat, potřebuješ její handle. PDFlibPas identifikuje uzly osnovy pomocí celočíselného ID a FindOutlineByTitle ji najde podle viditelného textu s přesným řízením rozsahu vyhledávání:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argument Depth je část, u které se vyplatí se zastavit. osdSiblingsOnly prohledává řetězec sourozenců na úrovni výchozího uzlu a končí; najde sourozeneckou záložku, ale nikdy nesestoupí do potomků sourozence. osdChildrenOnly se dívá o úroveň níž, do přímých potomků výchozího uzlu. osdFullSubTree prochází celou větev rekurzivně. Volba špatné hodnoty ti potichu nic nenajde, není to chyba: hledání jen mezi sourozenci pro název, který je o dvě úrovně hlouběji, prostě vrátí nulu a ty dojdeš k závěru, že záložka neexistuje, i když tam celou dobu byla. Předávej GetFirstOutline jako startovní ID, pokud chceš hledat od kořene 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;

Porovnává se přesný řetězec názvu jako WideString, takže rozlišuje velikost písmen a respektuje Unicode text přesně tak, jak je uložený. Pokud tvoje zdrojové PDF pocházejí od nekonzistentních výrobců, normalizuj název, který hledáš, stejným způsobem, jakým ho dokument uložil, jinak budeš honit zdánlivé chyby.

Zjišťování akce a cíle záložky

S handlem v ruce GetOutlineActionInfo ti dá typovaný pohled. Postup je: zavolat ho, přepnout podle Kind, a přečíst pole, které daný typ vyplňuje.

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;

Tady číhá první skutečný háček a právě ten během implementace odhalila zpětná vazba z testů. Existuje starší getter, GetActionURL, a sáhnout po něm pro čtení URI akce je na pohled zřejmá chyba. GetActionURL převádí specifikaci souboru přes klíč /F. To je správné pro GoToR a Launch, jejichž cíle jsou skutečně soubory, ale pro URI akci je to úplně špatný klíč. URIAdresa akce je prostý řetězec v jejím vlastním /URI klíči, ne specifikace souboru. Když pošleš URI akci do cesty pro file spec, dostaneš prázdný nebo nesmyslný výsledek. Typovaný getter to řeší interně tím, že čte /URI přímo pro akURI a resolver specifikace souboru volá jen pro akGoToR a akLaunch, což je přesně ten rozdíl, který se ručně psané verzi snadno rozmaže.

Typy přizpůsobení cíle a geometrie, která za nimi stojí

Akce akGoTo znamená „pohyb v tomto dokumentu“, ale neříká nic o kde nebo jak. To je úkol cíle a cíle mají víc nuancí, než lidé čekají. PDF cíl není jen číslo stránky; je to stránka plus specifikace „fit“, která říká, jak má prohlížeč stránku orámovat (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo jej vrací jako záznam:

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;

Osm typů přizpůsobení odpovídá na různé otázky ohledně orámování. dkXYZ umístí konkrétní bod do levého horního rohu při zadaném přiblížení, takže používá Left, Top a Zoom. dkFit zaplní celou stránku v okně a ignoruje souřadnice. dkFitH a dkFitV zaplní šířku nebo výšku stránky pomocí jediné relevantní souřadnice (horní nebo levé hrany). dkFitR je nejzajímavější: přizpůsobí zadaný obdélník, takže záleží na všech čtyřech hranách. Rodina dkFitB* dělá totéž vzhledem k ohraničovacímu rámečku viditelného obsahu místo celé stránky. Vědět, která pole jsou pro každý typ aktivní, je rozdíl mezi správným přečtením cíle a vypsáním nesmyslných souřadnic, které náhodou vycházejí na nulu.

PDF reader bookmark navigation panel showing a nested outline tree
Každá záložka v tomto navigačním panelu se vyhodnotí na akci a u interních skoků na cíl s vlastním typem přizpůsobení a vlastními souřadnicemi.

Pod povrchem se implementace opírá o promyšlené zarovnání, které stojí za to znát, protože vysvětluje, proč je mapování spolehlivé. Vnitřní GetDestType vrací celé číslo 1..8 pro osm typů přizpůsobení přesně v pořadí XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind je deklarován tak, aby se jeho ordinální hodnoty zarovnaly jedna ku jedné: dkXYZ je ordinální hodnota 1, dkFitBV je ordinální hodnota 8, zatímco dkNone je na nule. Převod je tedy přímý casting ordinální hodnoty s kontrolou rozsahu, ne vyhledávací tabulka, která se může při rozšiřování výčtu rozjet. Je to drobnost, ale právě taková věc se při naivním řešení stane chybou o jednu pozici dřív, než někdo přeuspořádá výčty.

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;

Hodnota Page rovná nule je signál, že se cíl nepodařilo vyřešit, obvykle proto, že akce nemá žádný cíl nebo že pojmenovaný cíl nebyl nalezen. Zkontrolujte ji, než začnete věřit jakékoli souřadnici. Všimněte si také, že GetOutlineDestinationInfo hledá na obou místech, kde může cíl ležet: přímo na záložce /Dest, i uvnitř vložené GoTo akce /D. Nemusíte vědět, kterou podobu výrobce použil.

Akce anotací a past SelectPage

Anotace odkazů nesou akce přesně stejně jako záložky a GetAnnotActionInfo vrací stejný TPDFlibActionInfo záznam se stejným vzorem typ a pak datová část. Je tu ale stavová záludnost, která u osnov neplatí, a to je třetí past.

Anotace patří ke stránkám a PDFlibPas zpřístupňuje anotace aktuální stránky přes stav, který se stane platným až po výběru dané stránky. Zavolejte GetAnnotActionInfo bez předchozího volání SelectPage(N) a handle anotace je nula; volání vrátí akNone a vy mylně usoudíte, že stránka nemá žádné akční anotace. Oprava je na jeden řádek, ale při procházení stránek se na ni snadno zapomíná:

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;

Dvě věci v tom cyklu jsou záměrné. Zaprvé, SelectPage(P) předchází jakémukoli přístupu k anotacím v každé iteraci; stav anotací po stránkách se nepřenáší dál. Zadruhé, test existence používá GetAnnotActionID(1) <> 0 namísto CheckPageAnnots'je tu první anotace a nese akci, kterou mohu číst?' Ještě jedna drobnost, kterou stojí za to zmínit: u anotací se skript akce čte přímo z JavaScript skriptu akce /JS a při takovém uložení se dekóduje proud, jinak se čte řetězec, takže to funguje pro oba běžné způsoby uložení.

Kde se hodí čtenářská introspekce

Tyto gettery jsou záměrně úzké. Jsou to čistá čtení postavená na stávajících vrstvách akcí a cílů s celočíselným handlem v knihovně, takže se nedotýkají žádné zapisovací cesty a nepřidávají riziko dokumentům, které právě také upravujete. Hlásí to, co je v souboru; neověřují to podle politiky ani nic nepřepisují. Pokud je vaším cílem opak, tedy teprve vytvářet záložky a anotace odkazů, které tyto akce nesou, to patří na zapisovací stranu, a doprovodná část o interaktivní formulářové akce a JavaScript v Delphiprochází jejich vytvářením. Pokud z PDF potřebuješ vytáhnout viditelný a strukturální obsah místo jeho navigačního grafu, podívej se na extrahování textu, obrázků a písem pomocí PDFlibPas.

Poctivá hranice, kterou je dobré mít na paměti: introspekce vidí jen to, co výrobce skutečně zapsal. Záložka, jejíž akce byla generátorem ponechána v chybné podobě, nebo cíl odkazující na pojmenovaný cíl, který nikdy nebyl definován, se projeví jako akNone nebo nulová stránka, ne jako výjimka. To je správné chování pro čtecí API, které prověřuje nedůvěryhodné soubory, ale znamená to, že by tvůj kód měl s těmi nulovými výsledky zacházet jako s „chybějícími nebo nevyřešenými“, ne jako s potvrzením správně vytvořeného vstupu. Typovaná introspekce akcí a cílů ukázaná zde je součástí PDFlibPas, nativní PDF knihovny pro Delphi a C++Builder.