Tehnički članak

Čitanje PDF bookmark i annotation akcija u Delphiju

Nasledite fasciklu PDF-ova odnekud iz prethodnog toka i zadatak zvuči trivijalno: recite mi koji bookmark-ovi vode na spoljni URL, koji pokreću JavaScript i gde se interni zaista završavaju. Onda otvorite API referencu i otkrijete da biblioteka može da napravi svaku od tih akcija, ali ništa ne nudi da ih pročita nazad. Ta asimetrija je svuda u PDF alatima. Upis bookmark-a koji otvara https://example.com je jedna linija; pitati postojeći bookmark "šta radiš i ka kojoj meti?" obično znači ručno prolaziti sirovo stablo objekata kroz /A, /S, /Dest i fan-out varijanti fit tipa koje gotovo niko ne pogodi iz prve

PDFlibPas je nativna Object Pascal PDF biblioteka za Delphi i C++Builder, i dugo je imao isti jaz: bogate write-side settere, a getteri su vam vraćali samo golu TPDFObject i ostavljali vas da kopate dalje. Izdanje v3.77.0 zatvorilo je deo tog jaza malim skupom tipizovanih introspekcionih poziva koji prijavljuju vrstu akcije, payload akcije i geometriju destinacije kao obične zapise. Ovaj članak govori o tome kako se ti pozivi mapiraju na ISO 32000-1 model akcija i destinacija, i o tri konkretne zamke koje ručno sastavljene verzije ovog koda vode u tihe greške

Zašto je čitanje akcija teže od njihovog upisa

Akcija u PDF-u je rečnik sa /S ključem koji imenuje njen podtip: GoTo, GoToR, URI, Launch, Named, JavaScript, i dužim repom koji retko srećete (ISO 32000-1 §12.6.4). Problem je što se payload nalazi u različitom ključu za svaki podtip, i ne postoji uniformno mesto za "daј mi cilj". URI akcija čuva svoju adresu u /URI. GoToR ili Launch akcija čuva specifikaciju fajla u /F. JavaScript akcija čuva svoj skript u /JS, koji može biti ili string ili tok. GoTo akcija ne nosi nikakav sopstveni payload; njena meta je destinacija, koja visi o /D, a koju onda morate da rešite odvojeno

Kada upisujete akciju, znate njen tip unapred, pa ništa od ovoga nije važno. Kada je čitate, morate prvo da granate po /S, pa da posegnete u pravi ključ, pa da obradite činjenicu da je isti logički koncept ("stvar na koju ova akcija pokazuje") kodiran na tri nekompatibilna načina. Upravo to grananje apsorbuju tipizovani getteri. GetOutlineActionInfo i GetAnnotActionInfo oba vraćaju TPDFlibActionInfo zapis:

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 govori koja su polja značajna preko Kind. Ako Kind vraća akURI, čitajte URI i ignorišite ostatak. Ako vraća akGoTo, none of the payload fields apply and you move on to the destination, which is a separate call covered further down. akNone is the honest answer when the bookmark or annotation has no action at all, rather than a zero you have to guess the meaning of

Prelazak kroz outline stablo da biste pronašli bookmark

Pre nego što možete da introspektujete bookmark, potreban vam je njegov handle. PDFlibPas identifikuje outline čvorove pomoću celobrojnog ID-ja, a FindOutlineByTitle pronalazi jedan po vidljivom tekstu sa jasnom kontrolom koliko daleko pretraga ide:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argument Depth je deo na kome vredi zastati. osdSiblingsOnly pretražuje lanac braće na nivou početnog čvora i staje; pronaći će peer bookmark, ali nikada neće sići u decu tog peer-a. osdChildrenOnly gleda jedan nivo niže, u neposrednu decu početnog čvora. osdFullSubTree rekurzivno prolazi kroz celu granu. Pogrešan izbor je tiho promašivanje, a ne greška: pretraga samo među braćom za naslov koji živi dva nivoa dublje jednostavno vraća nulu, i zaključite da bookmark ne postoji iako je bio tu sve vreme. Prosledite GetFirstOutline kao start ID da biste pretraživali 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;

Poklapanje ide po tačnom stringu naslova, poređenom kao WideString, pa je osetljivo na velika i mala slova i poštuje Unicode tekst tačno onako kako je sačuvan. Ako vaši izvori dolaze od nedoslednih proizvođača, normalizujte naslov koji tražite na isti način na koji ga je dokument sačuvao, ili ćete juriti fantomska promašivanja

Razrešavanje bookmark akcije i mete

Sa handle-om u ruci, GetOutlineActionInfo vam daje tipizovan prikaz. Obrazac je: pozovite ga, prebacite se po Kind, pročitajte polje koje taj tip popunjava

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 leži prva prava zamka, i to ona koju je feedback iz testova otkrio tokom implementacije. Postoji stariji getter, GetActionURL, i posezanje za njim da biste pročitali URI akciju je očigledna greška. GetActionURL razrešava specifikaciju fajla preko /F ključa. To je prava stvar za GoToR i Launch, čije su mete zaista fajlovi, ali to je pogrešan ključ za URI akciju uopšte. Adresa URI akcije je običan string na sopstvenom /URI ključu, a ne file spec. Pošaljite URI akciju u file-spec putanju i dobićete prazan ili besmislen rezultat. Tipizovani getter to interno rešava tako što čita /URI direktno za akURI i tek onda poziva resolver specifikacije fajla za akGoToR i akLaunch, što je upravo razlika koju ručno napisana verzija obično zamagli

Tipovi fit-a za destinaciju i geometrija iza njih

An akGoTo akcija znači "navigiraj unutar ovog dokumenta", ali vam ne kaže ništa o gde ili kako. To je posao destinacije, a destinacije nose više nijansi nego što ljudi očekuju. PDF destinacija nije samo broj strane; ona je strana plus specifikacija fit-a koja govori kako preglednik treba da uokviri tu stranu (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo vraća je kao 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;

Osam fit tipova odgovara na različita pitanja uokviravanja. dkXYZ postavlja određenu tačku u gornji levi ugao uz eksplicitni zoom, pa koristi Left, Top i Zoom. dkFit uklapa celu stranu u prozor i ignoriše koordinate. dkFitH i dkFitV uklapaju širinu ili visinu strane uz jednu relevantnu koordinatu (gornju ili levu ivicu). dkFitR je interesantan: uklapa zadati pravougaonik, pa su bitne sve četiri ivice. dkFitB* familija radi isto to u odnosu na bounding box vidljivog sadržaja umesto na celu stranu. Znati koja su polja živa za koji tip je razlika između ispravnog čitanja destinacije i ispisivanja đubre koordinata koje se slučajno završavaju kao nula

PDF reader bookmark navigation panel showing a nested outline tree
Svaki bookmark u ovom navigacionom panelu razrešava se na akciju i, za interne skokove, na destinaciju sa sopstvenim fit tipom i koordinatama.

Ispod haube implementacija se oslanja na namerno poravnanje koje vredi znati jer objašnjava zašto je mapiranje pouzdano. Interni GetDestType vraća ceo broj 1..8 za osam fit tipova tačno u redosledu XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind je deklarisan tako da mu ordinali stoje jedan-na-jedan: dkXYZ je ordinal 1, dkFitBV je ordinal 8, a dkNone je na nuli. Dakle, konverzija je direktno kastovanje ordinala sa čuvarom opsega, a ne lookup tabela koja može da isklizne iz sinhronizacije kako enum raste. To je mala stvar, ali upravo takva stvar, urađena na naivan način, postaje off-by-one bag čim neko prerasporedi enumeraciju

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 od nule signal je da se destinacija nije razrešila, obično zato što akcija nema destinaciju ili named destinacija nije pronađena. Proverite to pre nego što verujete bilo kojoj koordinati. Takođe imajte na umu da GetOutlineDestinationInfo gleda na oba mesta na kojima destinacija može da živi: direktno na bookmark-ovom /Dest, i unutar ugrađene GoTo akcije /D. Ne morate da znate koji obrazac je proizvođač upotrebio

Annotation akcije i SelectPage zamka

Link anotacije nose akcije isto kao i bookmark-ovi, a GetAnnotActionInfo vraća isti TPDFlibActionInfo zapis sa istim patternom tip-onda-payload. Ali ovde postoji stateful zamka koja ne važi za outline-ove, i to je treća zamka

Anotacije pripadaju stranama, a PDFlibPas izlaže trenutnu stranu anotacija kroz state koji postaje važeći tek nakon što selektujete tu stranu. Pozovite GetAnnotActionInfo bez prethodnog poziva SelectPage(N) i handle anotacije je nula; poziv vraća akNone i pogrešno zaključite da strana nema akcione anotacije. Popravka je jedna linija, ali je lako zaboraviti kada prolazite kroz strane:

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 u toj petlji su namerne. Prvo, SelectPage(P) dolazi pre bilo kakvog pristupa anotacijama u svakoj iteraciji; stanje anotacija po strani se ne prenosi. Drugo, test prisustva koristi GetAnnotActionID(1) <> 0 umesto CheckPageAnnots. Ovo drugo prijavljuje prisustvo kao flag tipa booleana umesto kao broj, pa je nenulti ID akcije precizniji način da pitate "da li postoji prva anotacija i da li nosi akciju koju mogu da pročitam?" Još jedna nijansa vredna pomena: za anotacije, skript JavaScript akcije čita se iz /JS direktno, dekodirajući tok kada je skript sačuvan tako i čitajući string u suprotnom, pa preživljava oba uobičajena kodiranja

Gde se čitanje uklapa

Ovi getteri su namerno uski. To su čista čitanja zasnovana na postojećim integer-handle slojevima za akcije i destinacije biblioteke, pa ne dodiruju putanju upisa i ne dodaju rizik dokumentima koje usput i uređujete. Oni prijavljuju šta je u fajlu; ne validiraju ga prema politici niti nešto prepisuju. Ako vam je cilj obrnuti smer, pravljenje bookmark-ova i link anotacija koje nose te akcije od početka, to je na strani upisa, a prateći tekst o interaktivnim form akcijama i JavaScript-u u Delphiju walks through creating them. For pulling the visible and structural content out of a PDF rather than its navigation graph, see extracting text, images, and fonts with PDFlibPas

The honest boundary to keep in mind: introspection only sees what the producer actually wrote. A bookmark whose action a generator left malformed, or a destination pointing at a named target that was never defined, will surface as akNone or a zero page rather than an exception. That is the right behavior for a read API auditing untrusted files, but it means your code should treat those zero results as "absent or unresolved," not as a guarantee of well-formed input. The typed action and destination introspection shown here is part of PDFlibPas, the native PDF library for Delphi and C++Builder