Όταν το HotPDF Delphi Component φορτώνει αρχείο PDF 1.5 με LoadFromFile, δεν αναλύει τα objects πακεταρισμένα μέσα σε containers /Type /ObjStm. Καταγράφει πού ζει κάθε συμπιεσμένο μέλος και το αναλύει μόνο όταν κάτι το ζητήσει. Εκείνο το lazy αναλλοίωτο είναι που κρατά τον χρόνο φόρτωσης ανάλογο του τι αγγίζεις πραγματικά, και είναι επίσης ο λόγος που μια πλήρης επανεγγραφή πρέπει να κάνει μία επιπλέον δουλειά πριν φύγουν οποιαδήποτε bytes: να αναπτύξει κάθε μέλος που είναι ακόμα μη αναλυμένο, επειδή η επανεγγραφή πρόκειται να πετάξει τα containers μέσα στα οποία ζουν εκείνα τα μέλη
Το σύμπτωμα που κίνησε αυτή τη σημείωση περιγράφεται εύκολα και αποσφαλματώνεται δύσκολα. Φόρτωσε αρχείο του οποίου τα fonts, τα color spaces και το structure tree κάθονται σε object streams, πέρνασέ το μέσα από το ζεύγος δημιουργίας BeginDoc και EndDoc, και η έξοδος ανοίγει χωρίς παράπονο. Το πλήθος σελίδων είναι σωστό, το κείμενο είναι ορατό στις σελίδες που τσεκάρεις στα γρήγορα. Μετά ένας συνάδελφος ανοίγει τη σελίδα 40 και το κείμενο σώματος αποδίδεται σε υποκατεστημένο font, ή η εντολή Extract Text γυρίζει σκουπίδια εκεί όπου υπήρχε αντικατάσταση ActualText. Τίποτα δεν κατέρρευσε. Ο writer απλώς σειριοποίησε object που δεν φορτώθηκε ποτέ, και ένα μη φορτωμένο object σειριοποιείται ως τίποτα
Τι κρατά πραγματικά το LoadFromFile για συμπιεσμένο object;
Για κάθε εγγραφή cross-reference τύπου 2, το LoadFromFile κρατά μικρό record στο FCompactObjects: τον αριθμό object, τον δείκτη του περιέχοντος stream στον πίνακα containers, τη θέση του μέλους μέσα σε εκείνο το stream, και pointer ParsedObject που ξεκινά nil. Το container το ίδιο εντοπίζεται, αποκρυπτογραφείται αν το έγγραφο είναι κρυπτογραφημένο, και ξεδιπλώνεται, αλλά τα σώματα των μελών μένουν ως bytes. Το ISO 32000-1 §7.5.7 ορίζει τη διάταξη container που το κάνει αυτό δυνατό: μια επικεφαλίδα ζευγών αριθμού-object και offset, και μετά τα σώματα των μελών συνενωμένα μετά το /First, ώστε οποιοδήποτε μοναδικό μέλος να μπορεί να κοπεί χωρίς να αγγίξεις τους γείτονές του
Το EnsureCompressedObjectLoaded είναι η μοναδική διαδρομή που μετατρέπει record σε object. Βρίσκει το record με τον αριθμό object, και αν το ParsedObject είναι ήδη ορισμένο επιστρέφει εκείνο το cached object και μετρά hit cache. Αλλιώς ξαναφορτώνει το container αν αποβλήθηκε, υπολογίζει το byte εύρος του μέλους από τον πίνακα offsets, δίνει στον parser zero-copy όψη εκείνου του τμήματος, και αποθηκεύει το αποτέλεσμα πίσω στο record. Από εκεί και πέρα το object είναι indirect, κουβαλάει τον πραγματικό αριθμό object του, και είναι καταχωρημένος στον δείκτη objects του εγγράφου όπως κάθε object που αναλύθηκε από το σώμα του αρχείου. Ο κατάλογος, το info dictionary, η ρίζα του page tree, και τα objects σελίδων περνάνε από αυτή τη διαδρομή τη στιγμή του φορτώματος επειδή η πλοήγηση τα χρειάζεται. Fonts, color spaces, dictionaries ExtGState, και structure elements όχι, και μένουν ως records μέχρι μια απόδοση σελίδας ή μια επανεγγραφή να τα αγγίξει
Μπορείς να το παρατηρήσεις από έξω. Το GetLoadedObjectStreamCacheInfo αναφέρει πόσα containers υπάρχουν, πόσα μέλη δεικτοδοτήθηκαν, και πόσα από αυτά έχουν αναλυθεί μέχρι στιγμής:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
Σε αρχείο πλούσιο σε δομές ο τρίτος αριθμός είναι μικρό κλάσμα του δεύτερου αμέσως μετά το φόρτωμα. Εκείνο το χάσμα είναι όλο το νόημα του lazy loading, και είναι επίσης ακριβώς το σύνολο objects για το οποίο πρέπει να γυρίσει πίσω μια πλήρης επανεγγραφή
Γιατί μια πλήρης επανεγγραφή πετάει fonts που κρατά ένα incremental save;
Μια πλήρης επανεγγραφή πετάει τα containers /ObjStm και /XRef του αρχείου πηγής και ξανασειριοποιεί τον γράφο objects από το μηδέν, οπότε κάθε μέλος του οποίου το ParsedObject είναι ακόμα nil δεν έχει καμία αναπαράσταση να αφήσει στην έξοδο. Ένα incremental update δεν έχει ποτέ αυτό το πρόβλημα, επειδή προσθέτει νέα objects μετά τα πρωτότυπα bytes και αφήνει τα παλιά containers στη θέση τους για να τα διευθύνει η προηγούμενη ενότητα cross-reference. Η διαφορά δεν είναι στο πώς οι δύο λειτουργίες μεταχειρίζονται fonts. Είναι στο αν τα πρωτότυπα containers επιβιώνουν για να τα διαβάσει ο επόμενος viewer
Το fix κατοικεί στο SaveToStream, τον serializer που οδηγεί το EndDoc είτε όρισες FileName είτε OutputStream. Πριν δρομολογήσει σε οποιονδήποτε branch writer, περπατάει το FCompactObjects και καλεί EnsureCompressedObjectLoaded σε κάθε εγγραφή. Αν ένα μέλος δεν μπορεί να φορτώσει, το save πετάει αντί να συνεχίσει, επειδή μια επανεγγραφή που πετάει σιωπηλά dictionary font είναι χειρότερη από μία που σταματά. Η ανάπτυξη πρέπει να κάθεται σε εκείνο το επίπεδο, πάνω από τους branches classic, packed και linearized, και πάνω από το κλάδεμα των reloaded structural streams της linearized διαδρομής. Μια προηγούμενη έκδοση ανέπτυσσε μέλη μόνο μέσα στο SaveLoadedDocument, που κάλυπτε το λεξιλόγιο του φορτωμένου εγγράφου και έχασε εντελώς το λεξιλόγιο της δημιουργίας. LoadFromFile ακολουθούμενο από BeginDoc, επεξεργασίες σελίδων, και EndDoc πήγαινε ευθεία στον writer με κάθε ανέγγιχτο μέλος ακόμα μη αναλυμένο
// Και τα δύο λεξιλόγια επανεγγραφής αναπτύσσουν πλέον compact μέλη πριν τρέξει οποιοσδήποτε writer.
// Διαδρομή φορτωμένου εγγράφου:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Διαδρομή δημιουργίας πάνω σε φορτωμένο αρχείο:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // Το SaveToStream υλοποιεί πρώτα κάθε εγγραφή FCompactObjects
Τα cached μέλη κρατούν ό,τι τους έκανες. Ένα object που αναλύθηκε, επεξεργάστηκε, και σημάθηκε βρώμικο πριν το save γυρίζει από την cache με τις επεξεργασίες του, και ένα μέλος που διέγραψες κρατά την κατάσταση διαγραφής του σε επαναλαμβανόμενα saves. Το πέρασμα ανάπτυξης είναι ιδανικά επαναλαμβανόμενο εκ κατασκευής: γεμίζει μόνο θέσεις nil
Γιατί οι pixel έλεγχοι σε τρεις σελίδες χάνουν την περίπτωση ActualText
Τα structure elements είναι όπου αυτό το bug κρύβεται περισσότερο. Μια εγγραφή ActualText σε αλληλουχία marked-content, ορισμένη στο ISO 32000-1 §14.9.4, αντικαθιστά τα glyphs για εξαγωγή και προσβασιμότητα αλλά δεν επηρεάζει την απόδοση. Αν το structure element ζει σε object stream και η επανεγγραφή το χάνει, η σελίδα ακόμα σχεδιάζεται σωστά, η πρώτη, η μέση και η τελευταία σελίδα συγκρίνονται pixel προς pixel με την πηγή, και το regression φαίνεται μόνο όταν κάποιος τρέχει εξαγωγή κειμένου ή screen reader. Ένα test επανεγγραφής που μόνο αποδίδει σελίδες δεν είναι test επανεγγραφής για tagged PDF. Κάνε diff και στο εξαγμένο κείμενο και στο structure tree
Πώς αλλάζει ο κενός κωδικός χρήστη το φόρτωμα;
Κενός κωδικός χρήστη εξακολουθεί να σημαίνει ότι το αρχείο είναι κρυπτογραφημένο, και τα object streams σε τέτοιο αρχείο είναι ciphertext μέχρι να ανακτηθεί το κλειδί αρχείου. Το ISO 32000-1 §7.6.3.4 Αλγόριθμος 2 παράγει εκείνο το κλειδί από τον κωδικό, την εγγραφή /O, το /P, και το πρώτο document identifier, και το HotPDF πρέπει να το τρέξει πάνω στο κενό string πριν το πέρασμα τύπου 2 μπορέσει να ξεδιπλώσει έστω ένα container. Γι' αυτό το BeginDoc σε φορτωμένο κρυπτογραφημένο έγγραφο καλεί DecryptLoadedDocument με κενό κωδικό πριν από οτιδήποτε άλλο: ο γράφος objects πρέπει να πιστοποιηθεί και να αποκρυπτογραφηθεί πριν αρχίσει επανεγγραφή, ανεξαρτήτως αν ο caller προτίθεται να προστατεύσει την έξοδο. Η κρυπτογράφηση εξόδου είναι ξεχωριστή απόφαση, καθοδηγούμενη από τις ρυθμίσεις προστασίας του caller, και το BeginDoc επαναφέρει εκείνες τις ρυθμίσεις μετά το πέρασμα αποκρυπτογράφησης ώστε κρυπτογραφημένη είσοδος να μην γίνεται σιωπηλά κρυπτογραφημένη έξοδος
Η πολιτική containers διαβάζεται από το dictionary /Encrypt πριν δοκιμαστεί οποιοσδήποτε κωδικός. Για /V 1 και 2 κάθε stream κρυπτογραφείται με το κλειδί αρχείου. Για crypt filters, το HotPDF επιλύει το /StmF μέσω /CF: φίλτρο Identity ή /CFM του None σημαίνει plaintext containers, ενώ V2 και AESV2 σημαίνουν κρυπτογραφημένα. Η απάντηση καταλήγει στο FReloadObjectStreamsEncrypted, και μετράει για μία συγκεκριμένη περίπτωση. Όταν τα containers είναι plaintext αλλά τα strings όχι, τα μέλη κουβαλλάνε κρυπτογραφημένα strings που πρέπει να αποκρυπτογραφούνται ατομικά, οπότε το MaterializeMembersOfPlaintextObjectStreams αναπτύσσει κάθε compact μέλος μπροστά από το πέρασμα αποκρυπτογράφησης ανά object. Δεν κάνει τίποτα όταν η πολιτική δεν είναι ακόμα γνωστή και τίποτα όταν τα ίδια τα containers ήταν κρυπτογραφημένα, επειδή τα μέλη κρυπτογραφημένου container είχαν ήδη αποκρυπτογραφηθεί μαζί του και δεν πρέπει ποτέ να αποκρυπτογραφηθούν δύο φορές
Τι γίνεται όταν ένα container δεν μπορεί να αποκρυπτογραφηθεί;
Container που αποτυγχάνει στην αποκρυπτογράφηση μπαίνει σε καραντίνα, δεν είναι μοιραίο. Το πέρασμα τύπου 2 καταγράφει εγγραφή THPDFObjStmQuarantineInfo στο FObjStmQuarantine με τον αριθμό object του container, ένα THPDFObjStmQuarantineReason, string διαγνωστικό, και τη λίστα των αριθμών objects μελών που το cross-reference είχε δρομολογήσει μέσα του. Το osqrDecryptFailed σημειώνεται για τέσσερις διακριτές καταστάσεις: κανένα crypt filter δεν μπορούσε να επιλυθεί, η αποκρυπτογράφηση AES-256 ή AES-GCM πέταξε, η legacy αποκρυπτογράφηση RC4 ή AES-128 πέταξε, ή δεν υπάρχει καθόλου χρησιμοποιήσιμο κλειδί αρχείου. Ανεξάρτητα containers συνεχίζουν να φορτώνουν, οπότε ένα έγγραφο με ένα κατεστραμμένο container ανοίγει ακόμα και αποδίδει ακόμα κάθε σελίδα που δεν εξαρτάται από αυτό
Η λίστα καραντίνας επιβιώνει του parser fallback. Αν το πρωτεύον φόρτωμα cross-reference αποτύχει και το HotPDF ανακατασκευάσει τον πίνακα objects σαρώνοντας το αρχείο, η σημαία κρυπτογράφησης από την πρώτη προσπάθεια μπορεί να μην επιβιώσει εκείνης της ανακατασκευής, αλλά οι εγγραφές καραντίνας επιβιώνουν. Γι' αυτό το BeginDoc ελέγχει τη λίστα καραντίνας και όχι τη σημαία κρυπτογράφησης: σε φορτωμένο έγγραφο περπατάει το FObjStmQuarantine και πετάει στην πρώτη εγγραφή osqrDecryptFailed, κατονομάζοντας το container και ζητώντας reload με έγκυρο κωδικό. Μια επανεγγραφή που θα προχωρούσε πέρα από εκείνο το σημείο θα έγραφε τα μέλη που το container έπρεπε να κρατά ως άδεια objects και θα ανέφερε επιτυχία. Μπορείς να τρέξεις τον ίδιο έλεγχο μόνο σου, νωρίτερα και με δική σου πολιτική, μέσω των δημόσιων accessors:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // κενός κωδικός χρήστη
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// ασφαλές να ξαναγράψεις από εδώ
end;
Οι υπόλοιποι λόγοι καραντίνας καλύπτουν τις μη κρυπτογραφικές αποτυχίες: container που δεν είναι stream, απόν dictionary, άκυρο /N ή /First, μέγεθος stream εκτός αποδεκτού εύρους, αποτυχία αποσυμπίεσης, /First που δείχνει πέρα από τα δεδομένα, ή σώμα μέλους που αποκωδικοποιήθηκε αλλά δεν αναλύθηκε. Αξίζουν logging κατά την πρόσληψη, αφού το καθένα κατονομάζει τα ακριβή μέλη που θα σου λείπουν κατάντη
Γιατί μια επανεγγραφή θέλει το αρχικό numeric token;
Το HotPDF αποθηκεύει κάθε numeric object ως Single, και ένα Single δεν μπορεί να αναπαραγάγει το κείμενο πηγής ενός πραγματικού αριθμού. Το ISO 32000-1 §7.3.3 αφήνει έναν writer να εκπέμψει 0.750000, .75, ή 0.75 για την ίδια τιμή, και κανένα από αυτά δεν επιβιώνει round trip μέσω δυαδικών 24-bit και γενικού formatter αναλλοίωτο. Χειρότερα, μια τιμή όπως 0.7 δεν είναι καθόλου αναπαραστάσιμη σε Single· αναλύεται στον πλησιέστερο float, και η αναδιαμόρφωση εκείνου του float μπορεί να παράγει 0.69999999 ή στρογγυλεμένο γείτονα ανάλογα με τον βρόχο ψηφίων. Σε χρώμα γεμίσματος ή σταθερά διαφάνειας /CA, αυτό είναι διαφορά ενός μετρητή σε κανάλι 8-bit, που φτάνει για να αποτύχει σύγκριση pixel με την πηγή και, σε όρια gradient, φτάνει για να το δεις
Το THPDFNumericObject.RememberSourceToken το λύνει αυτό για την ανέγγιχτη περίπτωση. Ο parser το καλεί με το ακατέργαστο token αμέσως μετά την ανάθεση του Value· η μέθοδος δέχεται μόνο tokens από ψηφία, το πολύ μία δεκαδική τελεία, και προαιρετικό αρχικό πρόσημο, και αποθηκεύει το token μαζί με την τιμή στην οποία αντιστοιχούσε στο FSourceValue. Η ιδιότητα SourceToken επιστρέφει το αποθηκευμένο κείμενο μόνο όσο το Value ισούται ακόμα με το FSourceValue. Άλλαξε τον αριθμό και το token εξατμίζεται, ώστε μια τροποποιημένη τιμή να περνάει πάντα από την υπάρχουσα διαδρομή μορφοποίησης και να μην εκπέμπει ποτέ παρωχημένο κείμενο. Το SaveNumericObject ελέγχει πρώτα το SourceToken και το γράφει ως έχει όταν υπάρχει, και μετά πέφτει στους branches integer, αναφοράς color-space, και κλασματικών μόνο για αριθμούς που δημιουργήθηκαν ή επεξεργάστηκαν στη μνήμη
Το αναλλοίωτο είναι μικρό και αξίζει να ειπωθεί καθαρά: αριθμό που δεν άγγιξες τον γράφουμε με τα bytes με τα οποία διαβάστηκε, και αριθμό που άγγιξες τον γράφει ο δικός μας formatter του HotPDF. Τα compact μέλη ωφελούνται από αυτό όπως τα objects σώματος, αφού το EnsureCompressedObjectLoaded τρέχει τον ίδιο parser πάνω στο τμήμα του μέλους. Η ίδια η μορφοποίηση αριθμών, και η ανεξαρτησία της από το locale της process, καλύπτεται στο άρθρο για την αναλλοίωτη από locale μορφοποίηση αριθμών PDF στο HotPDF
Δοκιμάζοντας διαδρομή επανεγγραφής απέναντι σε object streams
Τρεις έλεγχοι πιάνουν κάθε αποτυχία που περιγράφηκε παραπάνω, και κανένας δεν απαιτεί Acrobat. Πρώτον, σύγκρινε το IndexedObjectCount με το MaterializedObjectCount μετά το save· σε πλήρη επανεγγραφή πρέπει να είναι ίσα, και κάθε χάσμα είναι μέλος που πετάχτηκε. Δεύτερον, εξάγε κείμενο και απαρίθμησε το structure tree και στα δύο αρχεία, όχι μόνο απόδωσέ τα, ώστε χαμένο ActualText ή χαμένο structure element να φανεί ως diff. Τρίτον, φόρτωσε την έξοδο με φρέσκο instance και ισχυρίσου ότι το GetLoadedQuarantinedObjStmCount είναι μηδέν, που αποδεικνύει επίσης ότι ο writer δεν παρήγε container που ο reader δεν μπορεί να ανοίξει. Οι συνδυασμοί crypt filters που αποφασίζουν το FReloadObjectStreamsEncrypted έχουν τοποθετηθεί στο άρθρο πολιτειών StmF, StrF και EFF. Η πλευρά writer αυτής της ιστορίας, πώς να εκπέμψεις object streams και πότε να προτιμάς incremental update από επανεγγραφή, βρίσκεται στον οδηγό object streams και incremental updates
Lazy φόρτωμα μελών, το πέρασμα ανάπτυξης πριν τον writer, η καραντίνα αποκρυπτογράφησης, και η διατήρηση source-token κυκλοφορούν όλα στο HotPDF Delphi Component για Delphi και C++Builder. Η σελίδα προϊόντος συνδέει την αναφορά API αν θέλεις να ιχνηλατήσεις το GetLoadedObjectStreamCacheInfo και τους accessors καραντίνας απέναντι στη δική σου ροή πρόσληψης