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

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