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ä

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