Κληρονομείτε έναν φάκελο με PDF από κάποιο προηγούμενο στάδιο, και η εργασία ακούγεται απλή: πείτε μου ποιοι σελιδοδείκτες ανοίγουν εξωτερικό URL, ποιοι εκτελούν JavaScript και πού καταλήγουν πραγματικά οι εσωτερικοί. Έπειτα ανοίγετε την τεκμηρίωση του API και ανακαλύπτετε ότι η βιβλιοθήκη μπορεί να δημιουργήσει όλες αυτές τις ενέργειες αλλά δεν προσφέρει τίποτα για να τις διαβάσετε πίσω. Αυτή η ασυμμετρία υπάρχει παντού στα εργαλεία PDF. Η δημιουργία ενός σελιδοδείκτη που ανοίγει https://example.com είναι μία γραμμή· το να ρωτήσετε έναν υπάρχοντα σελιδοδείκτη "τι κάνεις και προς ποιον προορισμό;" συνήθως σημαίνει ότι περπατάτε με το χέρι το ακατέργαστο δέντρο αντικειμένων μέσω /A, /S, /Dest και μια διακλάδωση παραλλαγών fit-type που σχεδόν κανείς δεν πετυχαίνει σωστά με την πρώτη
Το PDFlibPas είναι μια εγγενής βιβλιοθήκη PDF σε Object Pascal για Delphi και C++Builder, και για πολύ καιρό είχε το ίδιο κενό: πλούσια setters στην πλευρά της εγγραφής, getters που σας έδιναν πίσω ένα γυμνό TPDFObject και σας άφηναν να ψάχνετε βαθιά. Η έκδοση v3.77.0 έκλεισε μέρος αυτού του κενού με ένα μικρό σύνολο από τυποποιημένες κλήσεις ενδοσκόπησης που αναφέρουν το είδος της ενέργειας, το φορτίο της ενέργειας και τη γεωμετρία του προορισμού ως απλά records. Αυτό το άρθρο εξηγεί πώς αυτές οι κλήσεις αντιστοιχούν στο μοντέλο ενεργειών και προορισμών του ISO 32000-1 και τις τρεις συγκεκριμένες παγίδες που κάνουν οι χειροποίητες εκδόσεις αυτού του κώδικα να αποτυγχάνουν σιωπηρά
Γιατί η ανάγνωση ενεργειών είναι πιο δύσκολη από την εγγραφή τους
Μια ενέργεια σε ένα PDF είναι ένα λεξικό με ένα /S κλειδί που ονομάζει τον υποτύπο της: GoTo, GoToR, URI, Launch, Named, JavaScript και μια μακριά ουρά που σπάνια συναντά κανείς (ISO 32000-1 §12.6.4). Το πρόβλημα είναι ότι το φορτίο ζει σε διαφορετικό κλειδί για κάθε υποτύπο, και δεν υπάρχει ένα ενιαίο πεδίο "δώσε μου τον προορισμό". Μια URI ενέργεια κρατά τη διεύθυνσή της στο /URI. Μια GoToR ή Launch ενέργεια κρατά μια προδιαγραφή αρχείου στο /F. Μια JavaScript ενέργεια κρατά το script της στο /JS, το οποίο μπορεί να είναι είτε string είτε stream. Μια GoTo ενέργεια δεν μεταφέρει καθόλου δικό της φορτίο· ο προορισμός της είναι ένα destination, κρεμασμένο από το /D, τον οποίο μετά πρέπει να επιλύσετε ξεχωριστά
Όταν γράφετε μια ενέργεια, γνωρίζετε από πριν το είδος της, οπότε τίποτα από αυτά δεν έχει σημασία. Όταν διαβάζετε μία, πρέπει πρώτα να κάνετε διακλάδωση με βάση το /S και μετά να μπείτε στο σωστό κλειδί, και έπειτα να χειριστείτε το γεγονός ότι η ίδια λογική έννοια ("το πράγμα προς το οποίο δείχνει αυτή η ενέργεια") κωδικοποιείται με τρεις ασύμβατους τρόπους. Αυτή ακριβώς τη διακλάδωση απορροφούν οι τυποποιημένοι getters. GetOutlineActionInfo και GetAnnotActionInfo και οι δύο επιστρέφουν ένα TPDFlibActionInfo record:
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;
Το record σάς λέει ποια πεδία είναι ουσιώδη μέσω του Kind. Αν το Kind επιστρέψει akURI, διαβάστε το URI και αγνοήστε τα υπόλοιπα. Αν επιστρέψει akGoTo, κανένα από τα πεδία του payload δεν ισχύει και περνάς στο destination, το οποίο καλύπτεται με ξεχωριστή κλήση πιο κάτω. akNone είναι η ειλικρινής απάντηση όταν ο σελιδοδείκτης ή το annotation δεν έχει καμία action, αντί για ένα μηδενικό που πρέπει να μαντέψεις τι σημαίνει
Διατρέχοντας το δέντρο outline για να βρεις έναν σελιδοδείκτη
Πριν μπορέσεις να εξετάσεις έναν σελιδοδείκτη, χρειάζεσαι το handle του. Το PDFlibPas αναγνωρίζει τους κόμβους outline με ένα ακέραιο ID, και FindOutlineByTitle εντοπίζει έναν με βάση το ορατό κείμενό του, με ρητό έλεγχο για το πόσο μακριά φτάνει η αναζήτηση:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Το Depth όρισμα είναι το σημείο που αξίζει να σταθείς λίγο. osdSiblingsOnly σαρώνει την αλυσίδα των αδελφών στο επίπεδο του αρχικού κόμβου και σταματά· θα βρει έναν αδελφό σελιδοδείκτη αλλά δεν θα κατέβει ποτέ στα παιδιά ενός αδελφού. osdChildrenOnly κοιτά μία στάθμη πιο κάτω, στα άμεσα παιδιά του αρχικού κόμβου. osdFullSubTree αναδρομεί σε ολόκληρο το κλαδί. Η λάθος επιλογή αποτυγχάνει σιωπηλά, όχι με σφάλμα: μια αναζήτηση μόνο σε αδελφούς για έναν τίτλο που βρίσκεται δύο επίπεδα πιο κάτω απλώς επιστρέφει μηδέν, και καταλήγεις ότι ο σελιδοδείκτης δεν υπάρχει ενώ ήταν εκεί εξαρχής. Δώσε το GetFirstOutline ως start ID για να ξεκινήσεις την αναζήτηση από τη ρίζα του εγγράφου
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;
Η αντιστοίχιση γίνεται πάνω στην ακριβή συμβολοσειρά του τίτλου, η οποία συγκρίνεται ως WideString, άρα είναι case-sensitive και σέβεται το κείμενο Unicode ακριβώς όπως είναι αποθηκευμένο. Αν τα PDF προέλευσής σου προέρχονται από ασυνεπείς δημιουργούς, κάνε κανονικοποίηση στον τίτλο που αναζητάς με τον ίδιο τρόπο που το έχει αποθηκεύσει το έγγραφο, αλλιώς θα κυνηγάς ανύπαρκτες αστοχίες
Εντοπισμός της action και του προορισμού ενός σελιδοδείκτη
Με το handle στο χέρι, GetOutlineActionInfoσου δίνει την τυποποιημένη προβολή. Το μοτίβο είναι: το καλείς, κάνεις switch στο Kind, και διαβάζεις το πεδίο που συμπληρώνει αυτό το kind
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;
Εδώ κρύβεται η πρώτη πραγματική παγίδα, και είναι εκείνη που ανέδειξε το test feedback κατά την υλοποίηση. Υπάρχει ένα παλαιότερο getter, GetActionURL, και το να στραφείς σε αυτό για να διαβάσεις ένα URI action είναι το προφανές λάθος που μοιάζει σωστό. GetActionURL επιλύει ένα file specification μέσω του /F key. Αυτό είναι το σωστό για GoToR και Launch, των οποίων οι στόχοι είναι όντως αρχεία, αλλά είναι το λάθος key για ένα URI action εντελώς. Η διεύθυνση μιας URI action είναι ένα απλό string στο δικό της /URI key, όχι file spec. Αν περάσεις ένα URI action στη διαδρομή του file spec, παίρνεις κενό ή ακατανόητο αποτέλεσμα. Το τυποποιημένο getter το χειρίζεται εσωτερικά διαβάζοντας /URI απευθείας για akURI και καλώντας τον resolver του file specification μόνο για akGoToR και akLaunch, που είναι ακριβώς η διάκριση που μια χειροποίητη εκδοχή συνήθως θολώνει
Τύποι fit των destination και η γεωμετρία πίσω τους
Ένα akGoTo action σημαίνει «πλοηγήσου μέσα σε αυτό το έγγραφο», αλλά δεν σου λέει τίποτα για το πού ή πώς. Αυτή είναι η δουλειά του destination, και τα destinations έχουν περισσότερη λεπτότητα απ' όση περιμένουν οι περισσότεροι. Ένα PDF destination δεν είναι απλώς ένας αριθμός σελίδας· είναι μια σελίδα μαζί με μια προδιαγραφή "fit" που λέει πώς πρέπει ο viewer να πλαισιώσει αυτή τη σελίδα (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo το επιστρέφει ως 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;
Οι οκτώ τύποι fit απαντούν σε διαφορετικά ερωτήματα πλαισίωσης. dkXYZ τοποθετεί ένα συγκεκριμένο σημείο στην επάνω αριστερή γωνία με ρητό ζουμ, άρα χρησιμοποιεί Left, Top και Zoom. dkFit προσαρμόζει ολόκληρη τη σελίδα στο παράθυρο και αγνοεί τις συντεταγμένες. dkFitH και dkFitV προσαρμόζουν το πλάτος ή το ύψος της σελίδας με μία μόνο σχετική συντεταγμένη (ένα επάνω άκρο ή ένα αριστερό άκρο). dkFitR είναι η πιο ενδιαφέρουσα: προσαρμόζει ένα καθορισμένο ορθογώνιο, άρα και οι τέσσερις πλευρές μετρούν. Η dkFitB* οικογένεια κάνει τα ίδια πράγματα σε σχέση με το bounding box του ορατού περιεχομένου αντί για ολόκληρη τη σελίδα. Το να ξέρεις ποια πεδία είναι ενεργά για κάθε τύπο είναι η διαφορά ανάμεσα στο να διαβάζεις σωστά έναν προορισμό και στο να τυπώνεις άχρηστες συντεταγμένες που τυχαίνει να είναι μηδέν

Στο παρασκήνιο, η υλοποίηση βασίζεται σε μια σκόπιμη αντιστοίχιση που αξίζει να τη γνωρίζεις, γιατί εξηγεί γιατί η χαρτογράφηση είναι αξιόπιστη. Η εσωτερική GetDestType επιστρέφει έναν ακέραιο 1..8 για τους οκτώ τύπους fit ακριβώς με τη σειρά XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind δηλώνεται έτσι ώστε οι ordinal τιμές του να ευθυγραμμίζονται μία προς μία: dkXYZ είναι ordinal 1, dkFitBV είναι ordinal 8, ενώ dkNone βρίσκεται στο μηδέν. Άρα η μετατροπή είναι απευθείας ordinal cast με έλεγχο εύρους, όχι ένας lookup table που μπορεί να ξεφύγει από τον συγχρονισμό καθώς μεγαλώνει το enum. Είναι μια μικρή λεπτομέρεια, αλλά είναι από εκείνα τα πράγματα που, αν γίνουν με τον αφελή τρόπο, καταλήγουν σε off-by-one bug την πρώτη φορά που κάποιος αναδιατάξει μια enumeration
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;
Ένα Page τιμή zero είναι το σήμα ότι ο προορισμός δεν επιλύθηκε, συνήθως επειδή η ενέργεια δεν μεταφέρει προορισμό ή ο ονομασμένος προορισμός δεν βρέθηκε. Έλεγξέ το πριν εμπιστευτείς οποιαδήποτε συντεταγμένη. Σημείωσε επίσης ότι GetOutlineDestinationInfo αναζητά και στα δύο μέρη όπου μπορεί να υπάρχει ένας προορισμός: απευθείας στο σελιδοδείκτη /Dest, και μέσα σε μια ενσωματωμένη GoTo ενέργειας /D. Δεν χρειάζεται να ξέρεις ποια μορφή χρησιμοποίησε ο δημιουργός
Οι ενέργειες σχολιασμού και η παγίδα SelectPage
Οι σχολιασμοί συνδέσμου μεταφέρουν ενέργειες ακριβώς όπως οι σελιδοδείκτες, και GetAnnotActionInfo επιστρέφει την ίδια TPDFlibActionInfo εγγραφή με το ίδιο μοτίβο kind-then-payload. Όμως εδώ υπάρχει μια παγίδα που εξαρτάται από την κατάσταση και δεν ισχύει για τα outlines, και αυτή είναι η τρίτη παγίδα
Οι annotations ανήκουν σε σελίδες, και το PDFlibPas εκθέτει τα annotations της τρέχουσας σελίδας μέσω κατάστασης που γίνεται έγκυρη μόνο αφού επιλέξεις εκείνη τη σελίδα. Κάλεσε GetAnnotActionInfo χωρίς να καλέσεις πρώτα SelectPage(N) και το handle του annotation είναι zero· η κλήση επιστρέφει akNone και εσφαλμένα καταλήγεις ότι η σελίδα δεν έχει annotations με action. Η διόρθωση είναι μία γραμμή, αλλά ξεχνιέται εύκολα όταν κάνεις loop στις σελίδες:
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;
Δύο πράγματα σε αυτό το loop είναι σκόπιμα. Πρώτον, SelectPage(P) προηγείται κάθε πρόσβασης σε annotation σε κάθε επανάληψη· η κατάσταση των annotations ανά σελίδα δεν μεταφέρεται. Δεύτερον, ο έλεγχος ύπαρξης χρησιμοποιεί GetAnnotActionID(1) <> 0 αντί για CheckPageAnnots. Το δεύτερο επιστρέφει την παρουσία ως σημαία τύπου boolean και όχι ως πλήθος, οπότε ένα μη μηδενικό action ID είναι ο πιο ακριβής τρόπος να ρωτήσεις "υπάρχει πρώτο annotation και φέρει μια action που μπορώ να διαβάσω;" Μια ακόμη λεπτομέρεια που αξίζει να σημειωθεί: για annotations, ένα JavaScript script της action διαβάζεται από /JS απευθείας, αποκωδικοποιώντας ένα stream όταν το script είναι αποθηκευμένο έτσι και διαβάζοντας μια συμβολοσειρά αλλιώς, ώστε να αντέχει και στις δύο συνηθισμένες κωδικοποιήσεις
Πού ταιριάζει η introspection της πλευράς ανάγνωσης
Αυτά τα getters είναι σκόπιμα περιορισμένα. Είναι καθαρές αναγνώσεις χτισμένες πάνω στα υπάρχοντα επίπεδα actions και destinations της βιβλιοθήκης με integer handle, άρα δεν αγγίζουν κανένα write path και δεν προσθέτουν κίνδυνο στα έγγραφα που επεξεργάζεσαι επίσης. Αναφέρουν τι υπάρχει στο αρχείο· δεν το ελέγχουν σε σχέση με κάποια πολιτική ούτε ξαναγράφουν τίποτα. Αν ο στόχος σου είναι ο αντίθετος, δηλαδή να χτίζεις bookmarks και link annotations που φέρουν αυτές τις actions εξαρχής, αυτό ανήκει στην πλευρά εγγραφής, και το συνοδευτικό κομμάτι στο διαδραστικές ενέργειες φορμών και JavaScript στο Delphi δείχνει πώς να τα δημιουργείτε. Για την εξαγωγή του ορατού και δομικού περιεχομένου από ένα PDF, αντί για το γράφημα πλοήγησής του, δείτε την εξαγωγή κειμένου, εικόνων και γραμματοσειρών με το PDFlibPas
Το σαφές όριο που πρέπει να θυμάστε είναι το εξής: η introspection βλέπει μόνο ό,τι έγραψε πραγματικά ο δημιουργός. Ένας σελιδοδείκτης του οποίου η ενέργεια έμεινε κακοσχηματισμένη από έναν generator, ή ένας προορισμός που δείχνει σε ένα ονομασμένο target το οποίο δεν ορίστηκε ποτέ, θα εμφανιστεί ως akNone ή ως μηδενική σελίδα, όχι ως εξαίρεση. Αυτή είναι η σωστή συμπεριφορά για ένα read API που ελέγχει μη αξιόπιστα αρχεία, αλλά σημαίνει ότι ο κώδικάς σας πρέπει να αντιμετωπίζει αυτά τα μηδενικά αποτελέσματα ως «απόντα ή μη επιλυμένα» και όχι ως εγγύηση σωστά διαμορφωμένης εισόδου. Η τυποποιημένη introspection ενεργειών και προορισμών που παρουσιάζεται εδώ αποτελεί μέρος του PDFlibPas, της εγγενούς βιβλιοθήκης PDF για Delphi και C++Builder