Du ärver en mapp med PDF-filer från någon uppströmskälla, och uppgiften låter trivial: säg vilka bokmärken som hoppar till en extern URL, vilka som kör JavaScript, och var de interna faktiskt landar. Sedan öppnar du API-referensen och upptäcker att biblioteket kan skapa varenda en av de här åtgärderna men inte erbjuder något sätt att läsa dem tillbaka. Den här asymmetrin finns överallt i PDF-verktyg. Att skriva ett bokmärke som öppnar https://example.com är en rad kod; att fråga ett befintligt bokmärke "vad gör du, och mot vilket mål?" betyder oftast att man måste gå för hand genom det råa objektträdet via /A, /S, /Dest och en uppsättning fit-typsvarianter som nästan ingen får rätt på första försöket
PDFlibPas är ett inbyggt PDF-bibliotek i Object Pascal för Delphi och C++Builder, och länge hade det samma lucka: kraftfulla skrivsides-sättare, men getter som skickade tillbaka ett naket TPDFObject och lämnade dig att rota runt. Version v3.77.0 stängde delvis den luckan med en liten uppsättning typade introspektionsanrop som rapporterar åtgärdstypen, åtgärdspayloaden och destinationsgeometrin som vanliga poster. Den här artikeln handlar om hur de anropen mappar mot modellen för åtgärder och destinationer i ISO 32000-1, och om de tre konkreta fallgroparna som gör att handskrivna versioner av den här koden tyst blir fel
Varför det är svårare att läsa åtgärder än att skriva dem
En åtgärd i PDF är en ordbok med ett /S-nyckelfält som anger dess undertyp: GoTo, GoToR, URI, Launch, Named, JavaScript och en längre svans som du sällan stöter på (ISO 32000-1 §12.6.4). Problemet är att payloaden ligger i en annan nyckel för varje undertyp, och det finns ingen enhetlig plats för "ge mig målet". En URI-åtgärd behåller sin adress i /URI. En GoToR- eller Launch-åtgärd behåller en filspecifikation i /F. En JavaScript-åtgärd behåller sitt skript i /JS, som kan vara antingen en sträng eller en ström. En GoTo-åtgärd har ingen egen payload alls; dess mål är en destination som hänger på /D och som du sedan måste lösa separat
När du skriver en åtgärd vet du vilken typ den är från början, så inget av detta spelar någon roll. När du läser en måste du först grena på /S, sedan gå in i rätt nyckel och därefter hantera att samma logiska begrepp ("saken som den här åtgärden pekar på") kodas på tre inkompatibla sätt. Den förgreningen är exakt det som de typade hämtarna absorberar. GetOutlineActionInfo och GetAnnotActionInfo returnerar båda en TPDFlibActionInfo-post:
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;
Posten berättar vilka fält som är meningsfulla via Kind. Om Kind kommer tillbaka akURI, läs URI och ignorera resten. Om den kommer tillbaka akGoTo, gäller inga av payloadfälten och du går vidare till destinationen, som är ett separat anrop som beskrivs längre ner. akNone är det ärliga svaret när bokmärket eller annoteringen inte har någon åtgärd alls, i stället för ett nollvärde du måste gissa betydelsen av
Gå igenom bokmärkesträdet för att hitta ett bokmärke
Innan du kan inspektera ett bokmärke behöver du dess ID. PDFlibPas identifierar bokmärkesträdets noder med ett heltals-ID, och FindOutlineByTitle letar upp en med dess synliga text med explicit kontroll över hur långt sökningen sträcker sig:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Parametern Depth är den del som är värd att stanna upp vid. osdSiblingsOnly skannar syskonkedjan på startnodens nivå och stannar där; den hittar ett syskonbokmärke men går aldrig ner i syskonets barn. osdChildrenOnly tittar ett nivå ner, till startnodens omedelbara barn. osdFullSubTree går rekursivt genom hela grenen. Att välja fel variant ger ett tyst misslyckande, inte ett fel: en sökning bara bland syskon efter en rubrik som ligger två nivåer ner returnerar helt enkelt noll, och du drar slutsatsen att bokmärket inte finns trots att det låg där hela tiden. Skicka GetFirstOutline som start-ID för att söka från dokumentroten
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;
Matchningen sker på den exakta titelsträngen, jämförd som en WideString, så den är skiftlägeskänslig och respekterar Unicode-texten exakt som den lagrats. Om dina käll-PDF:er kommer från ojämna producenter, normalisera titeln du söker efter på samma sätt som dokumentet lagrade den, annars jagar du spökträffar
Avgöra ett bokmärkes åtgärd och mål
Med ett ID i handen ger GetOutlineActionInfo dig den typade vyn. Mönstret är: anropa den, växla på Kind, läs det fält som den typen fyller i
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;
Här finns den första verkliga fallgropen, och det är den som teståterkopplingen avslöjade under implementationen. Det finns en äldre hämtare, GetActionURL, och att sträcka sig efter den för att läsa en URI-åtgärd är det uppenbara misstaget. GetActionURL löser en filspecifikation via /F-nyckeln. Det är rätt för GoToR och Launch, vars mål faktiskt är filer, men det är helt fel nyckel för en URI-åtgärd. En URI-åtgärds adress är en vanlig sträng på åtgärdens egen /URI-nyckel, inte en filspecifikation. Skickar du en URI-åtgärd till filspecifikationsvägen får du ett tomt eller nonsensartat resultat. Den typade hämtaren hanterar detta internt genom att läsa /URI direkt för akURI och bara anropa filspecifikationsupplösaren för akGoToR och akLaunch, vilket är exakt den skillnad som en handskriven version lätt suddar ut
Typer av destinationsanpassning och geometrin bakom dem
En akGoTo-åtgärd betyder "navigera inom det här dokumentet", men den säger dig ingenting om var eller hur. Det är destinationens jobb, och destinationer bär på mer nyans än folk förväntar sig. En PDF-destination är inte bara ett sidnummer; det är en sida plus en "fit"-specifikation som säger hur visaren ska rama in den sidan (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo returnerar den som en post:
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;
The eight fit kinds answer different framing questions. dkXYZ positions a specific point at the top-left corner at an explicit zoom, so it uses Left, Top and Zoom. dkFit fits the whole page in the window and ignores coordinates. dkFitH and dkFitV fit the page width or height with a single relevant coordinate (a top edge or a left edge). dkFitR is the interesting one: it fits a specified rectangle, so all four edges matter. The dkFitB* family does the same things relative to the bounding box of visible content rather than the full page. Knowing which fields are live for each kind is the difference between reading a destination correctly and printing garbage coordinates that happen to be zero

Under huven bygger implementationen på en medveten anpassning som är värd att känna till eftersom den förklarar varför mappningen är tillförlitlig. Den interna GetDestType returnerar ett heltal 1..8 för de åtta fit-typerna i exakt ordningen XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind är deklarerad så att dess ordningsvärden linjerar ett till ett: dkXYZ har ordningsvärde 1, dkFitBV har ordningsvärde 8, och dkNone ligger på noll. Så konverteringen är en direkt cast av ordningsvärdet med en gränskontroll, inte en uppslagstabell som kan driva isär när enumen växer. Det är en liten detalj, men det är den sortens sak som, gjord naivt, blir ett off-by-one-fel första gången någon omordnar en uppräkning
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;
En Page på noll är signalen om att destinationen inte gick att lösa, oftast för att åtgärden saknar destination eller den namngivna destinationen inte kunde hittas. Kontrollera det innan du litar på någon koordinat. Notera också att GetOutlineDestinationInfo tittar på båda platserna där en destination kan bo: direkt på bokmärkets /Dest och inne i en inbäddad GoTo-åtgärds /D. Du behöver inte veta vilken form producenten använde
Annoteringsåtgärder och SelectPage-fällan
Länkanoteringar bär åtgärder exakt på samma sätt som bokmärken gör, och GetAnnotActionInfo returnerar samma TPDFlibActionInfo-post med samma typ-först-och-sedan-payload-mönster. Men här finns en tillståndsbunden hake som inte gäller för dispositioner, och det är den tredje fallgropen
Annoteringar hör till sidor, och PDFlibPas exponerar den aktuella sidans annoteringar genom tillstånd som bara blir giltigt efter att du har valt den sidan. Anropa GetAnnotActionInfo utan att först anropa SelectPage(N) och annoteringshandtaget är noll; anropet returnerar akNone och du drar felaktigt slutsatsen att sidan saknar åtgärdsbara annoteringar. Fixen är en rad, men det är lätt att glömma när du loopar över sidor:
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;
Två saker i den loopen är medvetna. För det första kommer SelectPage(P) före all annoteringsåtkomst i varje iteration; annoteringstillståndet per sida följer inte med. För det andra använder existenskontrollen GetAnnotActionID(1) <> 0 i stället för CheckPageAnnots. Den senare rapporterar närvaro som en boolesk flagga i stället för en räknare, så ett icke-noll action-ID är det mer precisa sättet att fråga "finns det en första annotering, och bär den en åtgärd jag kan läsa?" En nyans till värd att flagga: för annoteringar läses ett JavaScript-åtgärds skript direkt från /JS, med avkodning av en ström när skriptet lagras så och annars genom att läsa en sträng, så det fungerar för båda vanliga kodningarna
Var läs-sidans introspektion passar in
De här hämtarna är medvetet smala. De är rena läsningar byggda ovanpå bibliotekets befintliga action- och destinationslager med heltalshandtag, så de rör ingen skrivväg och lägger ingen risk på dokument som du också redigerar. De rapporterar vad som finns i filen; de validerar det inte mot någon policy och skriver inte om något. Om ditt mål är det omvända, att bygga bokmärken och länkanoteringar som bär de här åtgärderna från början, så hör det hemma på skrivsidan, och det kompletterande inlägget om interaktiva formuläråtgärder och JavaScript i Delphi går igenom hur man skapar dem. För att hämta ut det synliga och strukturella innehållet ur en PDF snarare än dess navigationsgraf, se extrahera text, bilder och teckensnitt med PDFlibPas
Den ärliga gränsen att ha i åtanke: introspektion ser bara det som producenten faktiskt skrev. Ett bokmärke vars åtgärd en generator lämnade felaktig, eller en destination som pekar på ett namngivet mål som aldrig definierades, kommer att visa sig som akNone eller en sida på noll i stället för ett undantag. Det är rätt beteende för ett läs-API som granskar otillförlitliga filer, men det betyder att din kod bör behandla de nollresultaten som "saknas eller olöst", inte som en garanti för välformad indata. Den typade åtgärds- och destinationsintrospektion som visas här är en del av PDFlibPas, det inbyggda PDF-biblioteket för Delphi och C++Builder