Tehnični članak

Branje dejanj zaznamkov in anotacij PDF v Delphi

Podedujete mapo PDF-jev od nekje višje po verigi in naloga zveni trivialno: povejte mi, kateri zaznamki skočijo na zunanji URL, kateri poganjajo JavaScript in kam notranji dejansko pristanejo. Nato odprete API reference in odkrijete, da knjižnica zna ustvariti vsako od teh dejanj, ne ponudi pa ničesar za njihovo branje nazaj. Ta asimetrija je povsod v orodjih za PDF. Zapis zaznamka, ki odpre https://example.com, je enovrstičen; vprašati obstoječi zaznamek "kaj delaš in na kakšen cilj?" pa običajno pomeni ročno sprehajanje po surovem drevesu objektov skozi /A, /S, /Dest in razvejitev variant vrste prilagajanja, ki jih skoraj nihče ne zadene pravilno v prvem poskusu

PDFlibPas je izvorna knjižnica PDF v Object Pascal za Delphi in C++Builder, dolgo časa pa je imela isto vrzel: bogate setterje na strani zapisovanja, getterje, ki so vam vrnili goli TPDFObject in vas pustili pri raziskovanju. Izdaja v3.77.0 je del tega zaprla z majhnim naborom tipiziranih klicev za introspekcijo, ki kot navadne zapise poročajo vrsto dejanja, njegovo vsebino in geometrijo cilja. Ta članek govori o tem, kako se ti klici preslikajo na model dejanj in ciljev iz ISO 32000-1 ter o treh konkretnih pasteh, zaradi katerih ročno napisane različice te kode tiho zgrešijo

Zakaj je branje dejanj težje od zapisovanja

Dejanje v PDF je slovar s ključem /S, ki poimenuje njegov podtip: GoTo, GoToR, URI, Launch, Named, JavaScript in daljši rep, ki ga srečate redko (ISO 32000-1 §12.6.4). Težava je v tem, da vsebina živi v drugem ključu za vsak podtip in ni enotnega mesta "daj mi cilj". Dejanje URI hrani svoj naslov v /URI. Dejanje GoToR ali Launch hrani specifikacijo datoteke v /F. Dejanje JavaScript hrani svoj skript v /JS, ki je lahko niz ali tok. Dejanje GoTo sploh nima lastne vsebine; njegov cilj je destinacija, obešena na /D, ki jo morate nato razrešiti ločeno

Ko dejanje zapisujete, njegovo vrsto poznate vnaprej, zato nič od tega ni pomembno. Ko ga berete, pa morate najprej vejiti po /S, nato seči v pravi ključ in nato obravnavati dejstvo, da je isti logični pojem ("stvar, na katero to dejanje kaže") kodiran na tri med seboj nezdružljive načine. Prav to vejitev poberejo tipizirani getterji. GetOutlineActionInfo in GetAnnotActionInfo oba vrneta zapis 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;

Zapis vam s pomočjo Kind pove, katera polja so smiselna. Če se Kind vrne kot akURI, preberite URI in ostalo ignorirajte. Če se vrne kot akGoTo, ne velja nobeno polje vsebine in premaknete se na destinacijo, ki jo pokriva ločen klic niže. akNone je pošten odgovor, kadar zaznamek ali anotacija sploh nima dejanja, namesto neke ničle, katere pomen bi morali uganiti

Sprehod po drevesu orisa, da najdete zaznamek

Preden lahko izvajate introspekcijo zaznamka, potrebujete njegov handle. PDFlibPas vozlišča orisa identificira s celoštevilskim ID, FindOutlineByTitle pa ga najde po njegovem vidnem besedilu z izrecnim nadzorom nad tem, kako daleč segajo iskanja:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argument Depth si zasluži postanek. osdSiblingsOnly pregleda verigo sorojencev na ravni začetnega vozlišča in se ustavi; našel bo vrstniški zaznamek, nikoli pa se ne bo spustil v otroke katerega od vrstnikov. osdChildrenOnly pogleda eno raven niže, v neposredne otroke začetnega vozlišča. osdFullSubTree rekurzivno prehodi celotno vejo. Izbira napačnega načina je tiha zgrešitev, ne napaka: iskanje samo med sorojenci za naslov, ki živi dve ravni globlje, preprosto vrne nič in vi sklenete, da zaznamek ne obstaja, čeprav je bil tam ves čas. Podajte GetFirstOutline kot začetni ID, kadar želite iskati od korena dokumenta

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;

Ujemanje teče po natančnem nizu naslova, primerjanem kot WideString, zato je občutljivo na velikost črk in natančno spoštuje Unicode besedilo, kot je shranjeno. Če vaši izvorni PDF-ji prihajajo od neenotnih proizvajalcev, normalizirajte naslov, ki ga iščete, na isti način, kot ga je dokument shranil, sicer boste lovili fantomske zgrešitve

Razreševanje dejanja in cilja zaznamka

Ko imate handle, vam GetOutlineActionInfo poda tipiziran pogled. Vzorec je: pokličite ga, preklopite po Kind in preberite polje, ki ga ta vrsta zapolni

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;

Tu živi prva resnična past, ki jo je med implementacijo razkril odziv testov. Obstaja starejši getter, GetActionURL, in poseči po njem za branje dejanja URI je očitno videti prava napaka. GetActionURL razreši specifikacijo datoteke skozi ključ /F. To je prav za GoToR in Launch, katerih cilji so res datoteke, je pa povsem napačen ključ za dejanje URI. Naslov dejanja URI je navaden niz na lastnem ključu /URI, ne pa file spec. Če dejanje URI pošljete po poti za file spec, dobite prazen ali nesmiseln rezultat. Tipizirani getter to interno obvlada tako, da za /URI neposredno prebere akURI in resolver specifikacije datoteke pokliče samo za akGoToR in akLaunch, kar je natanko razlika, ki jo ročno napisana različica rada zabriše

Vrste prilagajanja destinacij in geometrija za njimi

Dejanje akGoTo pomeni "premakni se znotraj tega dokumenta", ne pove pa nič o tem, kam ali kako. To je naloga destinacije, destinacije pa nosijo več odtenkov, kot ljudje pričakujejo. Destinacija PDF ni samo številka strani; je stran plus specifikacija "fit", ki pove, kako naj pregledovalnik to stran umesti v okvir (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo jo vrne kot zapis:

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;

Osem vrst prilagajanja odgovarja na različna vprašanja o uokvirjanju. dkXYZ postavi določeno točko v zgornji levi kot pri izrecnem zoomu, zato uporablja Left, Top in Zoom. dkFit prilagodi celo stran oknu in koordinate ignorira. dkFitH in dkFitV prilagodita širino oziroma višino strani z eno samo relevantno koordinato (zgornji rob ali levi rob). dkFitR je zanimiv: prilagodi določen pravokotnik, zato so pomembni vsi štirje robovi. Družina dkFitB* počne iste stvari glede na okvir vidne vsebine namesto celotne strani. Vedeti, katera polja so aktivna za posamezno vrsto, je razlika med pravilnim branjem destinacije in izpisovanjem smeti koordinat, ki so po naključju nič

PDF reader bookmark navigation panel showing a nested outline tree
Vsak zaznamek v tej navigacijski plošči se razreši v dejanje in pri notranjih skokih v destinacijo z lastno vrsto prilagajanja in koordinatami.

Pod pokrovom se implementacija opira na namerno poravnavo, ki jo je vredno poznati, ker pojasni, zakaj je preslikava zanesljiva. Interni GetDestType vrne celo število 1..8 za osem vrst prilagajanja v točnem vrstnem redu XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind je deklariran tako, da se njegovi ordinali ujemajo ena proti ena: dkXYZ je ordinal 1, dkFitBV je ordinal 8, medtem ko dkNone sedi na ničli. Pretvorba je torej neposreden cast po ordinalu z varovalko za obseg, ne pa preglednica, ki bi z rastjo enumeracije lahko zdrsnila iz sinhronizacije. To je drobna podrobnost, a prav takšna stvar, ki pri naivnem pristopu postane off-by-one hrošč ob prvem preurejanju enumeracije

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;

Vrednost Page enaka nič je signal, da se destinacija ni razrešila, navadno zato, ker dejanje nima destinacije ali ker imenovana destinacija ni bila najdena. Preverite to, preden zaupate katerikoli koordinati. Upoštevajte tudi, da GetOutlineDestinationInfo gleda na obe mesti, kjer lahko destinacija živi: neposredno na /Dest zaznamka in znotraj vdelanega dejanja GoTo na njegovem /D. Ni vam treba vedeti, katero obliko je uporabil producent

Dejanja anotacij in past SelectPage

Anotacije povezav nosijo dejanja natanko tako kot zaznamki in GetAnnotActionInfo vrne isti zapis TPDFlibActionInfo z enakim vzorcem vrsta-nato-vsebina. Tu pa obstaja past stanja, ki pri orisih ne velja, in to je tretja past

Anotacije pripadajo stranem, PDFlibPas pa anotacije trenutne strani izpostavi skozi stanje, ki postane veljavno šele, ko izberete to stran. Pokličite GetAnnotActionInfo brez predhodnega klica SelectPage(N) in handle anotacije bo nič; klic vrne akNone in napačno sklenete, da stran nima nobene uporabne anotacije. Popravek je ena vrstica, vendar ga je med zankanjem po straneh lahko pozabiti:

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 stvari v tej zanki sta namerni. Prvič, SelectPage(P) pride pred vsakim dostopom do anotacij v vsaki iteraciji; stanje anotacij po straneh se ne prenaša. Drugič, test obstoja uporablja GetAnnotActionID(1) <> 0 namesto CheckPageAnnots. Slednji prisotnost poroča kot boolovski indikator namesto kot število, zato je neničelni action ID natančnejši način vprašanja "ali obstaja prva anotacija in ali nosi dejanje, ki ga lahko preberem?" Še ena podrobnost, vredna omembe: pri anotacijah se skript dejanja JavaScript bere neposredno iz /JS, pri čemer se ob tej obliki dekodira tok ali pa se sicer prebere niz, tako da preživi obe pogosti kodiranji

Kam sodi introspekcija na strani branja

Ti getterji so namenoma ozki. Gre za čista branja, zgrajena nad obstoječimi plastmi dejanj in destinacij knjižnice na osnovi celoštevilskih handlov, zato se ne dotaknejo nobene poti zapisovanja in ne dodajo tveganja dokumentom, ki jih hkrati tudi urejate. Poročajo, kaj je v datoteki; ne validirajo tega proti pravilniku in ne prepisujejo ničesar. Če je vaš cilj obratna smer, torej graditi zaznamke in anotacije povezav, ki ta dejanja sploh nosijo, to živi na strani zapisovanja, spremljevalni članek o dejanjih interaktivnih obrazcev in JavaScript v Delphi razloži njihovo ustvarjanje. Če želite iz PDF-ja izvleči vidno in strukturno vsebino namesto njegovega navigacijskega grafa, glejte izločanje besedila, slik in pisav z orodjem PDFlibPas

Poštena meja, ki jo je treba imeti v mislih: introspekcija vidi samo tisto, kar je producent dejansko zapisal. Zaznamek, katerega dejanje je generator pustil napačno oblikovano, ali destinacija, ki kaže na imenovani cilj, ki ni bil nikoli definiran, se bosta pokazala kot akNone ali kot stran nič, ne pa kot izjema. To je pravilno vedenje za API branja, ki auditira nezaupljive datoteke, vendar pomeni, da mora vaša koda te ničelne rezultate razumeti kot "manjka ali ni razrešeno", ne pa kot jamstvo dobro oblikovanega vhoda. Tipizirana introspekcija dejanj in destinacij, prikazana tukaj, je del PDFlibPas, izvorne knjižnice PDF za Delphi in C++Builder