Tekninen artikkeli

PDF-kirjanmerkkien ja huomautustoimintojen lukeminen Delphissä

Saat jostain järjestelmästä nipun PDF-tiedostoja, ja tehtävä kuulostaa mitättömältä: kerro, mitkä kirjanmerkit hyppäävät ulkoiseen URL-osoitteeseen, mitkä suorittavat JavaScriptiä ja minne sisäiset viitteet todellisuudessa päätyvät. Sitten avaat API-viitteen ja huomaat, että kirjasto osaa luoda kaikki nämä toiminnot, mutta ei tarjoa mitään tapaa lukea niitä takaisin. Tämä epäsymmetria on yleistä PDF-työkaluissa. Ulkoisen osoitteen https://example.com avaavan kirjanmerkin kirjoittaminen on yhden rivin juttu; mutta valmiilta kirjanmerkiltä kysyminen 'mitä teet ja mihin kohteeseen?' vaatii yleensä raa'an objektipuun käsin läpikäyntiä avaimien /A, /S, /Dest ja sellaisten sovitustyyppivarianttien läpi, joita lähes kukaan ei saa heti ensimmäisellä kerralla oikein

PDFlibPas on alkuperäinen Object Pascal -pohjainen PDF-kirjasto Delphille ja C++Builderille, ja pitkään siinä oli sama puute: kattavat kirjoituspuolen asetukset (setters), mutta lukupuolen hakumetodit (getters) palauttivat vain paljaan TPDFObject-olion ja jättivät sinut tutkimaan sitä itse. Versio v3.77.0 sulki osan tästä kuilusta tuomalla pienen joukon tyypitettyjä tarkastelukutsuja, jotka palauttavat toimintotyypin, toiminnon hyötykuorman ja kohdegeometrian selkeinä tietueina (records). Tämä artikkeli käsittelee sitä, miten nämä kutsut kartoittuvat ISO 32000-1 -standardin toiminto- ja kohdemalliin, sekä kolmea käytännön sudenkuoppa, joiden vuoksi käsin kirjoitetut versiot tästä koodista menevät helposti hiljaisesti pieleen

Miksi toimintojen lukeminen on vaikeampaa kuin niiden kirjoittaminen

Toiminto (action) on PDF:ssä sanakirja, jonka /S-avain nimeää sen alityypin: GoTo, GoToR, URI, Launch, Named, JavaScript ja muutamat muut, joita harvoin kohtaa (ISO 32000-1 §12.6.4). Ongelmana on, että hyötykuorma sijaitsee eri avaimessa jokaiselle alityypille, eikä mitään yhtenäistä 'anna minulle kohde' -paikkaa ole. URI-toiminto säilyttää osoitteensa avaimessa /URI. GoToR- tai Launch-toiminto säilyttää tiedostomäärityksen avaimessa /F. JavaScript-toiminto säilyttää skriptinsä avaimessa /JS, joka voi olla joko merkkijono tai virta. GoTo-toiminnolla ei ole lainkaan omaa hyötykuormaa; sen kohde on sijainti (destination), joka roikkuu avaimesta /D ja joka on ratkaistava erikseen

Kun kirjoitat toimintoa, tiedät sen tyypin etukäteen, joten tällä ei ole merkitystä. Kun luet sitä, sinun on ensin haarauduttava avaimen /S mukaan, kurotettava oikeaan avaimeen ja käsiteltävä se, että sama looginen käsite ('asia, johon tämä toiminto osoittaa') on koodattu kolmella yhteensopimattomalla tavalla. Tämän haarautumisen hoitavat tyypitetyt hakufunktiot. Sekä GetOutlineActionInfo että GetAnnotActionInfo palauttavat TPDFlibActionInfo-tietueen:

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;

Jäsennyspuun läpikäynti kirjanmerkin löytämiseksi

Ennen kuin voit tutkia kirjanmerkkiä, tarvitset sen kahvan (handle). PDFlibPas tunnistaa kirjanmerkkisolmut (outline nodes) kokonaislukutunnuksella (ID), ja FindOutlineByTitle etsii sellaisen sen näkyvän tekstin perusteella halliten tarkasti, kuinka pitkälle haku ulottuu:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Depth-argumentti on kohta, johon kannattaa pysähtyä. osdSiblingsOnly skannaa sisarusketjun aloitusolmun tasolla ja pysähtyy; se löytää saman tason kirjanmerkin, mutta ei koskaan laskeudu sisaruksen lapsiin. osdChildrenOnly katsoo yhden tason alaspäin, aloitusolmun välittömiin lapsiin. osdFullSubTree käy läpi koko alipuun. Väärän valinta johtaa hiljaiseen epäonnistumiseen, ei virheeseen: sisarus-haku kahden tason syvyydessä olevalle otsikolle palauttaa vain nollan, ja päätät kirjanmerkin puuttuvan, vaikka se oli siellä koko ajan. Välitä GetFirstOutline aloitus-ID:ksi hakeaksesi dokumentin juuresta alkaen

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;

Täsmäytys tehdään tarkan otsikkolauseen mukaan, jota verrataan WideString-tyyppinä. Se on siis kirjainkoosta riippuvainen ja huomioi Unicode-tekstin tarkalleen sellaisena kuin se on tallennettu. Jos lähdetiedostosi tulevat epäjohdonmukaisilta tuottajilta, normalisoi etsimäsi otsikko samalla tavalla kuin dokumentti on sen tallentanut, tai päädyt jahtaamaan olemattomia osumia

Kirjanmerkin toiminnan ja kohteen selvittäminen

Kun kahva on hallussa, GetOutlineActionInfo antaa tyypitetyn näkymän. Kaava on: kutsu sitä, haaraudu Kind-arvon mukaan, ja lue kyseisen tyypin täyttämä kenttä

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;

Tässä piilee ensimmäinen todellinen sudenkuoppa, ja se on se, jonka testipalaute toi esiin toteutuksen aikana. On olemassa vanhempi hakumetodi, GetActionURL, ja sen käyttäminen URI-toiminnon lukemiseen on ilmeinen virhe. GetActionURL selvittää tiedostomäärityksen avaimen /F kautta. Se on oikein GoToR- ja Launch-toiminnoille, joiden kohteet todellisuudessa ovat tiedostoja, mutta se on täysin väärä avain URI-toiminnolle. URI-toiminnon osoite on tavallinen merkkijono toiminnon omassa /URI-avaimessa, ei tiedostomääritys. Jos syötät URI-toiminnon tiedostomäärityspolulle, saat tyhjän tai järjettömän tuloksen. Tyypitetty hakufunktio hoitaa tämän sisäisesti lukemalla /URI-avainta suoraan akURI-tapauksessa ja kutsumalla tiedostomäärityksen selvittäjää vain akGoToR- ja akLaunch-tapauksissa. Juuri tämä ero tuppaa hämärtymään käsin kirjoitetussa koodissa

Kohteen sovitustyypit (fit types) ja niiden takana oleva geometria

akGoTo-toiminto tarkoittaa 'navigoi tämän dokumentin sisällä', mutta se ei kerro mitään siitä, minne tai miten. Se on kohteen (destination) tehtävä, ja kohteisiin liittyy enemmän vivahteita kuin yleensä arvellaan. PDF-kohde ei ole vain sivun numero; se on sivu plus 'sovitusmääritys' (fit specification), joka kertoo, miten katseluohjelman tulisi sivu kehystää (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo palauttaa sen tietueena:

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;

Kahdeksan sovitustyyppiä vastaavat erilaisiin kehystyskysymyksiin. dkXYZ sijoittaa tietyn pisteen vasempaan yläkulmaan tietyllä zoomauksella, joten se käyttää kenttiä Left, Top ja Zoom. dkFit sovittaa koko sivun ikkunaan ja ohittaa koordinaatit. dkFitH ja dkFitV sovittavat sivun leveyden tai korkeuden käyttäen yhtä koordinaattia (yläreunaa tai vasenta reunaa). dkFitR on mielenkiintoinen: se sovittaa määritellyn suorakulmion, joten kaikilla neljällä reunalla on merkitystä. dkFitB*-perhe tekee samat asiat suhteessa näkyvän sisällön rajoituslaatikkoon (bounding box) koko sivun sijaan. Se, että tietää mitkä kentät ovat aktiivisia kullekin tyypille, on ero kohteen oikean lukemisen ja nolliksi osoittautuvien roskakoordinaattien tulostamisen välillä

PDF reader bookmark navigation panel showing a nested outline tree
Jokainen tämän navigointipaneelin kirjanmerkki osoittaa toimintoon ja sisäisissä hypyissä kohteeseen omalla sovitustyypillään ja koordinaateillaan.

Konepellin alla toteutus nojaa tietoiseen linjaukseen, joka on hyvä tietää, koska se selittää kartoituksen luotettavuuden. Sisäinen GetDestType palauttaa kokonaisluvun 1..8 toiminnallisille XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV-sovitustyypeille tässä järjestyksessä. TPDFlibDestinationKind on ilmoitettu siten, että sen järjestysluvut (ordinals) täsmäävät yksitellen: dkXYZ on järjestysluku 1, dkFitBV on 8, ja dkNone on nolla. Joten muunnos on suora tyyppimuunnos (ordinal cast) rajatarkistuksella, eikä mikään hakutaulukko, joka voisi mennä epäsynkroniin enumin kasvaessa. Tämä on pieni yksityiskohta, mutta se on juuri sellainen asia, joka naiivisti tehtynä muuttuu off-by-one-virheeksi heti, kun joku järjestää enumin uudelleen

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;

Page-arvo nolla on merkki siitä, ettei kohdetta voitu selvittää, yleensä siksi, ettei toiminto sisällä kohdetta tai nimettyä kohdetta ei löytynyt. Tarkista se ennen kuin luotat mihinkään koordinaattiin. Huomaa myös, että GetOutlineDestinationInfo etsii molemmista paikoista, joissa kohde voi sijaita: suoraan kirjanmerkin /Dest-avaimesta tai upotetun GoTo-toiminnon /D-avaimesta. Sinun ei tarvitse tietää, kumpaa muotoa tiedoston luoja käytti

Huomautustoiminnot ja SelectPage-sudenkuoppa

Linkkihuomautukset (link annotations) sisältävät toimintoja aivan kuten kirjanmerkitkin, ja GetAnnotActionInfo palauttaa saman TPDFlibActionInfo-tietueen samalla tyyppi-sitten-hyötykuorma-kaavalla. Tähän liittyy kuitenkin tilasidonnainen sudenkuoppa, joka ei koske kirjanmerkkejä, ja se on kolmas sudenkuoppa

Huomautukset kuuluvat sivuille, ja PDFlibPas tuo esiin nykyisen sivun huomautukset tilan kautta, joka tulee voimaan vasta, kun olet valinnut kyseisen sivun. Jos kutsut GetAnnotActionInfo-metodia kutsumatta ensin SelectPage(N)-metodia, huomautuskahva on nolla; kutsu palauttaa arvon akNone, ja teet virheellisen johtopäätöksen, ettei sivulla ole toiminnallisia huomautuksia. Korjaus on yhden rivin pituinen, mutta se on helppo unohtaa, kun käyt sivuja läpi silmukassa:

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;

Kaksi asiaa tuossa silmukassa on tehty tarkoituksella. Ensinnäkin SelectPage(P) suoritetaan ennen mitään huomautuksiin koskemista jokaisella kierroksella; sivukohtainen huomautustila ei säily. Toiseksi olemassaolotarkistus käyttää ehtoa GetAnnotActionID(1) <> 0 metodin CheckPageAnnots sijaan. Jälkimmäinen ilmoittaa olemassaolon boolean-tyyppisenä lippuna eikä määränä, joten nollasta poikkeava toiminto-ID on tarkempi tapa kysyä: 'onko olemassa ensimmäistä huomautusta ja sisältääkö se toiminnon, jonka voin lukea?' Vielä yksi huomionarvoinen hienous: huomautusten tapauksessa JavaScript-toiminnon skripti luetaan suoraan avaimesta /JS, purkaen virta, jos skripti on tallennettu sillä tavalla, ja lukemalla merkkijono muussa tapauksessa. Se siis selviää molemmista yleisistä koodaustavoista

Mihin lukupuolen tarkastelu (introspection) sopii

Nämä hakufunktiot ovat tarkoituksellisen kapea-alaisia. Ne ovat puhtaita lukutapahtumia, jotka on rakennettu kirjaston olemassa olevien kokonaislukukahvojen ja kohdekerrosten päälle, joten ne eivät koske kirjoituspolkuun eivätkä aiheuta riskejä dokumenteille, joita myös muokkaat. Ne raportoivat, mitä tiedostossa on; ne eivät validoi sitä mitään käytäntöä vastaan tai kirjoita mitään uusiksi. Jos tavoitteesi on päinvastainen – eli rakentaa kirjanmerkkejä ja linkkihuomautuksia, jotka sisältävät näitä toimintoja alun perin – se elää kirjoituspuellla, ja rinnakkaisartikkeli interaktiivisista lomaketoiminnoista ja JavaScriptistä Delphissä käy läpi niiden luomisen. Jos haluat hakea PDF:stä näkyvää ja rakenteellista sisältöä sen navigointikaavion sijaan, katso artikkeli tekstin, kuvien ja fonttien purkamisesta PDFlibPas-kirjastolla

Rehellinen raja, joka on pidettävä mielessä: tarkastelu (introspection) näkee vain sen, mitä tiedoston tuottaja on todella kirjoittanut. Kirjanmerkki, jonka toiminnan generaattori jätti virheelliseksi, tai kohde, joka osoittaa nimettyyn kohteeseen, jota ei koskaan määritelty, näkyy arvona akNone tai nollasivuna eikä poikkeuksena. Tämä on oikea käyttäytyminen lukurajapinnalle, joka auditoi epäluotettavia tiedostoja, mutta se tarkoittaa, että koodisi tulisi käsitellä näitä nollatuloksia tilana 'puuttuva tai ratkaisematon' eikä takeena oikein muotoillusta syötteestä. Tässä esitetty tyypitetty toimintojen ja kohteiden tarkastelu on osa PDFlibPas-kirjastoa, joka on alkuperäinen PDF-kirjasto Delphille ja C++Builderille