Τεχνικό Άρθρο

Προσθήκη Σελιδοδεικτών σε Υπάρχοντα PDF στο Delphi με το HotPDF

Το HotPDF προσθέτει σελιδοδείκτες σε ένα υπάρχον PDF στο Delphi μέσω της AddLoadedOutline, η οποία δημιουργεί καταχωρίσεις διάρθρωσης (outline entries) σε οποιοδήποτε επίπεδο φωλιάσματος σε ένα φορτωμένο έγγραφο, και διαβάζει και γράφει ονομαστικούς προορισμούς (named destinations) μέσω των ResolveLoadedNamedDestination και AddLoadedNamedDestination. Φορτώστε το αρχείο, δημιουργήστε το δέντρο, καλέστε το SaveLoadedDocument, και η πλευρική στήλη του αναγνώστη που άλλοτε ήταν κενή τώρα φέρει έναν λειτουργικό πίνακα περιεχομένων

Το σενάριο που παρακινεί όλα αυτά είναι απογοητευτικά κοινό. Συγχωνεύετε καμιά δωδεκαριά συμβόλαια σε ένα πακέτο αναθεώρησης, ή συρράπτετε τρία εγχειρίδια προϊόντων σε ένα ενιαίο αρχείο διανομής, και το αποτέλεσμα είναι δομικά μια χαρά: κάθε σελίδα παρούσα, κάθε γραμματοσειρά άθικτη. Στη συνέχεια, κάποιος το ανοίγει στο Acrobat και το πάνελ των σελιδοδεικτών είναι άδειο. Ένα έγγραφο 400 σελίδων χωρίς πλοήγηση είναι τεχνικά πλήρες αλλά πρακτικά άχρηστο, και μέχρι την έκδοση 2.347.0 το HotPDF μπορούσε να διαβάζει, να τροποποιεί και να διαγράφει σελιδοδείκτες σε φορτωμένα έγγραφα αλλά ποτέ να μην τους δημιουργεί. Αυτό το κενό έχει πλέον κλείσει, και αυτό το άρθρο παρουσιάζει τη νέα διεπαφή API και ένα συγκεκριμένο μυστικό των PDF, το κλειδί /Count, που θα σας δημιουργήσει πρόβλημα εάν το αντιμετωπίσετε ως έναν απλό μετρητή καταχωρίσεων

Τι είναι στην πραγματικότητα το δέντρο διάρθρωσης

Μια διάρθρωση PDF (PDF outline) είναι ένα διπλά συνδεδεμένο δέντρο λεξικών (tree of dictionaries), όχι μια επίπεδη λίστα, και αυτή η δομή εξηγεί κάθε παράμετρο στο API. Το πρότυπο ISO 32000-1 §12.3.3 το ορίζει: ο κατάλογος εγγράφου (document catalog) δείχνει σε μια ρίζα /Outlines, η ρίζα δείχνει στα πρώτα και τελευταία παιδιά της μέσω των /First και /Last, και τα αδελφά στοιχεία συνδέονται μέσω των /Next και /Prev, ενώ κάθε καταχώριση δείχνει πίσω προς τα πάνω μέσω του /Parent. Κάθε καταχώριση φέρει μια συμβολοσειρά /Title και, συνήθως, ένα /Dest που δηλώνει πού πρέπει να οδηγήσει τον αναγνώστη κάνοντας κλικ σε αυτό

Οι προορισμοί διατίθενται σε δύο μορφές, και το ISO 32000-1 §12.3.2 τους διατηρεί σκόπιμα ξεχωριστούς. Ένας ρητός προορισμός (explicit destination) είναι ένας ενσωματωμένος πίνακας όπως ο [page /XYZ x y zoom]: μια άμεση αναφορά σε ένα αντικείμενο σελίδας συν μια προδιαγραφή προβολής. Αντίθετα, ένας ονομαστικός προορισμός (named destination) αποθηκεύει μια συμβολοσειρά ονόματος, και ο πραγματικός στόχος ζει στον κατάλογο εγγράφου κάτω από το δέντρο ονομάτων /Names /Dests, ένα επίπεδο έμμεσης αναφοράς (indirection) που επιτρέπει σε πολλούς συνδέσμους να μοιράζονται έναν στόχο και επιτρέπει στον στόχο να μετακινείται χωρίς να αγγίζονται οι σύνδεσμοι. Το HotPDF γράφει ρητούς προορισμούς /XYZ όταν δημιουργεί σελιδοδείκτες, και σας δίνει ξεχωριστές κλήσεις για την ανάγνωση και την επέκταση του δέντρου ονομάτων

Πώς προσθέτετε σελιδοδείκτες σε ένα υπάρχον PDF στο Delphi;

Καλέστε την AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) μετά τη LoadFromFile: περάστε ParentIndex = -1 για μια καταχώριση κορυφαίου επιπέδου, ή τον δείκτη με βάση το μηδέν μιας υπάρχουσας καταχώρισης κορυφαίου επιπέδου για να φωλιάσει κάτω από αυτήν. Η συνάρτηση δημιουργεί τη ρίζα /Outlines εάν το έγγραφο δεν είχε ποτέ, κατασκευάζει το λεξικό της καταχώρισης, γράφει έναν πίνακα /Dest [page /XYZ x y zoom] που δείχνει στο DestPageIndex με βάση το μηδέν, και συνδέει τη νέα καταχώριση στην αλυσίδα των αδελφών στοιχείων. Επιστρέφει 0 σε περίπτωση επιτυχίας και -1 σε περίπτωση αποτυχίας, για παράδειγμα όταν το DestPageIndex είναι εκτός ορίων

Μια λεπτομέρεια σύνδεσης έχει σημασία για τη σειρά: μια νέα καταχώριση εισάγεται στην αρχή της αλυσίδας παιδιών του γονέα της, ως το νέο /First. Έτσι, εάν προσθέσετε το "Κεφάλαιο 1" και στη συνέχεια το "Κεφάλαιο 2" ως καταχωρίσεις κορυφαίου επιπέδου, το Κεφάλαιο 2 εμφανίζεται πάνω από το Κεφάλαιο 1 στην πλευρική στήλη, και το Κεφάλαιο 1 html μετατοπιστεί στον δείκτη κορυφαίου επιπέδου 1. Το πρακτικό μοτίβο είναι είτε να προσθέτετε τις καταχωρίσεις με αντίστροφη σειρά ανάγνωσης, είτε να προσθέτετε κάθε γονέα και να γεμίζετε αμέσως τα παιδιά του όσο αυτός βρίσκεται ακόμη στον δείκτη 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Οι παράμετροι X, Y και Zoom αντιστοιχίζονται απευθείας στον προορισμό /XYZ και όλες έχουν ως προεπιλογή το 0. Το PDF τοποθετεί την προέλευση των συντεταγμένων στην κάτω αριστερή γωνία, οπότε το Y = 792 στοχεύει στην κορυφή μιας σελίδας US Letter, και σύμφωνα με το ISO 32000-1 μια μηδενική τιμή σε μια υποδοχή /XYZ λέει στο πρόγραμμα προβολής να διατηρήσει την τρέχουσα ρύθμισή του, η οποία είναι ακριβώς αυτό που θέλετε για το ζουμ σχεδόν σε κάθε περίπτωση. Όλα συμβαίνουν στο φορτωμένο γράφημα αντικειμένων στη μνήμη· τίποτα δεν αγγίζει το δίσκο μέχρι να εκτελεστεί η SaveLoadedDocument, το ίδιο μοντέλο επεξεργασίας-και-αποθήκευσης που χρησιμοποιεί το API φορτωμένων εγγράφων για την επανασυγγραφή τίτλου, δημιουργού και μεταδεδομένων XMP σε φορτωμένα PDF

Επαναστοχοθέτηση σελιδοδεικτών: μεταπηδήσεις σελίδων έναντι ενεργειών URI

Οι υπάρχοντες σελιδοδείκτες μπορούν να επανασυνδεθούν χωρίς να ξαναδημιουργηθούν, και το HotPDF δίνει στις δύο περιπτώσεις δύο διαφορετικές κλήσεις επειδή οι υποκείμενες δομές PDF είναι πραγματικά διαφορετικές. Η SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) αντικαθιστά το /Dest της καταχώρισης κορυφαίου επιπέδου στο Index με έναν φρέσκο πίνακα /XYZ, το εργαλείο για την κλασική αποτυχία μετά τη συγχώνευση, όπου κάθε σελιδοδείκτης εξακολουθεί να δείχνει στους αριθμούς σελίδων του αρχικού αρχείου, πριν από τη συγχώνευση. Η SetLoadedOutlineURI(Index, URI) κάνει κάτι δομικά διαφορετικό: επισυνάπτει ένα λεξικό ενεργειών /A με /S /URI, μετατρέποντας τον σελιδοδείκτη σε σύνδεσμο ιστού αντί για μεταπήδηση εντός του εγγράφου

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Κρατήστε τους δύο μηχανισμούς ξεχωριστούς όταν σχεδιάζετε το δέντρο. Ένα /Dest είναι καθαρή πλοήγηση μέσα στο έγγραφο· μια ενέργεια URI εγκαταλείπει εντελώς το έγγραφο, και το ISO 32000-1 αναμένει από μια καταχώριση διάρθρωσης να πλοηγείται μέσω του ενός μηχανισμού ή του άλλου, notches και των δύο. Επομένως, κρατήστε τη SetLoadedOutlineURI για καταχωρίσεις που δεν φέρουν ήδη προορισμό σελίδας, όπως ένα φύλλο "Αρχική σελίδα προϊόντος" ή "Αναφορά προβλήματος" στο τέλος του δέντρου, παρά για μια μετατραπείσα επικεφαλίδα κεφαλαίου. Η ενέργεια URI που γράφεται εδώ είναι η ίδια δομή /S /URI που χρησιμοποιείται για σχόλια συνδέσμων (link annotations) στο περιεχόμενο της σελίδας, η οποία καλύπτεται σε μεγαλύτερο βάθος στον οδηγό για τη δημιουργία υπερσυνδέσμων σε έγγραφα PDF με το HotPDF

Πώς λειτουργούν οι ονομαστικοί προορισμοί σε ένα φορτωμένο PDF;

Οι ονομαστικοί προορισμοί είναι ο μηχανισμός σταθερής άγκυρας (stable-anchor) του ISO 32000-1 §12.3.2.3: αντί να ενσωματώνεται μια αναφορά σελίδας σε κάθε σύνδεσμο, το έγγραφο διατηρεί έναν χάρτη ονόματος-προς-προορισμό στο δέντρο ονομάτων /Names /Dests του καταλόγου, και οι σύνδεσμοι αναφέρονται στις καταχωρίσεις με βάση το όνομα. Η ResolveLoadedNamedDestination(Name) αναζητά ένα όνομα σε αυτό το δέντρο και επιστρέφει τον δείκτη σελίδας με βάση το μηδέν στον οποίο δείχνει, ή -1 εάν το όνομα απουσιάζει. Ο επιλυτής (resolver) χειρίζεται και τις δύο μορφές αποθήκευσης που επιτρέπει η προδιαγραφή για την αντιστοιχισμένη τιμή: έναν απλό πίνακα προορισμού όπως τον [page /XYZ x y zoom] και τη μορφή λεξικού που τυλίγει τον πίνακα κάτω από ένα κλειδί /D

Η AddLoadedNamedDestination(Name, DestPageIndex, X, Y) λειτουργεί αντίστροφα: καταχωρεί μια νέα άγκυρα στο δέντρο /Names /Dests, δημιουργώντας το ίδιο το δέντρο όταν το έγγραφο δεν έχει κανένα, και επιστρέφει True σε περίπτωση επιτυχίας. Μαζί, το ζεύγος καλύπτει τη ροή εργασίας όπου ένα συγχωνευμένο έγγραφο πρέπει να σέβεται τις άγκυρες που έγραψαν τα εργαλεία ανάντη (upstream tools) και να δημοσιεύει νέες για να τις στοχεύουν οι κατάντη (downstream) σύνδεσμοι

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Τα ειλικρινή όρια: οι προορισμοί που γράφει το HotPDF, τόσο για σελιδοδείκτες όσο και για ονομαστικές άγκυρες, είναι προορισμοί /XYZ. Οι άλλοι ρητοί τύποι που ορίζει η προδιαγραφή, όπως οι /Fit, /FitH, /FitB και λοιποί, δεν εκπέμπονται από αυτές τις κλήσεις, αν και το /XYZ με μηδενικό ζουμ καλύπτει την κυρίαρχη περίπτωση χρήσης "μετάβαση σε αυτό το σημείο, διατήρηση του ζουμ μου". Και η GetLoadedBookmarkPageIndex(Title), η βολική αναζήτηση που δέχεται έναν τίτλο σελιδοδείκτη και επιστρέφει τη σελίδα προορισμού του, σαρώνει το κορυφαίο επίπεδο του δέντρου διάρθρωσης, οπότε χρησιμοποιήστε την για να επαληθεύσετε τις καταχωρίσεις επιπέδου κεφαλαίου που μόλις δημιουργήσατε και όχι βαθιά φωλιασμένα φύλλα

Η παγίδα του /Count: μετράει απογόνους, όχι καταχωρίσεις

Το κλειδί /Count στη ρίζα της διάρθρωσης δεν σημαίνει αυτό που υποθέτουν οι περισσότεροι προγραμματιστές, και η εσφαλμένη υπόθεση παράγει αποτυχίες δοκιμών που μοιάζουν με καταστροφή δεδομένων. ISO 32000-1 §12.3.3 ορίζει το /Count ως το συνολικό αριθμό των ορατών απογόνων σε όλα τα επίπεδα, όχι τον αριθμό των καταχωρίσεων κορυφαίου επιπέδου. Σε μια μεμονωμένη καταχώριση το πρόσημο έχει επίσης σημασία: μια θετική τιμή σημαίνει ότι η καταχώριση είναι ανοιχτή και τόσοι απόγονοι εμφανίζονται στην πλευρική στήλη, ενώ μια αρνητική τιμή σημαίνει ότι η καταχώριση είναι συμπτυγμένη και |N| απόγονοι είναι κρυμμένοι μέσα της

Αυτό προέκυψε από τη σουίτα δοκιμών παλινδρόμησης (regression suite) του ίδιου του HotPDF κατά την κατασκευή των API σελιδοδεικτών για φορτωμένα έγγραφα. Ένα έγγραφο δοκιμής περιείχε τρεις σελιδοδείκτες κορυφαίου επιπέδου, εκ των οποίων ο ένας είχε ένα παιδί· μετά τη διαγραφή μιας καταχώρισης κορυφαίου επιπέδου, η GetLoadedOutlineCount, η οποία διαβάζει το /Count της ρίζας, ανέφερε 1 αντί για το αναμενόμενο 2. Τίποτα δεν είχε καταστραφεί: το αρχικό 3 δεν ήταν ποτέ "τρεις καταχωρίσεις κορυφαίου επιπέδου" εξαρχής, και ο σωστός επανυπολογισμός πρέπει να διατρέξει την αλυσίδα κορυφαίου επιπέδου συσσωρεύοντας κάθε κόμβο συν το θετικό του /Count, παραλείποντας τους απογόνους των συμπτυγμένων κόμβων. Το μάθημα για τον δικό σας κώδικα είναι άμεσο: μην κάνετε ποτέ assert σε διαφορές /Count μετά από δομικές επεξεργασίες, επειδή η τιμή κινείται μη γραμμικά με τους ορατούς απογόνους. Επαληθεύστε τη δομή βάσει περιεχομένου

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Πού ταιριάζει αυτό στο σύνολο εργαλείων φορτωμένων εγγράφων

Η δημιουργία σελιδοδεικτών και ονομαστικών προορισμών ολοκληρώνει μια εικόνα που το API φορτωμένων εγγράφων συμπληρώνει εδώ και καιρό: ο ίδιος κύκλος φόρτωσης-επεξεργασίας-αποθήκευσης ήδη ξαναγράφει μεταδεδομένα εγγράφων, προσθέτει σχόλια συνδέσμων και πραγματοποιεί αμφίδρομη μεταφορά σχολίων μέσω εισαγωγής και εξαγωγής XFDF. Το μοτίβο είναι ενιαίο: LoadFromFile, μια σειρά από κλήσεις *Loaded* έναντι του γραφήματος αντικειμένων στη μνήμη, και στη συνέχεια μία SaveLoadedDocument, γεγονός που καθιστά απλή την ενσωμάτωση της κατασκευής διάρθρωσης σε μια υπάρχουσα ροή συγχώνευσης ή σφράγισης ως ένα ακόμη βήμα πριν από την αποθήκευση

Τα API που συζητούνται εδώ, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination και GetLoadedBookmarkPageIndex, αποστέλλονται στο τυπικό HotPDF Component για Delphi και C++Builder, χωρίς εξωτερικές εξαρτήσεις και χωρίς να απαιτείται ξεχωριστό runtime προγράμματος προβολής