Τα object streams του PDF 1.5 πακετάρουν πολλά μικρά έμμεσα αντικείμενα σε ένα container συμπιεσμένο με Flate, και η losLab PDF Library τα εκπέμπει σε μια πλήρη αποθήκευση μέσω της σημαίας PackObjectStreams. Το όφελος είναι πραγματικό: εκατοντάδες dictionaries σελίδας, γραμματοσειράς και annotation που το καθένα κοστίζει δεκάδες μη συμπιεσμένα bytes συμπτύσσονται σε μια χούφτα συμπιεσμένα blobs. Το κόστος είναι ότι κάθε πακεταρισμένο αντικείμενο χρειάζεται τώρα ένα cross-reference stream για να περιγραφεί
Αυτό το δεύτερο μισό είναι εκεί όπου σπάνε οι writers. Η κατασκευή ενός container /ObjStm είναι αριθμητική· η εκπαίδευση του μηχανισμού cross-reference να δείχνει μέσα σε αυτό είναι επανασχεδιασμός. Ένας writer που παράγει ένα απόλυτα έγκυρο container και μετά περιγράφει τα μέλη του με συνηθισμένα offsets τύπου 1 έχει παράγει ένα αρχείο που το Acrobat θα ανοίξει ακριβώς όσο χρειάζεται για να το δηλώσει κατεστραμμένο. Τα δύο χαρακτηριστικά είναι στην ουσία ένα, και αυτό το άρθρο καλύπτει την πλευρά εγγραφής και των δύο, όπως ορίζονται στο ISO 32000-1 §7.5.7 και §7.5.8
Τι περιέχει στην πραγματικότητα ένα container ObjStm
Ένα object stream είναι ένα stream του οποίου τα αποκωδικοποιημένα bytes αποτελούνται από δύο συνενωμένες περιοχές, και το ISO 32000-1 §7.5.7 δίνει στο dictionary ακριβώς τρία κλειδιά που έχουν σημασία για την κατασκευή. Το /Type /ObjStm το ταυτοποιεί, το /N δίνει τον αριθμό των μελών, και το /First δίνει το μήκος σε bytes της περιοχής header — ισοδύναμα, το offset στο οποίο ξεκινά το σώμα. Ο header είναι ζεύγη αριθμού αντικειμένου και offset χωρισμένα με λευκό χαρακτήρα· το σώμα είναι τα μέλη σειριοποιημένα το ένα μετά το άλλο, με κάθε offset μετρημένο από την αρχή του σώματος και όχι από την αρχή του αποκωδικοποιημένου payload. Η ανάγνωση ενός πλήρως αποκωδικοποιημένου container το κάνει προφανές: παρακάτω, το /First είναι 14 επειδή οι τρεις γραμμές header καταλαμβάνουν δεκατέσσερα bytes, και το αντικείμενο 7 βρίσκεται 55 bytes μέσα στο σώμα επειδή το αντικείμενο 4 σειριοποιήθηκε σε 54 χαρακτήρες συν έναν διαχωριστή
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
Δύο κανόνες συμμετοχής είναι απόλυτοι και προέρχονται και οι δύο απευθείας από την §7.5.7. Ένα stream object δεν μπορεί ποτέ να είναι μέλος, επειδή ένα stream μεταφέρει ακατέργαστα bytes που θα έπρεπε να ενθυλακωθούν μέσα σε άλλο stream. Και ένα μέλος πρέπει να είναι πλήρης τιμή αντικειμένου, ποτέ γυμνή έμμεση αναφορά — ένα συμπιεσμένο αντικείμενο που είναι απλώς 5 0 R δημιουργεί μια έμμεση παραπομπή που ο reader δεν μπορεί να επιλύσει χωρίς να γνωρίζει ήδη πού δείχνει. Η losLab PDF Library φιλτράρει και τις δύο περιπτώσεις κατά τη συλλογή υποψηφίων, μαζί με το dictionary κρυπτογράφησης και το αντικείμενο 0, και μετά πακετάρει ό,τι επιζεί σε ομάδες των 200 ανά container. Αυτό το όριο είναι απόφαση τυχαίας πρόσβασης και όχι όριο της προδιαγραφής: ένας reader που θέλει ένα μέλος πρέπει να αποσυμπιέσει ολόκληρο το container, οπότε τα υπερμεγέθη containers κάνουν τις μικρές αναζητήσεις ακριβές
Γιατί τα μέλη ObjStm πρέπει να χρησιμοποιούν εγγραφές cross-reference τύπου 2;
Επειδή ένα πακεταρισμένο αντικείμενο δεν έχει byte offset αρχείου προς καταγραφή. Το ISO 32000-1 §7.5.8 απαντά σε αυτό με τρεις τύπους εγγραφών σε ένα δυαδικό cross-reference stream: τύπος 0 για ελεύθερα αντικείμενα, τύπος 1 για συνηθισμένα αντικείμενα σε χρήση αποθηκευμένα σε byte offset, και τύπος 2 για συμπιεσμένα αντικείμενα, των οποίων τα δύο πεδία δεδομένων κρατούν τον αριθμό αντικειμένου του container και τον δείκτη μέλους μέσα σε αυτό. Δεν υπάρχει τρόπος να εκφραστεί ένα πακεταρισμένο αντικείμενο στον κλασικό απλού κειμένου πίνακα xref, κάτι που είναι ακριβώς ο λόγος για τον οποίο το PDF 1.5 εισήγαγε και τα δύο χαρακτηριστικά μαζί
Η σειρά που ακολουθεί μπερδεύει σχεδόν κάθε πρώτη υλοποίηση, συμπεριλαμβανομένης της δικής μας. Τα συνηθισμένα αντικείμενα παίρνουν εγγραφές τύπου 1. Τα ίδια τα containers /ObjStm παίρνουν εγγραφές τύπου 1, επειδή ένα container είναι ένα απολύτως κανονικό έμμεσο stream object γραμμένο σε πραγματικό offset. Μόνο τα μέλη παίρνουν εγγραφές τύπου 2. Και το ίδιο το cross-reference stream είναι έμμεσο αντικείμενο στο αρχείο, οπότε χρειάζεται τη δική του εγγραφή τύπου 1 που δείχνει στο offset όπου μόλις γράφτηκε — το ίδιο offset που καταγράφει το startxref. Μια πρώιμη έκδοση του δικού μας writer εξαιρούσε τους αριθμούς αντικειμένων των containers από τον βρόχο εγγραφής αντί να εξαιρεί τα μέλη, και το αποτέλεσμα ήταν ένα αρχείο με ένα cross-reference stream και καθόλου object streams: δομικά συνεκτικό, σημασιολογικά κενό, απορριπτόμενο στη συνέχεια. Η τιμή /Size κρύβει ένα αντίστοιχο off-by-one, αφού είναι ο υψηλότερος αριθμός αντικειμένου συν ένα και το cross-reference stream δεσμεύεται ως ο υψηλότερος αριθμός αντικειμένου, οπότε πρέπει να μετρηθεί και αυτό
Διαστασιολόγηση του πίνακα /W: γιατί τα τέσσερα bytes δεν αρκούν
Ο πίνακας /W δηλώνει το πλάτος σε bytes καθενός από τα τρία πεδία, και η losLab PDF Library τον γράφει ως /W [1 Field2 Field3] με το πεδίο 1 σταθερό σε ένα byte για τον κωδικό τύπου και το πεδίο 3 σταθερό σε δύο bytes, κάτι που καλύπτει αριθμούς generation έως 65535 όπως και δείκτες μελών. Το πεδίο 2 είναι αυτό που δεν μπορεί να είναι σταθερά, επειδή μεταφέρει δύο άσχετα μεγέθη: σε μια εγγραφή τύπου 1 είναι byte offset περιορισμένο μόνο από το μέγεθος του αρχείου, ενώ σε μια εγγραφή τύπου 2 είναι αριθμός αντικειμένου container και σε μια εγγραφή τύπου 0 είναι το επόμενο ελεύθερο αντικείμενο στην αλυσίδα. Ένα σταθερό πεδίο 2 τεσσάρων bytes λειτουργεί μια χαρά μέχρι το αρχείο να ξεπεράσει τα 4 GB, οπότε κάθε offset πέρα από το όριο περικόπτεται σιωπηλά και ολόκληρος ο πίνακας γίνεται σκουπίδια. Ο writer επομένως σαρώνει τον συναρμολογημένο πίνακα για τη μεγαλύτερη τιμή που θα κρατήσει ποτέ οποιαδήποτε θέση πεδίου 2, συμπεριλαμβανομένου του offset του ίδιου του cross-reference stream, και πλαταίνει το πεδίο έως οκτώ bytes
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
Μόλις γίνουν γνωστά τα πλάτη, το μέγεθος του payload είναι γνωστό ακριβώς, οπότε ο writer προδεσμεύει ολόκληρο το buffer και το γεμίζει με δείκτη· η προσάρτηση εγγραφών byte προς byte σε ένα AnsiString κάνει την κατασκευή του πίνακα τετραγωνική, κάτι που κανείς δεν παρατηρεί σε ένα τιμολόγιο δέκα σελίδων και όλοι παρατηρούν σε ένα έγγραφο με διακόσιες χιλιάδες αντικείμενα. Δύο ακόμα λεπτομέρειες κρατούν ικανοποιημένους τους αυστηρούς readers. Το /Index δηλώνει ποια εύρη αριθμών αντικειμένων καλύπτει ο πίνακας, και για μια πλήρη επανεγγραφή αυτό είναι απλώς [0 N] χωρίς κενά. Και κάθε θέση που ο writer δεν εξέπεμψε στην πραγματικότητα πρέπει να έχει ως προεπιλογή ελεύθερη αντί για σε χρήση: το αντικείμενο 0 βρίσκεται στην κεφαλή της αλυσίδας ελεύθερων, κάθε ελεύθερη θέση συνδέεται με την επόμενη, και μια θέση που κάποτε κρατούσε ένα διαγραμμένο αντικείμενο κρατά τη generation της αυξημένη κατά ένα. Η συνοδευτική σημείωση για την ασφάλεια μνήμης κατά την ανάλυση μη έμπιστων PDF κάνει το ίδιο επιχείρημα ορίων από την πλευρά της ανάγνωσης
Γιατί το cross-reference stream δεν πρέπει ποτέ να κρυπτογραφείται;
Επειδή ένας reader πρέπει να το αναλύσει πριν μπορέσει να ξέρει πώς να αποκρυπτογραφήσει οτιδήποτε. Το cross-reference stream είναι αυτό που λέει στον reader πού βρίσκεται το dictionary /Encrypt· αν τα bytes του ήταν και τα ίδια κρυπτογραφημένα, ο reader θα χρειαζόταν το κλειδί του αρχείου για να βρει το αντικείμενο που περιγράφει το κλειδί του αρχείου. Η losLab PDF Library το επιβάλλει με ένα και μόνο κατηγόρημα: η ShouldCryptStreamData επιστρέφει False όποτε το dictionary του stream φέρει /Type /XRef, οπότε η εξαίρεση ισχύει ανεξάρτητα από το ποια διαδρομή φτάνει στον serializer
Το container /ObjStm λαμβάνει την αντίθετη μεταχείριση, και η ασυμμετρία είναι σκόπιμη. Ένα container κρυπτογραφείται ολόκληρο, με κλειδί βασισμένο στον δικό του αριθμό αντικειμένου, ακριβώς όπως κάθε άλλο stream. Τα μέλη του δεν κρυπτογραφούνται μεμονωμένα — πακετάρονται στην αποκρυπτογραφημένη τους μορφή απλού κειμένου, και το ενιαίο πέρασμα πάνω στο συναρμολογημένο container τα καλύπτει, συμπεριλαμβανομένων των strings. Η διπλή κρυπτογράφηση των μελών παράγει ένα αρχείο που αποκρυπτογραφείται σε ciphertext, και επειδή το εξωτερικό επίπεδο πετυχαίνει, η αποτυχία εμφανίζεται ως σφάλμα ανάλυσης βαθιά μέσα στο γράφημα αντικειμένων αντί ως αποτυχία αυθεντικοποίησης. Ένα αντικείμενο τότε παραμένει εντελώς εκτός του σχήματος: σε ένα κρυπτογραφημένο έγγραφο ο Catalog διατηρείται ως άμεσο αντικείμενο τύπου 1 και δεν πακετάρεται ποτέ, επειδή το πακετάρισμά του θα ανάγκαζε τον loader να αποσυμπιέσει και να αποκρυπτογραφήσει ένα object stream προκειμένου να φτάσει στη ρίζα του εγγράφου, πριν χτιστεί πλήρως το πλαίσιο αποκρυπτογράφησης που η ρίζα βοηθά να θεμελιωθεί
Ενεργοποίηση του packing από τη Delphi
Ο δημόσιος διακόπτης είναι το PackObjectStreams, εκτεθειμένο ως πεδίο στην TPDFlibSaveOptions, ως ο αυτόνομος setter SetPackObjectStreams, και ως property στο αντικείμενο εγγράφου. Η προεπιλογή του είναι ενεργοποιημένο και ελέγχεται αυτόματα ανά έκδοση: ο writer πακετάρει μόνο όταν το έγγραφο είναι ήδη PDF 1.5 ή νεότερο, και καλεί τον εσωτερικό φύλακα ελάχιστης έκδοσης ώστε ένα πακεταρισμένο έγγραφο να ανεβαίνει στο 1.5 αντί να παραμένει λανθασμένα ετικετοποιημένο. Μετά την αποθήκευση, η GetLastSaveUsedObjectStreams αναφέρει αν η πύλη πράγματι άνοιξε, κάτι που είναι το assert που θέλετε σε ένα regression test αντί για σύγκριση μεγέθους σε bytes
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
Η σειρά έχει σημασία μεταξύ packing και συλλογής απορριμμάτων. Η ανάλυση προσβασιμότητας πρέπει να τρέξει πρώτη, επειδή ένα μέλος που επιζεί μέσα σε ένα container παρασύρει μαζί του και το container — αν ένα ζωντανό αντικείμενο πακεταριστεί, ο αριθμός του container του είναι προσβάσιμος εξ ορισμού, και η σάρωση του container το αφήνει αγκυροβολημένο χωρίς τρόπο εντοπισμού του. Η εκτέλεση του συλλέκτη πρώτα σημαίνει επίσης ότι τα νεκρά αντικείμενα δεν εισέρχονται ποτέ σε ένα container, κάτι από όπου προέρχεται το αθροιστικό όφελος μεγέθους. Το packing συμπληρώνει τους άλλους μοχλούς μεγέθους αντί να τους αντικαθιστά· η επισκόπηση για τη βελτιστοποίηση μεγέθους αρχείου PDF και font subsetting καλύπτει τους μοχλούς που δρουν πάνω στα stream payloads, ενώ τα object streams δρουν πάνω στη δομή
Όρια που αξίζει να γνωρίζετε πριν το ενεργοποιήσετε
Οι incremental αποθηκεύσεις δεν πακετάρουν ποτέ. Ένα incremental update προσαρτά νέα αντικείμενα και μια νέα ενότητα cross-reference ενώ αφήνει τις προγενέστερες αναθεωρήσεις φυσικά ανέπαφες, οπότε το ξαναπακετάρισμα υπαρχόντων αντικειμένων σε νέα containers θα ορφάνευε τις εγγραφές τύπου 1 που η προηγούμενη αναθεώρηση εξακολουθεί να αναφέρει· η losLab PDF Library απενεργοποιεί το packing όποτε είναι ενεργή η λειτουργία append, και το άρθρο για τα incremental updates και τη ροή append-mode καλύπτει πλήρως αυτή τη διαδρομή. Τα έγγραφα κάτω από PDF 1.5 κρατούν τον πίνακα cross-reference απλού κειμένου ανεπιφύλακτα: ένας καταναλωτής 1.4 δεν έχει ιδέα τι σημαίνει το /ObjStm, και η σιωπηλή προαγωγή ενός εγγράφου επειδή ο writer προτίμησε μικρότερο αρχείο θα ήταν το λάθος ανταλλάγμα να γίνει εκ μέρους του καλούντος. Ένα προαιρετικό κλειδί που σκόπιμα δεν εκπέμπουμε είναι το /Extends, το οποίο το ISO 32000-1 §7.5.7 ορίζει ώστε ένα container να μπορεί να ονομάσει έναν προκάτοχο και οι readers να μπορούν να αντιμετωπίζουν μια αλυσίδα containers ως λογική ομάδα. Είναι γνησίως προαιρετικό, κάθε container που γράφουμε είναι αυτοτελές και ανεξάρτητα αποκωδικοποιήσιμο, και η παράλειψή του αφαιρεί από τον writer μια κατηγορία σφαλμάτων κύκλου και εκκρεμών αναφορών — αν και οι readers πρέπει φυσικά να εξακολουθούν να σέβονται το /Extends όταν το συναντούν σε αρχεία από άλλους παραγωγούς
Το packing των object stream και η έξοδος cross-reference stream διατίθενται ως μέρος της losLab PDF Library για Delphi και C++Builder, μαζί με τον συλλέκτη απορριμμάτων και τον βελτιστοποιητή content-stream με τους οποίους συνδυάζονται· η σελίδα προϊόντος φέρει την πλήρη αναφορά επιλογών αποθήκευσης