Η διαγραφή μιας σελίδας από ένα PDF δεν διαγράφει τις γραμματοσειρές, τις εικόνες ή τα content streams της. Η losLab PDF Library τα ανακτά με έναν συλλέκτη mark-sweep που διατρέχει το γράφημα αντικειμένων προς τα εμπρός ξεκινώντας από τις ρίζες του trailer και αφαιρεί κάθε έμμεσο αντικείμενο που δεν προσεγγίζεται από πουθενά. Εκτελείται σε πλήρη αποθήκευση (full save), είναι απενεργοποιημένος από προεπιλογή, και επιστρέφει τον αριθμό των αντικειμένων που αφαίρεσε
Γιατί η διαγραφή σελίδων PDF δεν μειώνει το μέγεθος του αρχείου;
Διότι η διαγραφή σελίδας είναι επεξεργασία αναφορών, όχι λειτουργία αποθήκευσης. Η DeletePages(StartPage, PageCount) αποσυνδέει τα αντικείμενα σελίδας από το page tree και επιδιορθώνει τις εγγραφές του outline που τα δείχνουν. Αυτό που δεν μπορεί να κάνει είναι να αποφασίσει ότι το πρόγραμμα γραμματοσειράς, το content stream και το image XObject που χρησιμοποιούσαν αυτές οι σελίδες είναι πλέον νεκρά, επειδή τη στιγμή της διαγραφής τίποτα στο αρχείο δεν καταγράφει ποιος άλλος μπορεί να τα δείχνει ακόμα. Αυτά τα αντικείμενα παραμένουν στη λίστα αντικειμένων του εγγράφου, και μια πλήρης αποθήκευση τα ξαναγράφει όλα. Το αποτέλεσμα είναι το παράπονο με το οποίο ξεκινούν τα περισσότερα από αυτά τα νήματα υποστήριξης: ένας πελάτης διαγράφει το ενενήντα τοις εκατό των σελίδων, αποθηκεύει, και το αρχείο μικραίνει κατά δύο τοις εκατό. Χειρότερα, η διαρροή αθροίζεται. Φόρτωση, διαγραφή, αποθήκευση, ξανά φόρτωση, ξανά διαγραφή, ξανά αποθήκευση, και το αρχείο μεγαλώνει μονότονα ενώ ο αριθμός σελίδων μειώνεται. Αυτό είναι διαφορετικό πρόβλημα από αυτό που λύνει το font subsetting και image downsampling, τα οποία σμικρύνουν ζωντανά αντικείμενα. Εδώ τα αντικείμενα δεν είναι υπερβολικά μεγάλα. Απλώς δεν αποτελούν πλέον μέρος του εγγράφου
Το root set είναι ο trailer, όχι το page tree
Το γράφημα αντικειμένων του PDF δεν διαθέτει πεδίο αντίστροφης αναφοράς. Η μορφή δεν ορίζει ούτε μετρητή αναφορών ούτε λίστα δεικτών προς τα πίσω, και τα κλειδιά /Parent που όντως υπάρχουν ανήκουν σε συγκεκριμένες δομές όπως το page tree, όχι στο γράφημα αντικειμένων ως σύνολο. Τίποτα σε ένα έμμεσο αντικείμενο δεν αποκαλύπτει ποιος το παραπέμπει, οπότε το ερώτημα «χρησιμοποιεί κανείς ακόμα το αντικείμενο 47» έχει ακριβώς μία απάντηση: διάτρεξε προς τα εμπρός από μια γνωστή ρίζα και δες αν φτάνεις εκεί. Για αυτόν τον λόγο ο συλλέκτης στην losLab PDF Library είναι συλλέκτης mark-sweep και όχι σχήμα refcount
Οι ρίζες προέρχονται από τον trailer του αρχείου (ISO 32000-1 §7.5.5). Τρία κλειδιά τις μεταφέρουν: το /Root, ο document catalog της §7.7.2 από τον οποίο κρέμονται το page tree, τα names, τα outlines, το AcroForm και τα μεταδεδομένα· το /Info, το dictionary πληροφοριών εγγράφου· και το /Encrypt, το dictionary κρυπτογράφησης. Τα δύο υπόλοιπα κλειδιά του trailer είναι παγίδες. Το /ID είναι πίνακας δύο byte strings, και το /Prev είναι ακέραιο byte offset προς το προηγούμενο τμήμα cross-reference. Κανένα από τα δύο δεν είναι έμμεση αναφορά, οπότε κανένα δεν συνεισφέρει ρίζα. Η losLab PDF Library βάζει σε ουρά ολόκληρο το trailer dictionary αντί για τρία επώνυμα κλειδιά, κάτι που δεν κοστίζει τίποτα και διατηρεί ζωντανή κάθε ιδιωτική επέκταση trailer
Η ίδια η διάτρεξη είναι επαναληπτική και όχι αναδρομική. Όταν η διάτρεξη συναντά μια έμμεση αναφορά καταγράφει μόνο τον αριθμό αντικειμένου και τη generation, σημαδεύει την αντίστοιχη θέση και την προωθεί σε ουρά FIFO αντί να την αποαναφέρει αμέσως, κάτι που κρατά τα βαθιά page trees και τις μακριές αλυσίδες outline εκτός call stack και εμποδίζει την αποκωδικοποίηση του ίδιου αντικειμένου δύο φορές. Τα άμεσα dictionaries, οι πίνακες και τα stream dictionaries πηγαίνουν σε δεύτερη ουρά που φυλάσσεται από ένα σύνολο επισκεφθέντων, επειδή τα πραγματικά έγγραφα περιέχουν γνήσιους κύκλους: το /Parent μιας σελίδας δείχνει πίσω στον κόμβο page tree της, και τα στοιχεία outline αλυσιδώνονται μέσω /Prev και /Next και προς τις δύο κατευθύνσεις. Οι αριθμοί generation αποτελούν μέρος της αντιστοίχισης, όχι διακόσμηση. Μια αναφορά επιλύεται μόνο όταν συμφωνούν και ο αριθμός αντικειμένου και η generation· μια αναφορά σε αριθμό που υπάρχει σε διαφορετική generation αντιμετωπίζεται ως το null object που απαιτεί η προδιαγραφή, ποτέ ως ζωντανή ακμή
Πώς ενεργοποιείται η συλλογή απορριμμάτων σε μια αποθήκευση;
Η συλλογή απορριμμάτων είναι opt-in και ανήκει στην εγγραφή επιλογών αποθήκευσης. Έχει προεπιλογή False επειδή ο συλλέκτης είναι μια καταστροφική διέλευση πάνω στο γράφημα αντικειμένων και καμία βιβλιοθήκη δεν πρέπει να διαγράφει σιωπηλά αντικείμενα που ο καλών δεν ζήτησε ποτέ να εξεταστούν
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
Δύο ακόμα σημεία εισόδου φτάνουν στον ίδιο συλλέκτη. Η SetGarbageCollect(1) θέτει τη σημαία στο επιλεγμένο έγγραφο ώστε μια απλή SaveToFile να την τηρεί, και η GarbageCollectObjects εκτελεί τη διέλευση αμέσως και επιστρέφει τον αριθμό των ορφανών έμμεσων αντικειμένων που αφαιρέθηκαν. Η άμεση μορφή χρησιμοποιείται όταν χρειάζεται ένας αριθμός για καταγραφή ή για assert, και αξίζει τον έλεγχο, επειδή μια αρνητική τιμή επιστροφής δεν αποτελεί μέτρημα
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
Αυτή η διαδρομή αποτυχίας έχει μεγαλύτερη σημασία από όσο φαίνεται. Τα αντικείμενα αποκωδικοποιούνται τεμπέλικα (lazily), και ένα αντικείμενο που δεν έχει αποκωδικοποιηθεί ποτέ δεν εκθέτει καμία αναφορά. Αν ο συλλέκτης αντιμετώπιζε ένα μη αποκωδικοποιήσιμο αντικείμενο ως κενό κόμβο, θα σάρωνε μακριά οτιδήποτε είναι προσβάσιμο μόνο μέσω αυτού. Για τον λόγο αυτό η διάτρεξη επιβάλλει αποκωδικοποίηση καθώς αγγίζει κάθε αντικείμενο, και ένα και μόνο σφάλμα αποκωδικοποίησης ματαιώνει ολόκληρη τη διέλευση με αρνητικό αποτέλεσμα και αφήνει το έγγραφο byte-προς-byte αμετάβλητο. Η σάρωση ενός γραφήματος που κατανοείται μόνο εν μέρει είναι ο τρόπος με τον οποίο ένας συλλέκτης μετατρέπει ένα κατεστραμμένο αρχείο σε καταστραμμένο
Τι χαλάει έναν αφελή συλλέκτη PDF;
Δύο λεπτομέρειες, και οι δύο αποτυγχάνουν σιωπηλά αντί για θορυβωδώς. Η πρώτη είναι τα object streams. Από το PDF 1.5 και μετά, ένα μη-stream αντικείμενο μπορεί να ζει συμπιεσμένο μέσα σε ένα container /ObjStm (§7.5.7), και η εγγραφή cross-reference του είναι εγγραφή τύπου 2 που ονομάζει το container συν έναν δείκτη μέσα σε αυτό. Ένα συμπιεσμένο αντικείμενο είναι επομένως προσβάσιμο μόνο μέσω του container του. Αν σημαδευτεί το μέλος αλλά σαρωθεί το container επειδή τίποτα δεν το είχε αναφέρει ως αντικείμενο εγγράφου, προκύπτει αρχείο του οποίου το xref δείχνει σε αντικείμενο που δεν υπάρχει πια. Το container είναι δομική αποθήκευση, όχι δεδομένα εγγράφου, οπότε δεν εμφανίζεται ποτέ ως ακμή στο γράφημα αντικειμένων που διατρέχεται. Η losLab PDF Library το χειρίζεται αποσυνδέοντας κάθε επιζών συμπιεσμένο μέλος από το container προέλευσής του πριν εξαφανιστούν τα containers, μετά την οποία η αποθήκευση ξαναπακετάρει τους επιζώντες σε νέα object streams. Η δεύτερη λεπτομέρεια είναι τι αναφέρει στην πραγματικότητα ένα αντικείμενο stream. Τα bytes δεν αποτελούν μέρος του γραφήματος. Ένα content stream που σχεδιάζει κείμενο με /F1 12 Tf ονομάζει μια γραμματοσειρά με resource name, και αυτό το όνομα επιλύεται μέσω του dictionary /Resources της σελίδας, οπότε η ακμή προσβασιμότητας τρέχει σελίδα → /Resources → /Font → αντικείμενο γραμματοσειράς, ποτέ μέσω του περιεχομένου του stream. Οι μόνες αναφορές που συνεισφέρει ένα stream προέρχονται από το dictionary του, όπου τα /Length, /Filter και /DecodeParms επιτρέπεται όλα να είναι έμμεσα. Ένας συλλέκτης που αναλύει τα bytes του stream αναζητώντας αναφορές κάνει ακριβή δουλειά για το τίποτα· ένας συλλέκτης που παραλείπει τα stream dictionaries χάνει το αντικείμενο μήκους και καταστρέφει το αρχείο
Τι συμβαίνει με τους αριθμούς αντικειμένων που ελευθερώνονται
Γίνονται ελεύθερες εγγραφές, και δεν επαναχρησιμοποιούνται στην ίδια αποθήκευση. Η σάρωση διατρέχει τη λίστα αντικειμένων σε φθίνουσα σειρά ώστε οι διαγραφές να παραμένουν σταθερές ως προς τον δείκτη, ξαναχτίζει τον δείκτη αναζήτησης μία φορά στο τέλος αντί μετά από κάθε αφαίρεση, και για κάθε αφαιρεθέν αντικείμενο καταγράφει τον αριθμό στη free list με τη generation του αυξημένη κατά ένα, ακριβώς όπως ορίζει η §7.5.4 για μια εγγραφή που μπορεί αργότερα να επαναχρησιμοποιηθεί. Μια generation που βρίσκεται ήδη στο 65535 παραμένει εκεί, σηματοδοτώντας τον αριθμό εκείνο ως μόνιμα αποσυρμένο. Οι αριθμοί αντικειμένων σκόπιμα δεν συμπυκνώνονται. Μετά από μια συλλογή το αρχείο διατηρεί κενά: το αντικείμενο 12 μπορεί να είναι ελεύθερο ενώ τα 13 και 14 βρίσκονται σε χρήση, και το /Size του trailer εξακολουθεί να αναφέρει τον υψηλότερο αριθμό συν ένα αντί για τον αριθμό των επιζώντων. Αυτό είναι νόμιμο και φυσιολογικό. Η επαναρίθμηση θα εξοικονομούσε μια χούφτα bytes στον πίνακα cross-reference και θα απαιτούσε την επανεγγραφή κάθε αναφοράς στο έγγραφο, κάτι που είναι το είδος αλλαγής που ακυρώνει σιωπηλά οτιδήποτε κρατά αριθμούς αντικειμένων από έξω. Το μέγεθος που προκύπτει προέρχεται από τα σώματα των αντικειμένων, όχι από τον πίνακα xref
Πότε δεν πρέπει να εκτελείται ο συλλέκτης
Ποτέ σε ένα incremental update. Ο συλλέκτης είναι κλειδωμένος στις πλήρεις αποθηκεύσεις και η σημαία απλώς δεν διαβάζεται όταν γίνεται προσάρτηση στο έγγραφο, και αυτό το κλείδωμα δεν είναι περιορισμός που πρέπει να παρακαμφθεί. Ένα incremental update (§7.5.6) αφήνει τα αρχικά bytes ανέγγιχτα και προσαρτά μια νέα ενότητα cross-reference αλυσοδεμένη στην προηγούμενη μέσω /Prev. Κάθε προγενέστερη αναθεώρηση εξακολουθεί να δείχνει στα αντικείμενα που πάντα έδειχνε, οπότε ένα αντικείμενο που είναι απρόσιτο στην τρέχουσα αναθεώρηση είναι απόλυτα προσβάσιμο σε μια παλαιότερη. Η διαγραφή του θα κατέστρεφε κάθε αναθεώρηση εκτός από την τελευταία, και η μηχανική του γιατί καλύπτεται στο άρθρο για τα incremental updates και τις αποθηκεύσεις append-mode. Η ίδια λογική αποκλείει τη συλλογή απορριμμάτων σε ένα υπογεγραμμένο έγγραφο, επειδή η πλήρης επανεγγραφή που καθιστά δυνατή τη συλλογή είναι η ίδια αυτή που ακυρώνει την υπογραφή
Αξίζει επίσης να ξεκαθαριστεί τι δεν είναι η συλλογή. Δεν είναι sanitizer. Ο συλλέκτης αφαιρεί αντικείμενα που δεν αναφέρει τίποτα· δεν έχει καμία άποψη για το αν το περιεχόμενό τους ήταν ευαίσθητο, και ένα αντικείμενο που εξακολουθεί να αναφέρεται παραμένει ό,τι ήταν. Αν ο στόχος είναι να γίνει η πληροφορία μη ανακτήσιμη αντί να μικρύνει το αρχείο, το γράφημα αντικειμένων είναι το λάθος επίπεδο και η redaction σε επίπεδο εντολών και ο καθαρισμός εγγράφου (sanitizing) είναι το σωστό. Τα δύο συνδυάζονται καλά με αυτή τη σειρά: πρώτα redaction και sanitise, μετά συλλογή, ώστε τα αντικείμενα που αποσύνδεσε η redaction να φύγουν πράγματι από το αρχείο. Ο ίδιος συνδυασμός υπάρχει και στο API καθαρισμού πόρων (resource purge), όπου η ενεργοποίηση της επιλογής garbage-collect κάνει τον purge να εκτελέσει στη συνέχεια μια συλλογή και να αναφέρει τα ορφανά που αφαίρεσε στο OrphanObjectsRemoved
Μια τελευταία συνήθεια που αξίζει να υιοθετηθεί. Καταγράψτε την τιμή επιστροφής της GarbageCollectObjects σε όποια batch εργασία εκτελεί τις διαγραφές σελίδων, και παρακολουθήστε την για μερικές εβδομάδες με πραγματικά έγγραφα. Ένα μηδέν σε ένα αρχείο που μόλις κόπηκε στη μέση σημαίνει ότι κάτι ανάντη εξακολουθεί να κρατά μια αναφορά που δεν αναμενόταν, συνήθως μια εγγραφή name tree, έναν προορισμό outline ή ένα πεδίο AcroForm που επέζησε της σελίδας στην οποία ήταν προσαρτημένο. Ο συλλέκτης είναι ο φθηνότερος debugger προσβασιμότητας που θα αποκτήσετε ποτέ, επειδή απαντά στο ερώτημα που η ίδια η μορφή PDF αρνείται να απαντήσει
Ο συλλέκτης απορριμμάτων, η εγγραφή επιλογών αποθήκευσης και το API καθαρισμού πόρων που περιγράφονται εδώ αποτελούν μέρος της losLab PDF Library για Delphi και C++Builder, της οποίας η σελίδα προϊόντος φέρει την πλήρη αναφορά του save-pipeline, συμπεριλαμβανομένης της αλληλεπίδρασης μεταξύ συλλογής, πακεταρίσματος object-stream και linearization