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

Διαγραφή σελίδων PDF στο Delphi χωρίς κρεμαστές αναφορές

Το HotPDF Delphi Component διαγράφει μια σελίδα από φορτωμένο PDF μέσω THotPDF.DeletePage, και από την έκδοση 2.751.0 εκείνη η κλήση κλαδεύει επίσης κάθε αναφορά επιπέδου εγγράφου που δείχνει ακόμα στη σελίδα: named destinations στο δέντρο /Names /Dests, το legacy dictionary /Dests του καταλόγου, ενέργειες bookmark /GoTo, structure elements κάτω από /StructTreeRoot, το ParentTree, εγγραφές OBJR για σχολιασμούς, και σχολιασμούς link σε σελίδες που επιβιώνουν. Το page tree ξαναχτίζεται τελευταίο, αφού τίποτα άλλο δεν μπορεί πια να φτάσει το διαγεγραμμένο object

Η αποτυχία που αυτό προλαμβάνει είναι εύκολη στην αναπαραγωγή και δύσκολη στη διάγνωση. Διάγραψε τη σελίδα εξωφύλλου μιας tagged αναφοράς, αποθήκευσε, και άνοιξε το αποτέλεσμα: το Acrobat δείχνει το σωστό πλήθος σελίδων, αλλά το bookmark «Contents» πλέον προσγειώνεται πουθενά, ο έλεγχος προσβασιμότητας αναφέρει structure element χωρίς σελίδα, και ένας αυστηρός validator απαριθμεί αναφορά σε ελεύθερο object. Τίποτα στο page tree δεν είναι λάθος. Το πρόβλημα είναι ότι μια σελίδα PDF δεν είναι μόνο φύλλο του /Pages· είναι στόχος στον οποίο δείχνει το μισό κατάλογο, και η αφαίρεση του φύλλου αφήνει καθεμία από εκείνες τις δείκτες κρεμαστές

Γιατί η αφαίρεση σελίδας από /Kids δεν αρκεί;

Επειδή το ISO 32000-1 αφήνει τουλάχιστον επτά ανεξάρτητες δομές να κρατούν αναφορά σε object σελίδας, και μόνο μία από όλες είναι το page tree. Το πέταγμα της σελίδας από /Kids και η μείωση του /Count ικανοποιούν το §7.7.3, και κάθε άλλη αναφορά γίνεται pointer σε object που είτε είναι ελεύθερο στο xref είτε απλώς λείπει από το ξαναγραμμένο αρχείο. Ένας viewer που ακολουθεί έναν από εκείνους τους pointers παίρνει null, και το τι κάνει με εκείνο το null είναι υπόθεση του viewer

  • Το name tree κάτω από /Names /Dests (§7.7.4, §12.3.2.3) αντιστοιχίζει ονόματα σε πίνακες προορισμού των οποίων το πρώτο στοιχείο είναι η σελίδα
  • Το προ-1.2 dictionary /Dests απευθείας στον κατάλογο κρατά τα ίδια είδη πινάκων με κλειδί το όνομα
  • Τα outline items (§12.3.3) φτάνουν σελίδα είτε μέσω inline /Dest είτε μέσω ενέργειας /A με /S /GoTo και πίνακα /D
  • Τα structure elements (§14.7.2) κουβαλάνουν κλειδί /Pg που κατονομάζει τη σελίδα στην οποία ζει το marked content τους, και τα παιδιά /K τους μπορεί να είναι marked-content references και object references (§14.7.4.3) δεμένα με εκείνη τη σελίδα
  • Το ParentTree (§14.7.4.4) αντιστοιχίζει αριθμούς /StructParents σελίδων και σχολιασμών πίσω σε structure elements, και ένα στοιχείο μπορεί να ζει εκεί χωρίς να εμφανίζεται καθόλου στην αλυσίδα /K από τη ρίζα
  • Σχολιασμοί link σε άλλες σελίδες (§12.5.6.5) κουβαλλάνε /Dest ή ενέργεια /GoTo που στοχεύει τη σελίδα, και το /OpenAction του καταλόγου μπορεί να κάνει το ίδιο
Γιατί η αφαίρεση σελίδας HotPDF από /Kids δεν αρκεί: το ISO 32000-1 αφήνει το name tree /Names /Dests, το legacy dictionary /Dests του καταλόγου, τα outline items, τα structure elements με /Pg, το ParentTree, τους σχολιασμούς link και το /OpenAction να κρατούν όλες αναφορά στο ίδιο object σελίδας, και μόνο το page tree ξαναχτίζεται
Μια σελίδα PDF είναι στόχος στον οποίο δείχνει το μισό κατάλογο: το πέταγμα του φύλλου ικανοποιεί το page tree ενώ κάθε άλλος pointer επιλύεται σε null, οπότε μια περικομμένη αναφορά χάνει το bookmark Contents και αποτυγχάνει τον έλεγχο προσβασιμότητάς της

Τι καθαρίζει το THotPDF.DeletePage πριν αγγίξει το page tree;

Το THotPDF.DeletePage(PageIndex) σε φορτωμένο έγγραφο τρέχει πρώτα όλο το σάρωμα αναφορών, μετά σημαίνει το object της σελίδας ως διαγεγραμμένο με DeleteObj, αποκολλάει τυχόν σχολιασμούς widget από το δέντρο πεδίων AcroForm, μετατοπίζει τον εσωτερικό πίνακα σελίδων, και τελικά καλεί RebuildLoadedPageTree για να ξαναγράψει /Kids, /Count, και το /Parent κάθε επιβιώσασας σελίδας. Το σάρωμα επισκέπτεται τον κατάλογο με σταθερή σειρά: το name tree /Names /Dests, το παλιομοδίτικο dictionary /Dests, το /OpenAction, το δέντρο outline, το /StructTreeRoot με το ParentTree του, και τελευταία τους πίνακες /Annots κάθε σελίδας που μένει. Κάθε βήμα αποφασίζει αν μια αναφορά αφαιρείται, ξαναστοχεύεται, ή μένει ήσυχη σύμφωνα με όσα επιτρέπει η προδιαγραφή σε εκείνη τη δομή χωρίς τη σελίδα. Δύο guards ισχύουν πριν τρέξει οτιδήποτε: το DeletePage πετάει Invalid page number για δείκτη εκτός εύρους και αρνείται να αφαιρέσει την τελευταία σελίδα, επειδή κόμβος /Pages με μηδέν παιδιά δεν είναι έγκυρο PDF, ενώ το DeletePages παίρνει την ίδια σημειογραφία one-based "1,3-5,7-" με τις άλλες λειτουργίες σελίδων φορτωμένου εγγράφου και επαναλαμβάνει από τον υψηλότερο επιλεγμένο δείκτη προς τα κάτω ώστε οι δείκτες που έγραψες να μένουν έγκυροι όσο δουλεύει

Το σταθερό σάρωμα αναφορών που τρέχει το THotPDF.DeletePage πριν αγγιχτεί το page tree: guards απορρίπτουν δείκτη εκτός εύρους ή την τελευταία σελίδα, μετά τα /Names /Dests και το legacy /Dests κλαδεύονται, το /OpenAction πετιέται, τα outlines ξαναστοχεύονται σε NearestRetainedPage, τα StructTreeRoot και ParentTree κλαδεύονται, οι links διατηρημένων σελίδων αφαιρούνται, και το RebuildLoadedPageTree τρέχει τελευταίο
Κάθε δομή παίρνει τη μεταχείριση που επιτρέπει η προδιαγραφή: τα ονόματα εξαφανίζονται, τα bookmarks προσγειώνονται στην πλησιέστερη διατηρημένη σελίδα, τα structure elements χάνουν το /Pg ή εξαφανίζονται, και η επανεγγραφή /Kids γίνεται μόνο αφού τίποτα άλλο δεν μπορεί πια να φτάσει το διαγεγραμμένο object
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Zero-based: πέτα τη σελίδα εξωφύλλου. Named destinations,
      // bookmarks, structure tree, ParentTree και link
      // annotations που έδειχναν σε αυτήν κλαδεύονται πριν
      // ξαναχτιστεί το δέντρο /Pages.
      Pdf.DeletePage(0);
      // Σύνταξη ranges one-based για δέσμες, ο υψηλότερος δείκτης πρώτα
      // εσωτερικά ώστε οι προηγούμενοι δείκτες να μένουν έγκυροι.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Πώς μεταχειρίζονται διαφορετικά τα named destinations και τα bookmarks;

Τα named destinations αφαιρούνται και τα bookmarks ξαναστοχεύονται, επειδή ένα όνομα που δεν υπάρχει πια είναι αποδεκτό αποτέλεσμα ενώ bookmark χωρίς προορισμό είναι ορατό ελάττωμα. Στο δέντρο /Names /Dests το HotPDF περπατάει κάθε κόμβο, ελέγχει κάθε προορισμό, και στη γυμνή μορφή πίνακα και στη μορφή dictionary με κλειδί /D, απέναντι στη διαγεγραμμένη σελίδα, και αφαιρεί το ζεύγος όνομα/τιμή όταν το πρώτο στοιχείο του πίνακα είναι εκείνη η σελίδα. Ένας κόμβος του οποίου /Names και /Kids καταλήγουν και οι δύο άδειοι σημαίνεται διαγεγραμμένος και αποσυνδέεται από τον γονέα του, ώστε το δέντρο να μην κρατά ποτέ κοίλα φύλλα. Το ίδιο test τρέχει πάνω στο παλιομοδίτικο dictionary /Dests του καταλόγου, και το /OpenAction του καταλόγου απλώς πετιέται αν άνοιγε στη διαγεγραμμένη σελίδα. Ένα όριο εδώ: όταν ένας κόμβος name-tree χάνει εγγραφές, το HotPDF διαγράφει το ζεύγος /Limits εκείνου του κόμβου αντί να επανυπολογίσει τα νέα χαμηλότερα και υψηλότερα κλειδιά, και ενώ οι viewers επιλύουν ονόματα μια χαρά χωρίς αυτό, ένας αυστηρός έλεγχος συμμόρφωσης που διαβάζει το ISO 32000-1 §7.9.6 μπορεί να σημαδέψει μη-ριζικό κόμβο που λείπει το /Limits

Τα outline items πάνε τον αντίθετο δρόμο. Το RetargetOutlineDestinations διασχίζει /First και /Next από τη ρίζα outline, με λίστα επισκέψεων και όριο βάθους 128 ώστε ένα κατεστραμμένο κυκλικό δέντρο να μην κρεμάσει την κλήση, και για κάθε πίνακα /Dest ή πίνακα /D ενέργειας /GoTo που στέκεται στη σελίδα αντικαθιστά το πρώτο στοιχείο με NearestRetainedPage: τη σελίδα που ακολουθούσε τη διαγεγραμμένη, ή τη σελίδα πριν από αυτήν όταν η διαγεγραμμένη ήταν τελευταία. Οι παράμετροι προβολής μετά την αναφορά σελίδας μένουν ως ήταν. Ένα bookmark που στόχευε διαγεγραμμένο άνοιγμα κεφαλαίου προσγειώνεται λοιπόν στην πρώτη σελίδα όσων απομένουν αντί να εξαφανιστεί από την πλευρική μπάρα, που είναι η συμπεριφορά που περιμένουν οι reviewers από περικομμένο έγγραφο. Το test προορισμού ταιριάζει μόνο ρητούς πίνακες, πάντως: outline item του οποίου το /Dest είναι string ονόματος που επέλυε στη διαγεγραμμένη σελίδα δεν ξαναστοχεύεται, επειδή η εγγραφή name-tree έχει φύγει και η αναφορά πλέον επέρχεται σε τίποτα και όχι σε ελεύθερο object, οπότε ο viewer το μεταχειρίζεται ως νεκρό bookmark. Η μηχανική του ίδιου του δέντρου outline, τα /First, /Next, και η μη προφανής σημασιολογία του /Count, καλύπτονται στον οδηγό για την προσθήκη bookmarks και named destinations σε φορτωμένο PDF

// Επαλήθευσε το σάρωμα αντί να το εμπιστευτείς.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Bookmark που στόχευε το εξώφυλλο τώρα επιλύεται στη
// σελίδα που ακολουθούσε (δείκτης zero-based 0 μετά τη διαγραφή).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Τι γίνεται με το structure tree και το ParentTree;

Structure elements που υπάρχουν μόνο εξαιτίας της διαγεγραμμένης σελίδας αφαιρούνται, και στοιχεία που απλώνονται σε πολλές σελίδες χάνουν το κλειδί /Pg τους αλλά κρατούν τα παιδιά τους. Το PruneStructureElement κατεβαίνει την αλυσίδα /K από το /StructTreeRoot σε βάθος 128, χειριζόμενο και τη μορφή πίνακα και τη μορφή μοναδικού dictionary του /K που επιτρέπει το §14.7.2. Για κάθε στοιχείο πρώτα κλαδεύει τα παιδιά, και μετά αξιολογεί το ίδιο το στοιχείο: αν το κλάδεμα άδειασέ το /K του, το στοιχείο σημαίνεται διαγεγραμμένο και ο γονέας του το πετάει. Αν το δικό του /Pg κατονομάζει τη διαγεγραμμένη σελίδα και το στοιχείο έχει ακόμα παιδιά συν γονέα /P, αφαιρείται μόνο το /Pg, επειδή ένα /Pg σε στοιχείο είναι η προεπιλεγμένη σελίδα για τα παιδιά marked-content του και εκείνα τα παιδιά μπορεί να αναφέρονται ρητά σε άλλες σελίδες. Μόνο στοιχείο του οποίου το /Pg είναι η διαγεγραμμένη σελίδα και που δεν έχει τίποτα άλλο κάτω του αφαιρείται ευθέως

Το ParentTree παίρνει την ίδια μεταχείριση, και ο λόγος είναι αυτό που δάγκωσε κατά την ανάπτυξη: ένα structure element μπορεί να είναι προσβάσιμο από το ParentTree και πουθενά αλλού. Το number tree αντιστοιχίζει integers /StructParents είτε σε ένα μοναδικό στοιχείο είτε σε πίνακα στοιχείων, και το PruneParentTreeNode τρέχει PruneStructureElement πάνω σε κάθε τιμή που βρίσκει, αφαιρεί τιμές που κλαδεύτηκαν μακριά, διαγράφει ζεύγος /Nums όταν ο πίνακας τιμών του είναι άδειος, και αποσυνδέει κόμβο του οποίου /Nums και /Kids έχουν φύγει και οι δύο. Το κλάδεμα μόνο των απογόνων του /K θα είχε αφήσει εκείνα τα ορφανά στοιχεία να δείχνουν σε ελεύθερη σελίδα μέσω /Pg και σε ελεύθερες marked-content references μέσω των παιδιών /MCR τους. Αν εξάγεις κείμενο με σειρά δομής, αυτό μετράει ευθέως: η εξαγωγή κειμένου με σειρά δομής περπατάει ακριβώς αυτά τα δέντρα, και ένα στοιχείο με null /Pg είναι μια παράγραφος που πέφτει σιωπηλά έξω από τη σειρά ανάγνωσης

Ποιοι σχολιασμοί link σε διασωθείσες σελίδες αφαιρούνται;

Οποιοσδήποτε σχολιασμός link σε διατηρημένη σελίδα του οποίου ο πίνακας /Dest ή ενέργεια /GoTo δείχνει στη διαγεγραμμένη σελίδα αφαιρείται μαζί με την ιδιοκτησία του στο structure tree. Το RemoveRetainedPageDestinationAnnotations περπατάει τον πίνακα /Annots κάθε σελίδας εκτός του στόχου, εφαρμόζει το ίδιο test προορισμού που χρησιμοποιείται για outlines, σημαίνει ταιριάζοντα σχολιασμό διαγεγραμμένο, τον πετάει από τον πίνακα, και μετά καλεί PruneAnnotationReferencesInStructureTree ώστε το dictionary OBJR του οποίου το /Obj κατονομαζε εκείνον τον σχολιασμό να αφαιρεθεί από το structure element του, με το ίδιο το στοιχείο αφαιρεμένο αν το OBJR ήταν το μοναδικό του παιδί. Το να μείνει το OBJR στη θέση του θα παραβίαζε το §14.7.4.3, που απαιτεί το /Obj να αναφέρεται σε υπάρχον object, και θα φαινόταν σε έλεγχο PDF/UA ως tagged link χωρίς σχολιασμό πίσω του. Πρόσεξε την ασυμμετρία με τα bookmarks: τα links αφαιρούνται, δεν ξαναστοχεύονται. Μια παραπομπή στο κυρίως κείμενο που έλεγε «δες τη σελίδα 3» είναι λάθος μόλις η σελίδα 3 φύγει, και το να τη δείξεις στη σελίδα 4 θα ήταν ψέματα με τρόπο που δεν είναι ένα bookmark που προσγειώνεται στο πλησιέστερο κεφάλαιο, οπότε αν η ροή εργασίας σου θέλει εκείνα τα links διατηρημένα, ξαναστόχευσέ τα εσύ πριν καλέσεις DeletePage

Γιατί ένας αφαιρεθείς /MCR ή /OBJR δεν πρέπει ποτέ να καταχωρηθεί ως ελεύθερος;

Επειδή οι marked-content references και οι object references είναι συνήθως direct dictionaries μέσα στον πίνακα /K του γονικού στοιχείου τους, και το registry των incremental changes επιλύει direct object στον πλησιέστερο indirect object που τον περιέχει. Όταν το RemoveArrayItem πετάει παιδί από πίνακα /K απελευθερώνει το in-memory object μόνο αν ήταν THPDFLink ή μη-indirect τιμή, και το MarkRemovedObject καταχωρεί object στη λίστα ελεύθερων μόνο όταν ο αριθμός object του είναι μεγαλύτερος από μηδέν. Η πρώτη έκδοση αυτού του σαρώματος δεν έκανε εκείνη τη διάκριση, και το αποτέλεσμα σε incremental save ήταν ακριβώς ό,τι σχεδιάστηκε να κάνει το registry: το RegisterIncrementalChange περπατούσε από το direct /MCR μέχρι τη ρίζα της graph συναλλαγής του, που ήταν το διατηρημένο structure element που το κατείχε, και ξαναγράφε εκείνο το στοιχείο ως null. Ένα έγγραφο που έχασε μία σελίδα γύριζε με tagged περιεχόμενο στις άλλες σελίδες σιωπηλά untagged. Η μοναδική σωστή κίνηση για direct παιδί είναι να σημάνει το container του βρώμικο μέσω TouchContainer ώστε το container να ξαναγραφτεί, και να αφήσει ήσυχη τη λίστα ελεύθερων

Γιατί ένας αφαιρεθείς παιδί /MCR ή OBJR δεν πρέπει ποτέ να καταχωρηθεί ως ελεύθερος στο HotPDF: το registry των incremental changes επιλύει direct dictionary στον πλησιέστερο indirect container, οπότε η πρώτη έκδοση ξαναγράφε το διατηρημένο structure element ως null και σιωπηλά untagged τις επιβιώσασες σελίδες, ενώ το TouchContainer πλέον ξαναγράφει το container και αφήνει ήσυχη τη λίστα ελεύθερων
Η απελευθέρωση του in-memory παιδιού κρατιέται για τιμές THPDFLink ή μη-indirect και για αριθμούς objects μεγαλύτερους από μηδέν, οπότε ένα incremental save προσθέτει μόνο τα αγγειούμενα containers και το απελευθερωμένο object σελίδας
// Incremental update: μόνο τα αγγεία που αγγίχτηκαν και το
// απελευθερωμένο object σελίδας μπαίνουν στο προστιθέμενο τμήμα.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Διατηρημένα structure elements των οποίων το /K έχασε άμεσο /MCR
  // ξαναγράφονται στη θέση τους, ποτέ γραμμένα ως null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Η ίδια προσοχή διαμορφώνει ό,τι το DeletePage δεν απελευθερώνει σκόπιμα σε φορτωμένο έγγραφο. Content streams, XObjects, και οι μη-widget σχολιασμοί της διαγεγραμμένης σελίδας μένουν ως objects, επειδή ένα φορτωμένο αρχείο μπορεί να μοιράζεται οποιοδήποτε από αυτά με σελίδα που μένει και δεν υπάρχει φτηνός τρόπος να αποδειχτεί το αντίθετο τη στιγμή της διαγραφής. Η αφαίρεση της αναφοράς page tree αρκεί για ορθότητα· τα bytes που εκείνα τα objects καταλαμβάνουν ακόμα είναι ξεχωριστή ερώτηση, και το γράφος εξάρτησης objects και η ανάλυση retained bytes είναι το εργαλείο για να μετρήσεις τι κουβαλάει ακόμα ένα περικομμένο έγγραφο

DeletePage ή DeleteLoadedPage: ποιο πρέπει να καλείς;

Κάλεσε DeletePage για κάθε αφαίρεση σελίδας που κοιτάει χρήστης, και κράτησε το DeleteLoadedPage για την περίπτωση που όλο το έγγραφο αναδιατάσσεται και καμία αναφορά επιπέδου εγγράφου δεν αξίζει να διατηρηθεί. Το THotPDF.DeleteLoadedPage(PageIndex), προστέθηκε στην έκδοση 2.508.0, είναι η ελαφριά παραλλαγή: μετατοπίζει τον εσωτερικό πίνακα σελίδων, καλεί RebuildLoadedKidsArray για να ξαναγράψει /Kids και /Count, ακυρώνει το rendered-page cache, και πυροδοτεί OnLoadedDocumentModified. Δεν περπατάει το name tree, τα outlines, το structure tree, ή τους σχολιασμούς άλλων σελίδων, και δεν σημαίνει το object της σελίδας διαγεγραμμένο. Είναι το σωστό εργαλείο μέσα σε N-up imposition, όπου το HotPDF προσθέτει φρέσκα συντεθειμένα φύλλα και μετά πετάει κάθε αρχική σελίδα με DeleteLoadedPage(0): οι σελίδες πηγής αντικαθίστανται χοντρικά, και το περιεχόμενο του φύλλου αναφέρεται στους πόρους τους και όχι στα objects σελίδων. Για τη συνηθισμένη δουλειά «αφαίρεσε τη σελίδα 7 από αυτό το συμβόλαιο», το DeletePage είναι η μοναδική κλήση που αφήνει tagged, bookmarked, cross-linked έγγραφο αρκετά συνεπές για να περάσει validator, τόσο σε πλήρη επανεγγραφή μέσω SaveLoadedDocument όσο και σε incremental update μέσω SaveIncrementalUpdate. Και οι δύο μέθοδοι κυκλοφορούν στο HotPDF Delphi Component για Delphi και C++Builder, χωρίς εξωτερικό runtime viewer ή εξάρτηση