Du arver en mappe med PDF'er et sted oppefra, og opgaven lyder triviel: fortæl mig, hvilke bogmærker der hopper til en ekstern URL, hvilke der kører JavaScript, og hvor de interne faktisk lander. Så åbner du API-referencen og opdager, at biblioteket kan oprette hver eneste af de actions, men ikke tilbyder noget til at læse dem tilbage. Den asymmetri findes overalt i PDF-værktøjer. At skrive et bogmærke, der åbner https://example.com, er en one-liner; at spørge et eksisterende bogmærke "hvad gør du, og til hvad peger du?" betyder som regel, at du må vandre det rå objekttræ manuelt gennem /A, /S, /Dest og en vifte af fit-type-varianter, som næsten ingen får rigtigt første gang
PDF Library for Delphi er et native Object Pascal PDF-bibliotek til Delphi og C++Builder, og i lang tid havde det det samme hul: rige skrivesides-setters, getters der gav dig et nøgent TPDFObject tilbage og overlod resten til dig selv at udforske. v3.77.0-udgivelsen lukkede en del af det med et lille sæt typede introspektionskald, der rapporterer action-typen, action-nyttelasten og destinationsgeometrien som almindelige records. Denne artikel handler om, hvordan de kald mapper til ISO 32000-1's action- og destinationsmodel, og de tre konkrete faldgruber, der får håndrullede versioner af denne kode til at gå stille galt
Hvorfor det er sværere at læse actions end at skrive dem
En action i PDF er et dictionary med en /S-nøgle, der navngiver dens subtype: GoTo, GoToR, URI, Launch, Named, JavaScript, og en længere hale, du sjældent møder (ISO 32000-1 §12.6.4). Problemet er, at nyttelasten bor i en anden nøgle for hver subtype, og der er ingen ensartet "giv mig målet"-plads. En URI-action holder sin adresse i /URI. En GoToR- eller Launch-action holder en filspecifikation i /F. En JavaScript-action holder sit script i /JS, som kan være enten en streng eller en stream. En GoTo-action bærer slet ingen nyttelast selv; dens mål er en destination, der hænger på /D, som du derefter skal opløse separat
Når du skriver en action, kender du dens type på forhånd, så intet af det her betyder noget. Når du læser en, skal du først forgrene på /S, så nå ind til den rigtige nøgle, og så håndtere det faktum, at det samme logiske begreb ("det, denne action peger på") er kodet på tre indbyrdes uforenelige måder. Den forgrening er præcis det, de typede gettere absorberer. GetOutlineActionInfo og GetAnnotActionInfo returnerer begge en TPDFlibActionInfo-record:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // udfyldt for akURI
JavaScript: WideString; // udfyldt for akJavaScript
FileName: AnsiString; // udfyldt for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
Recorden fortæller dig, hvilke felter der er relevante, via Kind. Kommer Kind tilbage som akURI, læs URI og ignorér resten. Kommer den tilbage som akGoTo, gælder ingen af nyttelastfelterne, og du går videre til destinationen, som er et separat kald, dækket længere nede. akNone er det ærlige svar, når bogmærket eller annotationen slet ingen action har, i stedet for en nul-værdi, du skal gætte betydningen af
Gå dispositionstræet igennem for at finde et bogmærke
Før du kan introspicere et bogmærke, har du brug for dets handle. PDF Library for Delphi identificerer dispositionsknuder med et heltals-ID, og FindOutlineByTitle finder én ud fra dens synlige tekst med eksplicit kontrol over, hvor langt søgningen rækker:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Argumentet Depth er den del, det er værd at stoppe op ved. osdSiblingsOnly scanner søskendekæden på startknudens niveau og stopper; den finder et jævnbyrdigt bogmærke, men går aldrig ned i et jævnbyrdigt bogmærkes børn. osdChildrenOnly kigger ét niveau ned, ind i startknudens umiddelbare børn. osdFullSubTree rekurserer gennem hele grenen. At vælge den forkerte er et stille miss, ikke en fejl: en søskende-kun-søgning efter en titel, der ligger to niveauer nede, returnerer simpelthen nul, og du konkluderer, at bogmærket ikke findes, selvom det hele tiden var der. Angiv GetFirstOutline som startID for at søge fra dokumentroden
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Søg hele træet fra roden efter et indlejret bogmærke.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID er nu et handle, du kan give videre til action- og
// destinations-getterne nedenfor.
;
end;
finally
Lib.Free;
end;
end;
Matchning sker på den eksakte titelstreng, sammenlignet som en WideString, så det er versalfølsomt og respekterer Unicode-teksten præcis, som den er gemt. Kommer dine kilde-PDF'er fra inkonsistente producenter, skal du normalisere den titel, du søger efter, på samme måde som dokumentet gemte den, ellers jager du fantom-misses
Opløs et bogmærkes action og mål
Med et handle i hånden giver GetOutlineActionInfo dig den typede visning. Mønstret er: kald den, forgren på Kind, læs det felt, den type udfylder
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'); // se destination nedenfor
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
Det er her, den første reelle faldgrube bor, og det er den, testfeedback afslørede under implementeringen. Der findes en ældre getter, GetActionURL, og at gribe fat i den for at læse en URI-action er den fejl, der ser mest oplagt ud. GetActionURL opløser en filspecifikation gennem /F-nøglen. Det er det rigtige for GoToR og Launch, hvis mål reelt er filer, men det er den forkerte nøgle for en URI-action fuldstændigt. En URI-actions adresse er en almindelig streng på actionens egen /URI-nøgle, ikke en filspec. Fodrer du en URI-action til filspec-stien, får du et tomt eller meningsløst resultat. Den typede getter håndterer dette internt ved at læse /URI direkte for akURI og kun kalde filspecifikations-opløseren for akGoToR og akLaunch, hvilket er præcis den skelnen, en håndskrevet version har en tendens til at sløre
Destinationens fit-typer og geometrien bag dem
En akGoTo-action betyder "naviger inden for dette dokument", men den fortæller dig intet om hvor eller hvordan. Det er destinationens opgave, og destinationer bærer mere nuance, end folk forventer. En PDF-destination er ikke bare et sidenummer; det er en side plus en "fit"-specifikation, der siger, hvordan vieweren skal indramme den side (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo returnerer den som en record:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-baseret; 0 når uopløst
Left, Top, Right, Bottom, Zoom: Double;
end;
De otte fit-typer besvarer forskellige indramningsspørgsmål. dkXYZ placerer et bestemt punkt i det øverste venstre hjørne ved en eksplicit zoom, så den bruger Left, Top og Zoom. dkFit tilpasser hele siden til vinduet og ignorerer koordinater. dkFitH og dkFitV tilpasser sidens bredde eller højde med én relevant koordinat (en øverste kant eller en venstre kant). dkFitR er den interessante: den tilpasser et angivet rektangel, så alle fire kanter betyder noget. dkFitB*-familien gør de samme ting relativt til afgrænsningsboksen for synligt indhold i stedet for hele siden. At vide, hvilke felter der er levende for hver type, er forskellen mellem at læse en destination korrekt og udskrive volapyk-koordinater, der tilfældigvis er nul

Under motorhjelmen læner implementeringen sig op ad en bevidst justering, det er værd at kende, fordi den forklarer, hvorfor mappingen er pålidelig. Den interne GetDestType returnerer et heltal 1..8 for de otte fit-typer i præcis rækkefølgen XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind er erklæret, så dens ordinaler flugter én til én: dkXYZ er ordinal 1, dkFitBV er ordinal 8, med dkNone siddende på nul. Så konverteringen er et direkte ordinal-cast med en intervalvagt, ikke en opslagstabel, der kan glide ude af synkronisering, efterhånden som enumen vokser. Det er en lille detalje, men det er den slags, der, gjort på den naive måde, bliver en off-by-one-fejl, første gang nogen omordner en enumeration
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destinationen blev ikke opløst
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;
En Page på nul er signalet om, at destinationen ikke blev opløst, som regel fordi actionen ingen destination bærer, eller den navngivne destination ikke kunne findes. Tjek det, før du stoler på nogen koordinat. Bemærk også, at GetOutlineDestinationInfo kigger begge steder, en destination kan bo: direkte på bogmærkets /Dest, og inde i en indlejret GoTo-actions /D. Du behøver ikke vide, hvilken form producenten brugte
Annotationsactions og SelectPage-fælden
Link-annotationer bærer actions på præcis samme måde som bogmærker, og GetAnnotActionInfo returnerer den samme TPDFlibActionInfo-record med det samme type-så-nyttelast-mønster. Men der er en tilstandsbaseret fælde her, der ikke gælder for dispositionselementer, og det er den tredje faldgrube
Annotationer hører til sider, og PDF Library for Delphi eksponerer den aktuelle sides annotationer gennem tilstand, der først bliver gyldig, efter du har valgt den side. Kald GetAnnotActionInfo uden først at kalde SelectPage(N), og annotationshandlet er nul; kaldet returnerer akNone, og du konkluderer fejlagtigt, at siden ingen handlingsbare annotationer har. Rettelsen er én linje, men den er let at glemme, når du løkker over sider:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // obligatorisk, før du rører annotationer
// GetAnnotActionID(1) <> 0 er den pålidelige "har en action"-
// test. CheckPageAnnots returnerer et boolsk flag, ikke en
// count, så det er det svagere signal her.
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;
To ting i den løkke er bevidste. For det første kommer SelectPage(P) før enhver annotationsadgang på hver iteration; den pr.-side-annotationstilstand bæres ikke over. For det andet bruger eksistenstesten GetAnnotActionID(1) <> 0 frem for CheckPageAnnots. Sidstnævnte rapporterer tilstedeværelse som et boolsk flag frem for en count, så et action-ID forskelligt fra nul er den mere præcise måde at spørge "findes der en første annotation, og bærer den en action, jeg kan læse?" Endnu en finesse værd at nævne: for annotationer læses en JavaScript-actions script direkte fra /JS, hvor en stream afkodes, når scriptet er gemt på den måde, og en streng læses ellers, så det overlever begge almindelige kodninger
Hvor læseside-introspektion passer ind
Disse gettere er bevidst snævre. De er rene læsninger bygget oven på bibliotekets eksisterende integer-handle action- og destinationslag, så de rører ingen skrivesti og tilføjer ingen risiko for dokumenter, du også redigerer. De rapporterer, hvad der er i filen; de validerer det ikke mod en politik eller omskriver noget. Er dit mål det omvendte, at bygge bogmærker og link-annotationer, der bærer disse actions i første omgang, hører det til på skrivesiden, og sidestykket om interaktive formular-actions og JavaScript i Delphi gennemgår, hvordan man opretter dem. For at trække det synlige og strukturelle indhold ud af en PDF frem for dens navigationsgraf, se udtrækning af tekst, billeder og fonte med PDF Library for Delphi
Den ærlige grænse, du skal huske: introspektion ser kun det, producenten faktisk skrev. Et bogmærke, hvis action en generator efterlod misdannet, eller en destination, der peger på et navngivet mål, der aldrig blev defineret, vil dukke op som akNone eller en nul-side i stedet for en undtagelse. Det er den rigtige adfærd for en læse-API, der auditerer utroværdige filer, men det betyder, at din kode bør behandle de nul-resultater som "fraværende eller uopløst", ikke som en garanti for velformet input. Den typede action- og destinationsintrospektion, der er vist her, er en del af PDF Library for Delphi, det native PDF-bibliotek til Delphi og C++Builder