Műszaki cikk

PDF-könyvjelzők és annotációs műveletek olvasása Delphiben

Ö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

PDF olvasó könyvjelző-navigációs panelje egy beágyazott vázfával
A navigációs panel minden könyvjelzője egy műveletre oldódik fel, és belső ugrások esetén egy saját méretezési típussal és koordinátákkal rendelkező célhelyre mutat

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