Örököl egy mappányi PDF-fájlt valahonnan a munkafolyamat korábbi szakaszából, és a feladat triviálisnak tűnik: mondja meg, mely könyvjelzők ugranak külső URL-re, melyek futtatnak JavaScriptet, és a belső hivatkozások valójában hova mutatnak. Ekkor megnyitja az API referenciát, és azt tapasztalja, hogy a könyvtár képes létrehozni az összes ilyen műveletet, de nem kínál semmit a visszaolvasásukra. Ez az aszimmetria mindenütt jelen van a PDF-eszközökben. Egy olyan könyvjelző írása, amely megnyitja a https://example.com oldalt, egyetlen sor; de megkérdezni egy meglévő könyvjelzőt, hogy „mit csinálsz, és milyen célpontra mutatsz?”, általában a nyers objektumfa manuális bejárását jelenti a /A, /S, /Dest elemeken keresztül, valamint a méretezési típusok (fit-type) változatainak garmadáját, amit szinte senki sem csinál jól elsőre
A PDFlibPas egy natív Object Pascal PDF-könyvtár Delphihez és C++Builderhez, és sokáig ugyanezzel a hiányossággal küzdött: gazdag írásoldali beállítók (setters), de olyan lekérdezők (getters) amelyek egy csupasz TPDFObject-et adtak vissza, és a fejlesztőre bízták a kutatást. A v3.77.0 verzió bezárta ennek a résnek egy részét a típusos vizsgálati (introspection) hívások egy kis készletével, amelyek egyszerű rekordokként jelentik a művelet típusát, a művelet terhelését (payload) és a célgeometriát. Ez a cikk arról szól, hogyan képezhetők le ezek a hívások az ISO 32000-1 művelet- és célmodellre, valamint bemutatja azt a három konkrét csapdát, amelyek miatt a saját kezűleg írt kódok csendben elromlanak
Miért nehezebb a műveletek olvasása, mint az írásuk
A PDF-ben a művelet (action) egy szótár (dictionary), amelynek /S kulcsa határozza meg az altípusát: GoTo, GoToR, URI, Launch, Named, JavaScript, és egy hosszabb lista, amellyel ritkán találkozni (ISO 32000-1 §12.6.4). A probléma az, hogy az adat (payload) minden altípusnál más-más kulcs alatt lakik, és nincs egységes „add meg a célpontot” mező. A URI művelet a címét a /URI kulcsban tárolja. A GoToR vagy Launch művelet a fájlspecifikációt az /F kulcsban tartja. A JavaScript művelet a szkriptet a /JS kulcsban tárolja, amely lehet karakterlánc vagy folyam (stream) is. A GoTo művelet önmagában nem hordoz semmilyen adatot; a célpontja egy célhely (destination), amely a /D kulcson függ, amit aztán külön kell feloldani
Amikor műveletet ír, előre tudja annak típusát, így mindez nem számít. Amikor viszont olvas egyet, először a /S kulcs szerint kell elágaznia, majd a megfelelő kulcshoz kell nyúlnia, és kezelnie kell azt a tényt, hogy ugyanaz a logikai koncepció („az a dolog, amire ez a művelet mutat”) három egymással inkompatibilis módon van kódolva. Ez az elágazás pontosan az, amit a típusos lekérdezők elnyelnek. A GetOutlineActionInfo és a GetAnnotActionInfo egyaránt TPDFlibActionInfo rekordot ad vissza:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // feltöltve akURI esetén
JavaScript: WideString; // feltöltve akJavaScript esetén
FileName: AnsiString; // feltöltve akGoToR / akLaunch esetén
OpenInNewWindow: Boolean; // akGoToR / akLaunch esetén
end;
A rekord a Kind mezőn keresztül mondja meg, hogy mely mezők bírnak jelentéssel. Ha a Kind értéke akURI, olvassa el az URI mezőt, és hagyja figyelmen kívül a többit. Ha a visszatérési érték akGoTo, az adathordozó mezők egyike sem érvényes, és továbblép a célállomásra, amely egy külön hívás, amiről lejjebb lesz szó. Az akNone az őszinte válasz, amikor a könyvjelzőnek vagy az annotációnak egyáltalán nincs művelete, ahelyett, hogy egy nullát kapna, aminek a jelentését ki kellene találnia
A vázfa bejárása egy könyvjelző megtalálásához
Mielőtt megvizsgálhatna egy könyvjelzőt, szüksége van annak azonosítójára (handle). A PDFlibPas egy egész számmal azonosítja a vázfa csomópontjait, és a FindOutlineByTitle a látható szöveg alapján keres meg egyet, kifejezett ellenőrzést biztosítva a keresés mélysége felett:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
A Depth argumentum az a rész, amelynél érdemes elidőzni. Az osdSiblingsOnly a kezdő csomópont szintjén lévő testvérláncot vizsgálja meg és megáll; megtalálja a vele egy szinten lévő könyvjelzőt, de soha nem ereszkedik le annak gyermekei közé. Az osdChildrenOnly egy szinttel lejjebb néz, a kezdő csomópont közvetlen gyermekeibe. Az osdFullSubTree rekurzívan bejárja az egész ágat. A rossz választás csendes sikertelenséget eredményez, nem pedig hibát: egy olyan cím keresése, amely két szinttel mélyebben lakik, testvér-alapú keresésnél egyszerűen nullát ad vissza, és Ön arra a következtetésre jut, hogy a könyvjelző nem létezik, pedig végig ott volt. A dokumentum gyökerétől való kereséshez adja meg a GetFirstOutline értéket kezdő azonosítóként
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// A teljes fa keresése a gyökértől egy beágyazott könyvjelzőhöz
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// A FoundID most már egy azonosító, amelyet átadhat a művelet és
// célhely lekérdezőknek
;
end;
finally
Lib.Free;
end;
end;
Az egyezés a pontos címsorozat alapján történik WideString-ként összehasonlítva, így kis- és nagybetűérzékeny, és pontosan úgy veszi figyelembe a Unicode szöveget, ahogyan az el van tárolva. Ha a forrás PDF-fájlok nem konzisztens generátorokból származnak, normalizálja a keresett címet ugyanúgy, ahogyan a dokumentum tárolta azt, különben fantom-sikertelenségeket fog kergetni
A könyvjelző műveletének és célpontjának feloldása
Ha a handle a kezében van, a GetOutlineActionInfo megadja a típusos nézetet. A minta a következő: hívja meg, váltson a Kind szerint, és olvassa le a mezőt, amelyet az adott típus kitölt
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'); // lásd a célhelyet alább
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
Itt lakik az első valódi csapda, and it is the one that test feedback flushed out during implementation. There is an older getter, GetActionURL, and reaching for it to read a URI action is the obvious-looking mistake. GetActionURL resolves a file specification through the /F key. That is the right thing for GoToR and Launch, whose targets genuinely are files, but it is the wrong key for a URI action entirely. A URI action's address is a plain string on the action's own /URI key, not a file spec. Feed a URI action to the file-spec path and you get an empty or nonsensical result. The typed getter handles this internally by reading /URI directly for akURI and only invoking the file-specification resolver for akGoToR and akLaunch, which is exactly the distinction a hand-written version tends to blur
A célhely-méretezési típusok és a mögöttük lévő geometria
Az akGoTo művelet azt jelenti, hogy „navigáljon ezen a dokumentumon binnen”, de semmit sem mond arról, hogy hova vagy hogyan. Ez a célhely (destination) feladata, és a célhelyek több árnyalatot hordoznak, mint amennyire az emberek számítanak. A PDF célhely nem csak egy oldalszám; az egy oldal és egy „méretezési” (fit) specifikáció, amely meghatározza, hogyan keretezze a megjelenítő az oldalt (ISO 32000-1 §12.3.2.2). A GetOutlineDestinationInfo ezt rekordként adja vissza:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-alapú; 0 ha feloldatlan
Left, Top, Right, Bottom, Zoom: Double;
end;
A nyolc méretezési típus (fit kinds) különböző keretezési kérdésekre válaszol. A dkXYZ egy meghatározott pontot helyez el a bal felső sarokban explicit nagyítással, így a Left, Top és Zoom értékeket használja. A dkFit a teljes oldalt az ablakhoz igazítja, és figyelmen kívül hagyja a koordinátákat. A dkFitH és dkFitV az oldal szélességét vagy magasságát igazítja egyetlen releváns koordinátával (felső él vagy bal él). A dkFitR az érdekes: ez egy megadott téglalaphoz igazodik, így mind a négy él számít. A dkFitB* család ugyanezt teszi a látható tartalom határolókeretéhez (bounding box) viszonyítva a teljes oldal helyett. Annak ismerete, hogy mely mezők érvényesek az egyes típusoknál, jelenti a különbséget a célhely helyes olvasása és az egyébként nulla értékű szemét-koordináták kiírása között

A színfalak mögött a megvalósítás egy szándékos igazításra támaszkodik, amelyet érdemes ismerni, mert megmagyarázza, miért megbízható a leképezés. A belső GetDestType egy 1..8 közötti egész számot ad vissza a nyolc méretezési típushoz pontosan a XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV sorrendben. A TPDFlibDestinationKind úgy van deklarálva, hogy a sorszámai (ordinals) egy az egyben illeszkedjenek: a dkXYZ sorszáma 1, a dkFitBV sorszáma 8, míg a dkNone a nullánál ül. So the conversion is a direct ordinal cast with a range guard, not a lookup table that can drift out of sync as the enum grows. Ez egy apró részlet, de ez az a fajta dolog, ami naiv módon megvalósítva off-by-one (eggyel eltolt) hibává válik, amint valaki átrendezi a felsorolást
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // a célhely nem oldódott fel
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;
A nulla értékű Page jelzi, hogy a célhely nem oldódott fel, általában azért, mert a művelet nem hordoz célhelyet, vagy a megnevezett célhely nem található. Ellenőrizze ezt, mielőtt megbízna bármely koordinátában. Vegye figyelembe azt is, hogy a GetOutlineDestinationInfo mindkét helyen keres, ahol a célhely lakhat: közvetlenül a könyvjelző /Dest kulcsában, és a beágyazott GoTo művelet /D kulcsán belül. Nem kell tudnia, hogy a generátor melyik formát használta
Annotációs műveletek és a SelectPage csapda
A link-annotációk pontosan úgy hordoznak műveleteket, mint a könyvjelzők, and GetAnnotActionInfo returns the same TPDFlibActionInfo record with the same kind-then-payload pattern. Van azonban itt egy állapotfüggő buktató, amely a vázfákra nem vonatkozik, és ez a harmadik csapda
Az annotációk az oldalakhoz tartoznak, és a PDFlibPas az aktuális oldal annotációit olyan állapoton keresztül teszi közzé, amely csak az adott oldal kiválasztása után válik érvényessé. Ha a GetAnnotActionInfo-t a SelectPage(N) meghívása nélkül hívja meg, az annotáció-azonosító (handle) nulla lesz; a hívás akNone értékkel tér vissza, és Ön tévesen arra a következtetésre jut, hogy az oldal nem tartalmaz végrehajtható annotációkat. A javítás egyetlen sor, de könnyű elfelejteni, amikor végigfut az oldalakon:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // kötelező az annotációk elérése előtt
// A GetAnnotActionID(1) <> 0 a megbízható „rendelkezik művelettel”
// teszt. A CheckPageAnnots egy logikai típusú jelzőt ad vissza, nem pedig
// darabszámot, így ez a gyengébb jelzés itt
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;
Két dolog szándékos abban a ciklusban. Elsőként, a SelectPage(P) minden iterációban megelőzi az annotációk elérését; az oldalankénti annotációs állapot nem öröklődik tovább. Másodszor, a létezés ellenőrzése a GetAnnotActionID(1) <> 0 értéket használja a CheckPageAnnots helyett. Ez utóbbi jelenlétet logikai jelzőként jelenti, nem pedig darabszámként, így a nem-nulla műveletazonosító a pontosabb módja annak a kérdésnek, hogy „van-e első annotáció, és hordoz-e olyan műveletet, amit el tudok olvasni?”. Még egy finomság, amit érdemes megjegyezni: annotációk esetén a JavaScript művelet szkriptje közvetlenül a /JS kulcsból olvasódik be, dekódolva a folyamot, ha a szkript így van tárolva, és karakterláncként olvasva ellenkező esetben, így mindkét gyakori kódolást túléli
Hol kap helyet az olvasó-oldali vizsgálat
Ezek a lekérdezők szándékosan szűk körűek. Tiszta olvasási műveletek, amelyek a könyvtár meglévő egész-handle művelet- és célhely-rétegeire épülnek, így nem érintenek írási útvonalat, és nem jelentenek kockázatot a szerkesztés alatt álló dokumentumokra. Tegyék közzé, ami a fájlban van; ne érvényesítsék azt valamilyen szabályzat szerint, és ne írjanak át semmit. Ha a cél ennek az ellenkezője, azaz olyan könyvjelzők és link-annotációk építése, amelyek eleve hordozzák ezeket a műveleteket, az az írási oldalon lakik, és a vezérlő-műveletekről és a JavaScriptről Delphiben szóló kísérőcikk bemutatja ezek létrehozását. Ha a PDF látható és strukturális tartalmát szeretné kinyerni a navigációs gráf helyett, olvassa el a szövegek, képek és betűtípusok kinyerése a PDFlibPas segítségével című cikket
A szem előtt tartandó őszinte határ: a vizsgálat (introspection) csak azt látja, amit a generátor valájában leírt. Egy olyan könyvjelző, amelynek műveletét a generátor hibásan hagyta meg, vagy egy olyan célhely, amely egy soha meg nem határozott nevű célpontra mutat, kivétel helyett akNone vagy nulla oldalként fog megjelenni. Ez a helyes viselkedés a nem megbízható fájlkat auditáló olvasási API esetében, de azt jelenti, hogy a kódnak ezeket a nulla eredményeket „hiányzóként vagy feloldatlanként” kell kezelnie, nem pedig a jól formázottság garanciájaként. Az itt bemutatott típusos művelet- és célhely-vizsgálat a PDFlibPas, a Delphihez és C++Builderhez készült natív PDF-könyvtár része