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

Γιατί ένα no-op save χαλάει Info και XMP metadata σε PDF

Το PDF Library for Delphi v3.539.18 και v3.539.20 διορθώνουν δύο τρόπους με τους οποίους ένα save που δεν αλλάζει τίποτα μπορούσε παρόλα αυτά να χαλάσει τα metadata του εγγράφου: όταν τα /CreationDate και /ModDate αναφέρονταν στο ίδιο string object, η αυτόματη ενημέρωση ModDate ξαναέγραφε και τα δύο, και όταν το XMP object δημιουργούνταν πριν διαβαστεί το αρχικό stream /Metadata, ένα προεπιλεγμένο packet αντικαθιστούσε το πρωτότυπο. Τα fixes αντικαθιστούν τις αναφορές του dictionary αντί να μεταλλάσσουν κοινόχρηστα objects, και δεσμεύουν το υπάρχον packet πριν την lazy αρχικοποίηση XMP

Η σκηνή είναι η πιο αδιάφορη λειτουργία που κάνει μια βιβλιοθήκη PDF: φόρτωσε ένα αρχείο, αποθήκευσέ το με νέο όνομα, μην αγγίξεις τίποτα ενδιάμεσα. Οι σελίδες αποδίδονταν πανομοιότυπα πριν και μετά. Τα hashes των content streams ταίριαζαν. Το αρχείο περνούσε κάθε έλεγχο που είχαμε, και ήταν ακόμα λάθος σε δύο σημεία που κανένας renderer δεν θα σου έδειχνε ποτέ. Και τα δύο ελαττώματα κάθονταν στη διαδρομή read-modify-write από την οποία περνά κάθε πραγματική επεξεργασία, οπότε οποιοδήποτε save αρκούσε για να τα πυροδοτήσει, και βρέθηκαν μόνο όταν ένας δεύτερος, ανεξάρτητος parser σύγκρινε τη μη οπτική σημασιολογία των δύο αρχείων

Γιατί η αποθήκευση PDF αλλάζει το CreationDate του;

Επειδή στο document information dictionary επιτρέπεται να αναφέρει ένα indirect string object από δύο κλειδιά, και η βιβλιοθήκη ενημέρωνε το object αντί για το κλειδί. Το ISO 32000-1 §7.3.10 αφήνει οποιαδήποτε τιμή dictionary να είναι indirect reference, και τίποτα στο §14.3.3 Πίνακας 317 δεν λέει ότι η τιμή κάτω από /CreationDate πρέπει να είναι διαφορετικό object από την τιμή κάτω από /ModDate. Ένας producer που έγραψε την ίδια χρονική σήμανση δύο φορές τη στιγμή της δημιουργίας μπορεί, απόλυτα νόμιμα, να δείχνουν και τα δύο κλειδιά σε ένα μοναδικό 2728 0 R, που είναι ακριβώς αυτό που έκανε ένα CJK έγγραφο σχεδιασμού στο τοπικό μας corpus

Η σκανδάλη είναι η αυτόματη ημερομηνία τροποποίησης. Εκτός αν είναι set το UserModDate, το SaveToFile καλεί SetInfo('ModDate', ...) με την τρέχουσα ώρα πριν τη γραφή, που φτάνει στο SetRawInfo. Το παλιό SetRawInfo έβρισκε το object κάτω από το κλειδί και, αν έβρισκε TPDFString, καλούσε SetTo πάνω του. Αυτό είναι γράψιμο in-place σε όποιο object επιλύει εκείνη τη στιγμή το κλειδί, και όταν αυτό το object είναι κοινόχρηστο, το /CreationDate αναφέρει πλέον κι αυτό την ώρα του save. Το έγγραφο ακόμα ανοίγει, τυπώνει και αποδίδει pixel προς pixel όπως πριν, οπότε μια οπτική regression suite περνάει χωρίς να ανοίξει το μάτι

Μετάλλαξη κοινόχρηστου string Info στο PDFlibPas: τα /CreationDate και /ModDate αναφέρουν νόμιμα ένα string object 2728 0 R, το παλιό SetRawInfo καλούσε SetTo σε ό,τι επιλύει το κλειδί και ξαναέγραφε και τις δύο ημερομηνίες με την ώρα του save, και το νέο SetRawInfo προσθέτει φρέσκο string κάτω από το κλειδί διατηρώντας τη λειτουργία hex string
Η ενημέρωση μιας εγγραφής dictionary πλέον αντικαθιστά την αναφορά της εγγραφής αντί να μεταλλάσσει το κοινόχρηστο object, ώστε ένα αυτόματο γράψιμο ModDate να μην μπορεί πια να αλλάξει το CreationDate, και το παρωχημένο object κρατιέται για τις υπόλοιπες αναφορές
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

Το fix στο TPDFDocument.SetRawInfo είναι μικρό και η αρχή πίσω του είναι γενική: η ενημέρωση μιας εγγραφής dictionary αντικαθιστά την αναφορά εκείνης της εγγραφής, ποτέ το object στο οποίο τύχαινε να επιλύεται. Ο νέος κώδικας διαβάζει το υπάρχον TPDFStringMode ώστε ένα hex string να μείνει hex και ένα literal string να μείνει literal, και μετά προσθέτει ένα φρέσκο string από FStructure.NewString(Value, StringMode) κάτω από το κλειδί. Δύο άλλες λεπτομέρειες μετράνε όσο και η κύρια αλλαγή. Ο παλιός branch για εγγραφή με τιμή stream καθάριζε το stream με SetTo('') πριν το αντικαταστήσει, που θα είχε αδειάσει την τιμή για κάθε άλλο κλειδί που δείχνει ακόμα σε εκείνο το stream, οπότε αυτός ο καθαρισμός φεύγει. Και το παρωχημένο object δεν διαγράφεται, επειδή το structure το κατέχει και άλλες αναφορές μπορεί να το χρειάζονται ακόμα

// Πριν: μετάλλαξε ό,τι object επιλύει αυτή τη στιγμή το κλειδί
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Μετά: κράτα την αναπαράσταση, αντικατάστησε μόνο την αναφορά του κλειδιού
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Το regression στο Tests\SharedInfoSemantics.inc χτίζει το aliasing επίτηδες αντί να βασίζεται σε αρχείο corpus: ένα hex string αναφερόμενο και από τα δύο κλειδιά ημερομηνιών, ένα direct string κοινόχρηστο από /Title και /Subject, ένα stream κοινόχρηστο από /Author και /Keywords. Μετά την ενημέρωση ενός κλειδιού από κάθε ζεύγος, το άλλο πρέπει ακόμα να διαβάζει την αρχική του τιμή και το ενημερωμένο string πρέπει ακόμα να είναι hex. Η δημόσια τεκμηρίωση του SetInformation πλέον δηλώνει την εγγύηση σε μία πρόταση: η ενημέρωση ενός πεδίου Info αντικαθιστά μόνο εκείνο το πεδίο, ακόμα κι όταν άλλα πεδία αναφέρονται στο ίδιο object

Γιατί ένα υπάρχον XMP packet αντικαθίσταται από προεπιλογές;

Εξαιτίας της σειράς δύο γραμμών. Το TPDFDocument.GetMetadata έχει ένα fast path: όταν το πεδίο XMP είναι ήδη ανατεθειμένο, επιστρέφει XMP.SaveToString αντί να αποκωδικοποιήσει το stream /Metadata από τον κατάλογο. Κάποια call sites αρχικοποιούσαν lazily με XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, που διαβάζεται φυσιολογικά και είναι λάθος: τη στιγμή που τρέχει το GetMetadata, το XMP είναι ανατεθειμένο, οπότε η «πηγή» που φορτώνεται είναι το σειριοποιημένο προεπιλεγμένο packet ενός object που δημιουργήθηκε μια γραμμή νωρίτερα. Το αρχικό packet, με τα dc:creator του, τα custom namespaces και όποια standards identification, δεν φτάνει ποτέ στο object και ξεπερνιέται στο save. Η ίδια αυτόματη ημερομηνία τροποποίησης αρκεί για να το πυροδοτήσει, επειδή το SetInfo αρχικοποιεί το XMP πριν αγγίξει το Info dictionary ώστε το xmp:ModifyDate να μένει σε συγχρονισμό με το /ModDate. Πρόσεξε τι κρύβεται πίσω από αυτό το ελάττωμα: η σύγκριση του Info dictionary από το πρώτο bug περνάει, αφού τα /Author και /Title στο /Info είναι ανέγγιχτα. Μόνο το δέντρο XMP άλλαξε, και μόνο ένας έλεγχος που αναλύει και συγκρίνει εκείνο το δέντρο το παρατηρεί

Σειρά lazy αρχικοποίησης XMP στο PDFlibPas: η δημιουργία του XMP object πριν την κλήση GetMetadata κάνει το fast path να σειριοποιεί προεπιλεγμένο packet και να χάνει τα dc:creator, custom namespaces και standards identification, ενώ η δέσμευση του Source πριν το TPDFlibXMP.Create φορτώνει το αρχικό stream /Metadata από τον κατάλογο
Κάθε save πυροδοτούσε την αντικατάσταση επειδή το SetInfo αρχικοποιεί το XMP για να μένει το xmp:ModifyDate σε συγχρονισμό με το /ModDate, οπότε κάθε lazy αρχικοποίηση στο έγγραφο πλέον διοχετεύεται σε ένα EnsureXMP που δεσμεύει το υπάρχον packet πριν δημιουργηθεί το object
// Λάθος: το GetMetadata πλέον σειριοποιεί το object που δημιουργήθηκε στην προηγούμενη γραμμή
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Σωστό: δέσμευσε πρώτα το stream /Metadata, μετά δημιούργησε και φόρτωσε
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

Το fix κάνει δύο πράγματα. Το TPDFDocument.EnsureXMP πλέον δεσμεύει Source := GetMetadata πριν το TPDFlibXMP.Create, και κάθε lazy αρχικοποίηση στο έγγραφο αντικαταστάθηκε από κλήση σε αυτό: το SetInfo, το SetXMPInformation, το GetXMPInformation, οι setters των λειτουργιών PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR και PDF/UA, και η διαδρομή επιδιόρθωσης metadata. Δημόσιες είσοδοι όπως το SetXMPProperty περνούσαν ήδη από το EnsureXMP, και το GetXMPProperty διαβάζει μέσω GetDocumentMetadata, οπότε όλη η επιφάνεια μοιράζεται μία σειρά αρχικοποίησης. Ένα σωστό αντίγραφο μιας τριών γραμμών ακολουθίας αξίζει περισσότερο από δέκα αντίγραφα που τυχαίνει να συμφωνούν σήμερα

Δύο μικρότερες παγίδες που βρέθηκαν στον ίδιο δρόμο

Ο σειριοποιητής XMP σε Windows χρησιμοποιεί τον XML writer της πλατφόρμας, που εκπέμπει μια δήλωση XML την οποία το packet δεν πρέπει να κουβαλάει. Ο παλιός κώδικας την απέσπαγε διαγράφοντας χαρακτήρες μέχρι να φτάσει στο <?xpacket. Το ISO 16684-1 §7.3.2 κάνει τον wrapper xpacket προαιρετικό, και ένας producer που γράφει γυμνό στοιχείο <x:xmpmeta> βρίσκεται μέσα στο standard, οπότε σε ένα τέτοιο packet ο βρόχος διαγραγε ολόκληρο, έγκυρο έγγραφο. Ο σειριοποιητής πλέον εντοπίζει το κλείσιμο ?> της δήλωσης και αφαιρεί μόνο αυτό. Το Tests\XMPRetentionSemantics.inc τρέχει τον έλεγχο διατήρησής του δύο φορές, μία με τον wrapper και μία με αυτόν κομμένο, και ισχυρίζεται ότι ένας marker custom namespace και ο αρχικός author επιβιώνουν από SetInfo, GetMetadata, SaveToString και ένα reload. Η δεύτερη παγίδα ήταν σύμβολο preprocessor: ο συγχρονισμός Info προς XMP στο SetInfo προστατευόταν από το NOVCL, που είναι set στα builds Free Pascal, αλλά το XMP backend κόβεται από το λειτουργικό σύστημα, όχι από το framework, αφού το PDFlibXMP.pas ορίζει NO_XMP μόνο όταν λείπει το OS_WINDOWS. Ένα Windows build Lazarus είχε λοιπόν ένα λειτουργικό XMP object και ένα SetInfo που παράλειπε σιωπηλά την ενημέρωσή του. Ο guard είναι πλέον το NO_XMP, οπότε μια εφαρμογή Free Pascal σε Windows παίρνει τον ίδιο συγχρονισμό με το Delphi

Πώς κρατάς το αρχικό ModDate σε ένα pass-through save;

Θέσε KeepModDate στο TPDFlibSaveOptions και αποθήκευσε μέσω SaveToFileOptions. Η επιλογή θέτει το UserModDate για τη διάρκεια της κλήσης, και το SaveToFile τότε παραλείπει την αυτόματη χρονική σήμανση, που είναι και το βήμα που αρχικοποιεί lazily το XMP object. Ένα έγγραφο του οποίου τα metadata δεν αγγίξατε ποτέ, και για το οποίο δεν ενεργοποιήθηκε λειτουργία συμμόρφωσης, κρατά και το Info dictionary και το stream /Metadata όπως φορτώθηκαν. Η κλήση SetInformation(8, ...) έχει το ίδιο αποτέλεσμα μόνιμα, επειδή το να ορίσεις εσύ την ημερομηνία τροποποίησης τη σημειώνει ως ελεγχόμενη από χρήστη

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // χωρίς αυτόματο /ModDate, χωρίς lazy XMP init
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Να είσαι ειλικρινής για το τι σου αγοράζει αυτό. Το KeepModDate είναι η σωστή επιλογή για ένα pass-through βήμα του οποίου η έξοδος πρέπει να περιγράφει την ίδια αναθεώρηση με την είσοδο, και είναι λάθος επιλογή για οτιδήποτε επεξεργάζεται πραγματικά περιεχόμενο, επειδή το §14.3.3 περιμένει το /ModDate να αντικατοπτρίζει την πιο πρόσφατη τροποποίηση. Επίσης δεν διορθώνει αναδρομικά μια βιβλιοθήκη που μεταλλάσσει κοινόχρηστα objects· αποφεύγει μόνο το ένα γράψιμο που αποκάλυπτε το ελάττωμα. Και τα δύο fixes παραπάνω είναι αυτά που κάνουν ένα συνηθισμένο save ασφαλές, και η επιλογή είναι αυτή που κάνει ένα σκόπιμο no-op ειλικρινές

Πώς επαληθεύεις ότι ένα save δεν άλλαξε τίποτα εκτός του ModDate;

Όχι με pixels και όχι με stream hashes, γιατί και τα δύο ελαττώματα αφήνουν κάθε σελίδα και κάθε content stream byte προς byte πανομοιότυπα. Ο έλεγχος που τα έπιασε είναι ένα μη οπτικό σημασιολογικό snapshot που παίρνει ένας ανεξάρτητος parser, που δεν μοιράζεται κώδικα με τη βιβλιοθήκη υπό δοκιμή, από το αρχείο πηγής και από το αποθηκευμένο αρχείο, ακολουθούμενο από δομική σύγκριση. Το snapshot καλύπτει το Info dictionary με το /ModDate εξαιρεμένο, το δέντρο outline με κάθε bookmark επιλυμένο σε αριθμό σελίδας αντί για αριθμό object, τα named destinations και τους στόχους των συνδέσμων επιλυμένους με τον ίδιο τρόπο, τις τιμές των πεδίων φόρμας, τα bytes των συνημμένων ως hashes, και το packet XMP αναλυμένο ως δέντρο αντί να συγκριθεί ως κείμενο. Οι αριθμοί objects σκόπιμα δεν είναι μέρος του, αφού μια πλήρης επανεγγραφή ξανααριθμεί τα πάντα και μια σύγκριση με κλειδί πάνω τους θα ανέφερε θόρυβο

Μη οπτική σημασιολογική επαλήθευση saves στο PDFlibPas: ένας ανεξάρτητος parser χωρίς κοινό κώδικα φωτογραφίζει το Info dictionary μείον το /ModDate, τις σελίδες outline και destinations, τις τιμές φόρμας, τα hashes συνημμένων και το δέντρο XMP, μετά συγκρίνει την πηγή με το αποθηκευμένο αρχείο αφήνοντας τα /ModDate, xmp:ModifyDate και xmp:MetadataDate ως αναμενόμενες αλλαγές
Tα pixels και τα stream hashes μένουν byte προς byte πανομοιότυπα και στα δύο ελαττώματα, οπότε η σύγκριση δουλεύει σε επιλυμένη σημασιολογία και όχι σε αριθμούς objects, και η διασωθείσα metadata αναφέρεται ειλικρινά ως διατηρημένη, όχι ως έγκυρη κατά schema ή συμμορφούμενη με PDF/UA και PDF/A

Οι εξαιρέσεις είναι το ίδιο σημαντικές με τις εντάξεις. Τα /ModDate, xmp:ModifyDate και xmp:MetadataDate αναμένεται να αλλάξουν και πέφτουν πριν τη σύγκριση· ένα αρχείο του οποίου η πηγή δεν είχε καθόλου XMP δεν τιμωρείται επειδή απέκτησε packet. Αυτό που δεν ισχυρίζεται ο έλεγχος είναι εξίσου ρητό: η διατήρηση υπάρχοντος packet δεν λέει τίποτα για το αν εκείνο το packet είναι έγκυρο κατά schema ή αν το έγγραφο πληροί PDF/UA ή οποιοδήποτε μέρος PDF/A. Είναι ξεχωριστές ερωτήσεις με ξεχωριστά εργαλεία, και η σύγχυση του «τα metadata επιβίωσαν» με «τα metadata είναι συμμορφούμενα» είναι ο λόγος που το πρώτο bug κρύφτηκε όσο κρύφτηκε. Στην πλευρά της βιβλιοθήκης τα δύο regressions τρέχουν πλέον σε κάθε στοχευμένο πέρασμα στα Delphi Win32 και Win64 και στα Free Pascal Win32 και Win64, και η σημασιολογική σύγκριση είναι συνθήκη επιτυχίας για το benchmark του corpus πραγματικών εγγράφων

Αν δουλεύεις στο επίπεδο κάτω από αυτά τα fixes, η μηχανική του πώς ένα save ξαναγράφει objects καλύπτεται στο incremental updates και αποθήκευση μόνο-προσθήκης, που είναι η μία λειτουργία save όπου ένα κοινόχρηστο object απλώς μένει εκεί που ήταν, και στο modification levels και revision diffing, που είναι το άλλο σημείο όπου μια παρωχημένη ή ξαναγραμμένη ημερομηνία παραπλανά έναν reader. Η οπτική επιδιόρθωσης του ίδιου ζεύγους Info και XMP, όπου τα δύο μισά κάνουν να συμφωνούν αντί απλώς να διατηρούνται, βρίσκεται στο μετατροπή σε PDF/A και επιδιόρθωση metadata

Το PDF Library for Delphi είναι εγγενής βιβλιοθήκη PDF σε Pascal για Delphi, C++Builder και Lazarus, και η διαδρομή read-modify-write που περιγράφεται εδώ είναι η ίδια από την οποία περνά κάθε επεξεργασία στη δική σου process, οπότε οι εγγυήσεις παραπάνω ισχύουν είτε αποθηκεύεις μία φορά είτε χίλιες τη μέρα — δες τη σελίδα προϊόντος του PDF Library for Delphi για τους υποστηριζόμενους compilers και πλατφόρμες