Odborný článok

Čítanie bookmark a annotation akcií v PDF v Delphi

Zdedia sa vám priečinky PDF súborov z nejakého upstream zdroja a úloha znie triviálne: povedzte mi, ktoré bookmarky skáču na externé URL, ktoré spúšťajú JavaScript a kam presne mieria interné odkazy. Potom otvoríte API referenciu a zistíte, že knižnica vie každú z týchto akcií vytvoriť, ale neponúka nič na ich spätné čítanie. Táto asymetria je v PDF toolingu všade. Zapísať bookmark, ktorý otvorí https://example.com, je otázka jedného riadka. Spýtať sa existujúceho bookmarku "čo robíš a na aký cieľ?" zvyčajne znamená ručne prechádzať surový strom objektov cez /A, /S, /Dest a celý rozvetvený strom fit-type variantov, ktoré takmer nikto neurobí správne na prvýkrát

PDFlibPas je natívna Object Pascal PDF knižnica pre Delphi a C++Builder a dlhý čas mala ten istý nedostatok: bohaté write-side setter API, ale gettery, ktoré vám vrátili len holé TPDFObject a zvyšok nechali na ručné spelunkovanie. Vydanie v3.77.0 časť tejto medzery uzavrelo malou sadou typed introspection volaní, ktoré vracajú druh akcie, payload akcie a geometriu cieľa ako obyčajné záznamy. Tento článok vysvetľuje, ako sa tieto volania mapujú na model akcií a cieľov v ISO 32000-1, a ukazuje tri konkrétne pasce, kvôli ktorým si ručne napísaná verzia tohto kódu ticho pokazí výsledok

Prečo je čítanie akcií ťažšie než ich zápis

Akcia v PDF je slovník s kľúčom /S, ktorý určuje jej subtype: GoTo, GoToR, URI, Launch, Named, JavaScript a dlhší chvost variantov, s ktorými sa stretávate len zriedka (ISO 32000-1 §12.6.4). Problém je v tom, že payload žije pri každom subtype v inom kľúči a neexistuje žiadny jednotný slot typu "daj mi cieľ". Akcia URI drží svoju adresu v /URI. Akcia GoToR alebo Launch drží file specification v /F. Akcia JavaScript drží svoj skript v /JS, pričom môže ísť buď o string, alebo stream. Akcia GoTo nenesie vlastný payload vôbec. Jej cieľom je destination zavesený pod /D, ktorý potom musíte vyriešiť samostatne

Keď akciu zapisujete, jej typ poznáte vopred, takže nič z toho nevadí. Keď ju čítate, musíte najprv vetviť podľa /S, potom siahnuť na správny kľúč a potom zvládnuť fakt, že ten istý logický pojem, teda "vec, na ktorú táto akcia ukazuje", je zakódovaný tromi nekompatibilnými spôsobmi. Presne túto vetviacu logiku typed gettery absorbujú. GetOutlineActionInfo a GetAnnotActionInfo obidve vracajú záznam 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;

Záznam vám cez hodnotu Kind povie, ktoré polia majú význam. Ak sa Kind vráti ako akURI, čítajte URI a zvyšok ignorujte. Ak sa vráti ako akGoTo, vtedy sa nepoužije žiadne pole payloadu a pokračujete k destination, ktoré sa rieši samostatným volaním nižšie. akNone je poctivá odpoveď v prípade, že bookmark alebo annotation nemá žiadnu akciu, namiesto nejakej nuly, pri ktorej by ste len hádali jej význam

Prechod stromom osnovy pri hľadaní bookmarku

Skôr než môžete bookmark introspektovať, potrebujete jeho handle. PDFlibPas identifikuje outline uzly celočíselným ID a FindOutlineByTitle ho vie nájsť podľa viditeľného textu s explicitnou kontrolou nad tým, ako hlboko sa má hľadať:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Pri argumente Depth sa oplatí zastaviť. osdSiblingsOnly prehľadá reťazec súrodencov na úrovni štartovacieho uzla a skončí, takže nájde peer bookmark, ale nikdy nezíde do detí súrodenca. osdChildrenOnly ide o jednu úroveň nižšie, do priamych detí štartovacieho uzla. osdFullSubTree rekurzívne prejde celú vetvu. Vybrať nesprávnu možnosť znamená tiché netrafenie, nie chybu: sibling-only vyhľadávanie názvu, ktorý leží o dve úrovne nižšie, jednoducho vráti nulu a vy usúdite, že bookmark neexistuje, hoci tam celý čas bol. Na hľadanie od koreňa dokumentu odovzdajte ako štartovacie ID GetFirstOutline

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ávanie sa robí s presným title stringom, porovnaným ako WideString, takže je case-sensitive a rešpektuje Unicode text presne tak, ako je uložený. Ak vaše zdrojové PDF prichádzajú od nekonzistentných producentov, normalizujte hľadaný title rovnakým spôsobom, akým ho dokument uložil, inak budete naháňať zdanlivé chýbajúce zhody

Vyhodnotenie akcie bookmarku a jej cieľa

Keď máte handle, GetOutlineActionInfo vám dá typed pohľad. Vzor je jednoduchý: zavolajte ho, switchnite podľa Kind a čítajte pole, ktoré tento druh akcie napĺňa

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;

Prvá skutočná pasca je práve tu a ukázala ju spätná väzba z testov počas implementácie. Existuje starší getter, GetActionURL, a siahnuť po ňom pri čítaní akcie URI vyzerá na prvý pohľad správne. GetActionURL vyhodnocuje file specification cez kľúč /F. To je správne pre GoToR a Launch, ktorých cieľmi skutočne sú súbory, ale pri akcii URI je to úplne nesprávny kľúč. Akcia URI drží svoju adresu ako obyčajný string priamo vo vlastnom kľúči /URI, nie ako file spec. Ak pošlete akciu URI cez cestu file-spec, dostanete prázdny alebo nezmyselný výsledok. Typed getter to rieši interne tak, že číta /URI priamo pre akURI a resolver file specification volá len pre akGoToR a akLaunch, čo je presne ten rozdiel, ktorý ručne písaná verzia kódu zvykne rozmazať

Destination fit typy a geometria, ktorá za nimi stojí

Akcia akGoTo znamená "naviguj v rámci tohto dokumentu", ale sama o sebe nehovorí nič o tom, kam ani ako. To je úloha destination a destination nesú viac jemných rozdielov, než ľudia čakajú. PDF destination nie je len číslo strany. Je to strana plus špecifikácia typu "fit", ktorá hovorí, ako má viewer túto stranu orámovať (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo ju vracia ako 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;

Týchto osem typov fit odpovedá na rôzne otázky rámovania. dkXYZ umiestni konkrétny bod do ľavého horného rohu pri explicitnom zoome, takže používa Left, Top a Zoom. dkFit prispôsobí do okna celú stranu a súradnice ignoruje. dkFitH a dkFitV prispôsobia šírku alebo výšku strany s jedinou relevantnou súradnicou, teda hornou hranou alebo ľavým okrajom. dkFitR je zaujímavý typ, pretože prispôsobuje zadaný obdĺžnik, takže sú dôležité všetky štyri okraje. Rodina dkFitB* robí to isté, ale vzťahuje sa na bounding box viditeľného obsahu, nie na celú stranu. Vedieť, ktoré polia sú pri ktorom type živé, je rozdiel medzi správnym čítaním destination a vypisovaním nezmyselných nulových súradníc

PDF reader bookmark navigation panel showing a nested outline tree
Každý bookmark v tomto navigačnom paneli sa vyhodnotí na akciu a pri interných skokoch aj na destination s vlastným fit typom a súradnicami.

Pod kapotou sa implementácia opiera o zámerné zarovnanie, ktoré stojí za to poznať, pretože vysvetľuje, prečo je mapovanie spoľahlivé. Interné GetDestType vracia celé číslo 1..8 pre osem typov fit presne v poradí XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind je deklarovaný tak, aby jeho ordinaly sedeli jedna k jednej: dkXYZ má ordinal 1, dkFitBV má ordinal 8 a dkNone sedí na nule. Konverzia je teda priamy ordinal cast s range guard, nie lookup tabuľka, ktorá by sa mohla rozísť pri raste enumu. Je to drobnosť, ale presne ten typ detailu, z ktorého sa pri naivnej implementácii stane off-by-one bug hneď, ako niekto zmení poradie enumerácie

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 signalizuje, že sa destination nevyriešil, zvyčajne preto, že akcia žiadny destination nenesie alebo sa named destination nenašiel. Skontrolujte to skôr, než budete veriť súradniciam. Stojí za zmienku aj to, že GetOutlineDestinationInfo hľadá destination na oboch miestach, kde môže žiť: priamo na bookmarku v /Dest a aj vo vnútri vloženej akcie GoTo pod jej /D. Nemusíte teda vedieť, ktorú formu producent použil

Annotation akcie a pasca SelectPage

Link annotation nesie akcie rovnakým spôsobom ako bookmarky a GetAnnotActionInfo vracia ten istý záznam TPDFlibActionInfo s rovnakým vzorom kind-then-payload. Je tu však stavová pasca, ktorá sa outline netýka, a práve to je tretia pasca

Annotation patria ku stranám a PDFlibPas sprístupňuje annotation aktuálne vybratej strany cez stav, ktorý je platný až po vybraní tejto strany. Zavolajte GetAnnotActionInfo bez predchádzajúceho SelectPage(N) a handle annotation bude nula. Volanie vráti akNone a vy mylne usúdite, že strana nemá žiadne actionable annotation. Oprava je jednoradková, ale pri cykle cez strany sa na ňu ľahko zabúda:

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;

Dve veci v tomto cykle sú zámerné. Po prvé, SelectPage(P) prichádza pred akýmkoľvek prístupom k annotation v každej iterácii; stav annotation po stránkach sa neprenáša. Po druhé, test existencie používa GetAnnotActionID(1) <> 0 a nie CheckPageAnnots. Druhá možnosť hlási prítomnosť len ako boolean-like flag, kým nenulové ID akcie je presnejší spôsob, ako sa spýtať: "existuje prvá annotation a nesie akciu, ktorú viem prečítať?" Za zmienku stojí aj ešte jedna jemnosť: pri annotation sa skript akcie JavaScript číta priamo z /JS, pričom sa dekóduje stream, keď je skript uložený takto, a inak sa číta string, takže prežijú obe bežné kódovania

Kam read-side introspection zapadá

Tieto gettery sú zámerne úzke. Sú to čisté čítacie operácie postavené na existujúcich integer-handle vrstvách pre akcie a destination v knižnici, takže sa nedotýkajú write path a nepridávajú žiadne riziko dokumentom, ktoré zároveň upravujete. Hlásia, čo v súbore je; nič nevalidujú proti politike a nič neprepíšu. Ak chcete opak, teda vytvárať bookmarky a link annotation, ktoré tieto akcie vôbec nesú, patrí to na write side a sprievodný článok o interactive form actions and JavaScript in Delphi vás prevedie ich vytváraním. Ak z PDF potrebujete vytiahnuť viditeľný aj štrukturálny obsah namiesto navigačného grafu, pozrite si článok o extrakcii textu, obrázkov a fontov pomocou PDFlibPas

Poctivá hranica, ktorú treba mať na pamäti: introspection vidí len to, čo producent skutočne zapísal. Bookmark, ktorého akciu generátor nechal poškodenú, alebo destination smerujúci na named target, ktorý nikdy nebol definovaný, sa prejaví ako akNone alebo ako nultá strana, nie ako výnimka. To je pri read API auditujúcom nedôveryhodné súbory správne správanie, ale znamená to, že váš kód musí tieto nulové výsledky čítať ako "chýba alebo sa nevyriešilo", nie ako záruku korektne vytvoreného vstupu. Typed introspection akcií a destination opísaná tu je súčasťou PDFlibPas, natívnej PDF knižnice pre Delphi a C++Builder