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

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