Primești un folder de PDF-uri din amonte și sarcina pare banală: spune-mi care bookmark-uri sar la un URL extern, care rulează JavaScript și unde ajung de fapt cele interne. Apoi deschizi referința API și descoperi că biblioteca poate crea fiecare dintre aceste acțiuni, dar nu oferă nimic pentru a le citi înapoi. Această asimetrie apare peste tot în instrumentele PDF. Să scrii un bookmark care deschide https://example.com este o singură linie; să întrebi un bookmark existent „ce faci și către ce țintă?” înseamnă de obicei să parcurgi manual arborele brut de obiecte prin /A, /S, /Dest și o ramificare de variante de fit pe care aproape nimeni nu le nimerește din prima
PDFlibPas este o bibliotecă PDF nativă Object Pascal pentru Delphi și C++Builder și, multă vreme, a avut aceeași lipsă: setteri bogați pentru scriere, getteri care îți returnau un simplu TPDFObject ID întreg și te lăsau să scotocești. Lansarea v3.77.0 a închis parțial asta cu un mic set de apeluri tipizate de introspecție care raportează tipul acțiunii, payload-ul acțiunii și geometria destinației ca simple recorduri. Articolul acesta explică modul în care acele apeluri se mapează pe modelul de acțiuni și destinații ISO 32000-1 și cele trei capcane concrete care fac versiunile scrise manual ale acestui cod să dea greș în tăcere
De ce citirea acțiunilor este mai grea decât scrierea lor
O acțiune în PDF este un dicționar cu o /S cheie care îi numește subtipul: GoTo, GoToR, URI, Launch, Named, JavaScript, și o coadă mai lungă pe care o întâlnești rar (ISO 32000-1 §12.6.4). Problema este că payload-ul stă în altă cheie pentru fiecare subtip și nu există o fantă uniformă de tipul „dă-mi ținta”. O URI acțiune își păstrează adresa în /URI. O GoToR sau Launch acțiune păstrează o specificație de fișier în /F. O JavaScript acțiune își păstrează scriptul în /JS, care poate fi fie un șir, fie un flux. O GoTo acțiune nu poartă deloc payload propriu; ținta ei este o destinație, atașată de /D, pe care apoi trebuie să o rezolvi separat
Când scrii o acțiune, îi știi dinainte tipul, așa că nimic din toate astea nu contează. Când citești una, trebuie să ramifici mai întâi pe /S mai întâi, apoi să intri în cheia potrivită, apoi să gestionezi faptul că același concept logic („lucrul spre care indică această acțiune”) este codificat în trei moduri incompatibile. Acea ramificare este exact ceea ce absorb getteri tipizați. GetOutlineActionInfo și GetAnnotActionInfo ambele returnează un TPDFlibActionInforecord:
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;
Recordul îți arată ce câmpuri sunt relevante prin Kind. Dacă Kind revine akURI, citește URI și ignoră restul. Dacă revine akGoTo, niciunul dintre câmpurile payload nu se aplică și treci la destinație, care este o apelare separată, tratată mai jos. akNone este răspunsul sincer atunci când bookmark-ul sau adnotarea nu are nicio acțiune, în loc de un zero a cărui semnificație trebuie ghicită
Parcurgerea arborelui outline pentru a găsi un bookmark
Înainte să poți inspecta un bookmark, ai nevoie de handle-ul lui. PDFlibPas identifică nodurile outline printr-un ID întreg, iar FindOutlineByTitle îl localizează după textul vizibil, cu control explicit asupra profunzimii căutării:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Argumentul Depth este partea asupra căreia merită să te oprești. osdSiblingsOnly parcurge lanțul de frați la nivelul nodului de pornire și se oprește; va găsi un bookmark de același nivel, dar nu va coborî niciodată în copiii unui nod vecin. osdChildrenOnly coboară un nivel, la copiii direcți ai nodului de pornire. osdFullSubTree recurge prin întreaga ramură. Alegerea greșită nu produce o eroare, ci un eșec tăcut: o căutare doar între frați pentru un titlu aflat la două niveluri adâncime returnează pur și simplu zero, iar tu ajungi să conchizi că bookmark-ul nu există, deși era acolo tot timpul. Transmite GetFirstOutline ca ID de pornire pentru a căuta din rădăcina documentului
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;
Potrivirea se face pe șirul exact al titlului, comparat ca un WideString, deci ține cont de majuscule și minuscule și respectă textul Unicode exact așa cum este stocat. Dacă PDF-urile sursă vin de la producători inconsistenți, normalizează titlul pe care îl cauți în același mod în care l-a stocat documentul, altfel vei urmări ratări fantomă
Rezolvarea acțiunii și țintei unui bookmark
Cu handle-ul în mână, GetOutlineActionInfo îți oferă vizualizarea tipizată. Tiparul este: îl apelezi, faci switch pe Kind, apoi citești câmpul pe care îl populează acea categorie
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;
Aici se află prima capcană reală, aceea pe care feedbackul de test a scos-o la iveală în timpul implementării. Există un getter mai vechi, GetActionURL, iar a-l folosi pentru a citi o acțiune URI este greșeala care pare evidentă. GetActionURL rezolvă o specificație de fișier prin cheia /F. Asta este corect pentru GoToR și Launch, ale căror ținte sunt într-adevăr fișiere, dar este cheia greșită pentru o acțiune URI în întregime. O acțiune URI are adresa ca un simplu șir pe cheia /URI proprie a acțiunii, nu ca specificație de fișier. Dacă dai o acțiune URI pe calea file-spec, obții un rezultat gol sau fără sens. Getterul tipizat gestionează asta intern, citind /URI direct pentru akURI și apelând rezolvatorul de specificații de fișier doar pentru akGoToR și akLaunch, iar aceasta este exact distincția pe care o versiune scrisă de mână tinde să o estompeze
Tipuri de fit pentru destinații și geometria din spatele lor
O acțiune akGoTo înseamnă „navighează în interiorul acestui document”, dar nu îți spune nimic despre unde sau cum. Asta este treaba destinației, iar destinațiile poartă mai multe nuanțe decât se așteaptă oamenii. O destinație PDF nu este doar un număr de pagină; este o pagină plus o specificație de „fit” care spune cum ar trebui să încadreze vizualizatorul acea pagină (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo o returnează ca record:
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;
Cele opt tipuri de fit răspund la întrebări diferite despre încadrare. dkXYZ poziționează un punct specific în colțul din stânga sus la un zoom explicit, așa că folosește Left, Top și Zoom. dkFit încadrează întreaga pagină în fereastră și ignoră coordonatele. dkFitH și dkFitV încadrează lățimea sau înălțimea paginii folosind o singură coordonată relevantă, adică o muchie de sus sau o muchie din stânga. dkFitR este cea interesantă: încadrează un dreptunghi specificat, așa că toate cele patru muchii contează. Familia dkFitB* face aceleași lucruri raportat la caseta de încadrare a conținutului vizibil, nu la pagina completă. A ști ce câmpuri sunt active pentru fiecare tip face diferența dintre citirea corectă a unei destinații și afișarea unor coordonate invalide care se întâmplă să fie zero

În spate, implementarea se bazează pe o aliniere deliberată, care merită cunoscută deoarece explică de ce maparea este fiabilă. Funcția internă GetDestType returnează un întreg 1..8 pentru cele opt tipuri de fit în exact ordinea XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind este declarată astfel încât ordinalele să se alinieze unu-la-unu: dkXYZ este ordinalul 1, dkFitBV este ordinalul 8, cu dkNone aflat la zero. Așa că conversia este o conversie directă a ordinalului, cu o verificare a intervalului, nu un tabel de căutare care poate ieși din sincronizare pe măsură ce enum-ul crește. Este un detaliu mic, dar exact genul de lucru care, făcut naiv, devine un bug de tip off-by-one prima dată când cineva reordonează o enumerare
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;
Un Page de zero este semnalul că destinația nu s-a rezolvat, de obicei pentru că acțiunea nu conține nicio destinație sau destinația numită nu a putut fi găsită. Verifică-l înainte să ai încredere în vreo coordonată. Observă și că GetOutlineDestinationInfo caută în ambele locuri în care poate exista o destinație: direct pe /Dest, și în interiorul unei acțiuni GoTo al acțiunii /D. Nu trebuie să știi ce formă a folosit producătorul
Acțiunile de adnotare și capcana SelectPage
Adnotările de link conțin acțiuni exact la fel ca marcajele, iar GetAnnotActionInfo returnează același TPDFlibActionInfo înregistrare, cu aceeași structură: tip urmat de payload. Dar aici există o capcană care ține de stare și nu se aplică la outline-uri, iar aceasta este a treia capcană
Adnotările aparțin paginilor, iar PDFlibPas expune adnotările paginii curente printr-o stare care devine validă doar după ce selectezi acea pagină. Apelează GetAnnotActionInfo fără să apelezi mai întâi SelectPage(N) și handle-ul adnotării este zero; apelul returnează akNone și tragi greșit concluzia că pagina nu are adnotări cu acțiuni. Rezolvarea e o singură linie, dar e ușor să uiți asta când iterezi prin pagini:
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;
Două lucruri din acea buclă sunt deliberate. Mai întâi, SelectPage(P) vine înainte de orice acces la adnotări la fiecare iterație; starea adnotărilor pe pagină nu se păstrează. În al doilea rând, testul de existență folosește GetAnnotActionID(1) <> 0 în loc de CheckPageAnnots. Acesta din urmă raportează prezența ca un indicator de tip boolean, nu ca un număr, așa că un ID de acțiune nenul este modul mai precis de a întreba "există o primă adnotare și conține o acțiune pe care o pot citi?" Încă un detaliu subtil merită menționat: pentru adnotări, scriptul unei JavaScript acțiuni este citit din /JS direct, decodificând un flux atunci când scriptul este stocat astfel și citind un șir în caz contrar, așa că funcționează cu ambele codificări uzuale
Unde se potrivește introspecția pe partea de citire
Aceste metode de tip getter sunt intenționat restrânse. Sunt doar citiri pure construite peste straturile existente de acțiuni și destinații pe bază de handle-uri întregi ale bibliotecii, așa că nu ating nicio cale de scriere și nu adaugă niciun risc documentelor pe care le editezi și tu. Ele raportează ce se află în fișier; nu îl validează în raport cu o politică și nu rescriu nimic. Dacă obiectivul tău este inversul, adică să construiești marcaje și adnotări de link care poartă aceste acțiuni de la bun început, asta ține de partea de scriere, iar piesa pereche despre acțiunile interactive de formular și JavaScript în Delphi parcurge procesul de creare a acestora. Pentru a extrage conținutul vizibil și structural dintr-un PDF, nu graful său de navigare, vezi extracting text, images, and fonts with PDFlibPas
Limita de ținut minte este aceasta: introspecția vede doar ce a scris efectiv producătorul. Un semn de carte a cărui acțiune a fost lăsată defectă de un generator sau o destinație care indică spre o țintă denumită și care nu a fost niciodată definită va apărea ca akNone sau ca o pagină zero, nu ca o excepție. Acesta este comportamentul corect pentru o API de citire care verifică fișiere nesigure, dar înseamnă că trebuie să tratezi aceste rezultate zero ca „absent sau nerezolvat”, nu ca pe o garanție că intrarea este bine formată. Introspecția de tipizare pentru acțiuni și destinații prezentată aici face parte din PDFlibPas, biblioteca PDF nativă pentru Delphi și C++Builder