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

Διατήρηση ακρίβειας δεκαδικών PDF στην αποθήκευση Delphi

Το PDFlibPas, το losLab PDF Developer Library, κρατά το ακριβές δεκαδικό κείμενο που ανέλυσε για κάθε πραγματικό αριθμό σε έγγραφο και ξαναγράφει εκείνο το κείμενο ως έχει όποτε η τιμή δεν τροποποιήθηκε ποτέ. Από το v3.539.19 η ρύθμιση SetPrecision καθορίζει μόνο αριθμούς που δημιουργεί ή επεξεργάζεται η βιβλιοθήκη, οπότε ένα συνηθισμένο load-and-save δεν στρογγυλοποιεί πια ένα CalRGB /Gamma του 2.22221 σε 2.2222 και δεν μετατοπίζει τα χρώματα μιας σελίδας που δεν άγγιξε κανείς. Η αλλαγή είναι μικρή σε κώδικα και μεγάλη σε όσα λέει για τους parsers: η τιμή που αποκωδικοποιείς και το literal που εκπέμπεις είναι δύο διαφορετικά πράγματα, και ένα round trip Double δεν είναι μετασχηματισμός ταυτότητας

Γιατί ένα save που δεν άλλαξε τίποτα μετατόπισε τα χρώματα της σελίδας;

Επειδή αναδιαμορφώνονταν οι παράμετροι του color space, όχι η εικόνα. Το αρχείο που αποκάλυψε αυτό είναι ένα office έγγραφο 35 σελίδων στο τοπικό regression corpus, με εικόνα header που επαναχρησιμοποιείται σε κάθε σελίδα. Το φόρτωμα και η ευθεία επαναποθήκευσή του παρήγαν image streams πανομοιότυπα byte προς byte με την είσοδο, και μια σύγκριση stream-hash ανέφερε το έγγραφο αμετάβλητο. Μια σύγκριση απόδοσης διαφωνούσε: καθεμία από τις 35 σελίδες έδειχνε pixel διαφορές στο header, και πουθενά αλλού

Η εικόνα header σχεδιάζεται μέσα από CalRGB color space, που το ISO 32000-1 §8.6.5.3 ορίζει με ένα /WhitePoint, έναν προαιρετικό πίνακα /Gamma τριών στοιχείων και έναν προαιρετικό πίνακα /Matrix εννέα στοιχείων. Εκείνοι οι πίνακες είναι απλά numeric objects στο dictionary του color space. Το TPDFNumeric αποθήκευε καθένα ως Double και τίποτα άλλο, και το TPDFNumeric.Output μορφοποιούσε εκείνο το Double μέσω PDFPrecNum, που προεπιλέγει τέσσερις δεκαδικές θέσεις. Έτσι το /Gamma πήγε από 2.22221 σε 2.2222, μια εγγραφή matrix πήγε από 0.71519 σε 0.7152, και ο renderer παράγει πιστά ελαφρώς διαφορετικά χρώματα από ελαφρώς διαφορετική βαθμονόμηση. Τα bytes της εικόνας ήταν αθώα· οι αριθμοί γύρω τους όχι. Το δυσάρεστο κομμάτι είναι πόσο αόρατο ήταν αυτό. Η σύγκριση αποκωδικοποιημένων bytes stream δεν μπορεί να το δει, επειδή οι αριθμοί μένουν σε dictionary, όχι σε stream. Η σύγκριση payloads συνημμένων δεν μπορεί να το δει. Ακόμα και το revision diff που περιγράφεται στο άρθρο για τα modification levels δακτυλογραφεί ένα κανονικοποιημένο σώμα object, οπότε και οι δύο αναθεωρήσεις έχουν το ίδιο hash και το diff τις αναφέρει πανομοιότυπες. Μόνο η απόδοση το έπιασε, γι' αυτό το baseline του corpus αποδίδει κάθε σελίδα αντί να εμπιστεύεται μόνο τους δομικούς ελέγχους

Πού έχασε το PDFlibPas ακρίβεια CalRGB σε no-op save στο Delphi: το αναλυμένο /Gamma 2.22221 και μια εγγραφή matrix 0.71519 μένουν στο TPDFNumeric ως Double, το Output τα μορφοποιεί μέσω PLDoubleToStr με PDFPrecNum σε τέσσερις δεκαδικές θέσεις, κάθε δομικός έλεγχος αναφέρει το έγγραφο αμετάβλητο, και μόνο η σύγκριση απόδοσης δείχνει και τις 35 εικόνες header μετατοπισμένες
Τα bytes της εικόνας ήταν αθώα: το TPDFNumeric αναδιαμόρφωσε τους αριθμούς βαθμονόμησης γύρω τους μέσω PDFPrecNum, οπότε τα stream hashes και το fingerprint diff ανέφεραν και τα δύο πανομοιότυπες αναθεωρήσεις ενώ ο renderer παρήγε ελαφρώς διαφορετικά χρώματα σε κάθε σελίδα

Η τιμή που ανέλυσες δεν είναι το literal που πρέπει να γράψεις

Ένας πραγματικός αριθμός PDF είναι δεκαδικό string, και το ISO 32000-1 §7.3.3 είναι ρητό ότι είναι μόνο δεκαδικό string: χωρίς radix σημειογραφία, χωρίς μορφή εκθέτη. Το Annex C μετά απαριθμεί την ακρίβεια που αναμένεται να τηρήσει μια υλοποίηση, περίπου πέντε σημαντικά δεκαδικά ψηφία στο κλασματικό μέρος. Μια προεπιλεγμένη ακρίβεια εξόδου τεσσάρων είναι ήδη κάτω από αυτό, και χειροτερεύει κοντά στο μηδέν: το PLDoubleToStr κλιμακώνει την τιμή, στρογγυλοποιεί σε integer και εκπέμπει 0 όταν το αποτέλεσμα είναι μηδέν, οπότε μια εγγραφή matrix -0.000012345 δεν χάνει ένα ψηφίο, εξαφανίζεται ολόκληρη

Το να ανεβάσεις την προεπιλογή απλώς μετακινεί τον γκρεμό. Το fix είναι να σταματήσεις να προσποιείσαι ότι ένα Double είναι ο αριθμός. Όταν ο tokenizer στο TPDFStructure.Decode αναγνωρίζει standard real, που σημαίνει ότι το token περιέχει δεκαδική τελεία και όχι marker εκθέτη, αποθηκεύει το κείμενο πηγής στο νέο πεδίο FOriginalText δίπλα στη μετατραπείσα τιμή. Το Output τότε προτιμά εκείνο το κείμενο και πέφτει στη μορφοποίηση μόνο όταν δεν υπάρχει τίποτα προς προτίμηση

Πώς το PDFlibPas διατηρεί αναλυμένο δεκαδικό κείμενο στο Delphi: ο tokenizer στο TPDFStructure.Decode κρατά το literal πηγής στο FOriginalText για κάθε token με δεκαδική τελεία και χωρίς εκθέτη, το Output γράφει εκείνο το κείμενο ως έχει αντί να καλεί το PLDoubleToStr, και το SetTo το καθαρίζει επειδή ένας επεξεργασμένος αριθμός είναι νέος αριθμός
Η τιμή που αποκωδικοποιείς και το literal που εκπέμπεις είναι δύο διαφορετικά πράγματα: η προτίμηση του αναλυμένου κειμένου κρατά το 2.22221 ακριβές, ενώ οι αριθμοί που δημιουργούνται και επεξεργάζονται από τη βιβλιοθήκη ακολουθούν ακόμα το PDFPrecNum και η ρύθμιση δεν φτάνει ποτέ την ανέγγιχτη είσοδο
// Lib/PDFlibStruct.pas — όλο το fix στην πλευρά της εξόδου
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // ένας επεξεργασμένος αριθμός είναι νέος αριθμός
  FValue:= Value;
  FChanged:= True;
End;

Δύο όρια είναι σκόπιμα. Οι integers δεν διατηρούνται, επειδή η μορφοποίηση integer είναι ήδη χωρίς απώλειες. Οι μορφές εκθέτη όπως 6.02E23 γίνονται ανεκτές στην είσοδο για χάρη ελαττωματικών producers αλλά δεν διατηρούνται στην έξοδο, αφού το να ξαναγραφτούν θα διαιώνιζε μια σύνταξη που το §7.3.3 απαγορεύει· περνάνε από τον formatter όπως κάθε αριθμός που παράγει η βιβλιοθήκη. Ο tokenizer εφαρμόζει επίσης τη συνηθισμένη ελάχιστη επιδιόρθωσή του πριν αποθηκεύσει το κείμενο, οπότε ένα literal με αρχική τελεία όπως .5 κρατιέται ως 0.5 και ένα literal με τελική τελεία όπως 5. ως 5.0. Και τα δύο είναι ο ίδιος αριθμός για κάθε reader και γίνονται πολύ πιο ευρέως δεκτά

Τι εγγυάται το SetPrecision μετά το v3.539.19;

Το TPDFlib.SetPrecision πλέον ελέγχει τις δεκαδικές θέσεις των αριθμών που παράγει η ίδια η βιβλιοθήκη: τιμές που τραβιούνται μέσω του painter, αριθμοί που δημιουργούνται από Double όπως μέσω NewNumeric, και κάθε αναλυμένη τιμή που έχει επεξεργαστεί έκτοτε με SetTo. Πρόσεξε ότι κείμενο που αποκωδικοποιείται μέσω του object API, για παράδειγμα ένα literal που περνάει στο SetObjectFromString, περνάει από τον ίδιο tokenizer και διατηρείται με τον ίδιο τρόπο. Ένα αναλυμένο δεκαδικό που δεν τροποποιήθηκε ποτέ κρατά την ακρίβεια εισόδου του ανεξαρτήτως της ρύθμισης, και η αλλαγή της ρύθμισης μετά το φόρτωμα δεν το αγγίζει αναδρομικά. Η καταχώρηση αναφοράς του SetPrecision ενημερώθηκε στην ίδια κυκλοφορία να λέει ακριβώς αυτό, επειδή η παλιά διατύπωση υπέθαλπε ότι η ρύθμιση εφαρμόζεται σε κάθε αριθμό του αρχείου

Ο καθαρισμός γίνεται στο SetTo αντί να παραγεται από τη σημαία Changed, και αυτή η διάκριση μετράει. Ο σωλήνας αποθήκευσης μηδενίζει το Changed στα objects μόλις γραφτούν, οπότε ένας έλεγχος της μορφής «εκπεμπές αρχικό κείμενο εκτός αν άλλαξε» θα ξεκινούσε να εκπέμπει παρωχημένο κείμενο για τιμή που επεξεργάστηκε, αποθηκεύτηκε, και ξαναεπεξεργάστηκε μέσα στην ίδια συνεδρία. Το να δένεις το αρχικό κείμενο με την ίδια την ανάθεση κάνει αδύνατο τα δύο να διαφωνήσουν. Το regression test καρφώνει καθεμία από αυτές τις συμπεριφορές με τις τιμές από το αρχικό αρχείο

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // Η ανέγγιχτη είσοδος επιβιώνει ως έχει, συμπεριλαμβανομένης της τιμής
    // που η μορφοποίηση τεσσάρων θέσεων θα είχε καταρρίψει σε 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Μια επεξεργασία πετάει το αρχικό κείμενο και ακολουθεί το PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Το κατέβασμα της ακρίβειας μετά δεν φτάνει την ανέγγιχτη είσοδο
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Γιατί το content model εξακολουθεί να κανονικοποιεί αριθμούς;

Επειδή το TPDFContentProgram υπόσχεται canonical numeric operands, και εκείνη η υπόσχεση αξίζει περισσότερο από verbatim κείμενο μέσα σε content stream. Το επεξεργάσιμο content model, το ίδιο πάνω στο οποίο χτίζεται ο tracker graphics-state, υπάρχει ώστε το NormalizeContentStreams, ο optimizer και το Emit να παράγουν σταθερή, συγκρίσιμη έξοδο από αυθαίρετη είσοδο. Αν ένα αναλυμένο operand κουβαλούσε το αρχικό του κείμενο μέσα στο model, μια ακολουθία operator όπως 0.50000 0 0 RG θα εκπεμπόταν διαφορετικά από το 0.5 0 0 RG, και κάθε κατάντη σύγκριση θα παρασυρόταν από τις συνήθειες μορφοποίησης του producer

Έτσι το model αφαιρεί το αρχικό κείμενο στα δύο σημεία εισόδου του. Το NormalizeContentNumbers τρέχει σε κάθε operand καθώς τον σπρώχνει ο parser και ξανά μέσα στο SetOperand όταν αποκωδικοποιείται πηγή από caller, και αναδύεται μέσα από arrays και dictionaries ώστε να καλύπτονται τα dash patterns, οι πίνακες TJ και τα property dictionaries του marked content. Η κλήση SetTo(AsDouble) σε κάθε numeric αρκεί, αφού είναι ακριβώς η λειτουργία που καθαρίζει το κείμενο. Τα ακατέργαστα δεδομένα inline images μένουν ως έχουν, όπως πάντα

Γιατί το content model του PDFlibPas εξακολουθεί να κανονικοποιεί αριθμούς: το NormalizeContentNumbers τρέχει εκεί που ο parser σπρώχνει κάθε operand και ξανά μέσα στο SetOperand, αναδύεται μέσα από arrays και dictionaries ώστε να καλύπτονται dash patterns, πίνακες TJ και property dictionaries marked-content, και το SetTo AsDouble καθαρίζει το αρχικό κείμενο ώστε το 0.50000 και το 0.5 να εκπέμπονται πανομοιότυπα
Τα canonical numeric operands είναι η υπόσχεση του content model: τα ακατέργαστα δεδομένα inline images μένουν ως έχουν, και οι ανέγγιχτοι αριθμοί dictionary έξω από content streams κρατούν την verbatim εγγύηση, οπότε ένα απλό ζεύγος LoadFromFile και SaveToFile τους διατηρεί ακόμα
// Lib/PDFlibContentModel.pas — το content model τηρεί τη σύμβασή του
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

Ο πρακτικός κανόνας για τους callers είναι επομένως απλός. Ένα απλό LoadFromFile ακολουθούμενο από SaveToFile αφήνει τα ανέγγιχτα content streams και τους ανέγγιχτους αριθμούς dictionary όπως ήταν. Μια σελίδα που περνάει από NormalizeContentStreams, ή κάθε επεξεργασία μέσω του content model, βγαίνει canonical εκ σχεδιασμού, και η υπόλοιπη παράταξη του εγγράφου εξακολουθεί να διατηρείται. Είναι δύο διαφορετικά αιτήματα, και πλέον κάνουν δύο διαφορετικά πράγματα

Τι κοστίζει, και πού σταματά η εγγύηση

Κάθε TPDFNumeric κουβαλάει πλέον μία επιπλέον αναφορά AnsiString, και κάθε αναλυμένο δεκαδικό κρατά το κείμενο πηγής του ζωντανό για όσο ζει το object. Σε έγγραφο με εκατομμύρια πραγματικούς αριθμούς αυτό είναι πραγματική μνήμη, και ανήκει σε κάθε μέτρηση μεγάλων εγγράφων αντί να απορρίπτεται με το χέρι. Η εγγύηση είναι επίσης περιορισμένη στο δικό του έγγραφο του αριθμού: η αντιγραφή objects ανάμεσα σε έγγραφα ή η ανακατασκευή τιμών μέσω του object API παράγει νέους αριθμούς, που ακολουθούν την ακρίβεια εξόδου όπως κάθε άλλος νέος αριθμός. Αξίζει να είσαι ακριβής για όσα δηλώνει και όσα δεν δηλώνει η κυκλοφορία. Ένα load-and-save ενός ανέγγιχτου εγγράφου διατηρεί πλέον τους αριθμούς βαθμονόμησης που καταναλώνει πραγματικά ο renderer, που είναι η ιδιότητα που ελέγχει το baseline του corpus. Δεν δηλώνει byte-πανομοιότυπη έξοδο, που εξαρτάται επίσης από την αρίθμηση objects, τη συμπίεση streams και το trailer identifier που συζητιέται στο άρθρο για το ντετερμινιστικό PDF ID. Και δεν κάνει το fingerprint diff να βλέπει διαφορές στρογγυλοποίησης σε αρχεία από άλλο λογισμικό, αφού εκείνα έχουν ακόμα το κανονικοποιημένο σώμα. Το μάθημα γενικεύει καλά πέρα από το CalRGB: όταν ένας parser κρατά μόνο τη μετατραπείσα τιμή, κάθε save είναι μια επεξεργασία, και ο μόνος τρόπος να το προσέξεις είναι να κοιτάξεις το αποτυπωμένο αποτέλεσμα. Ο χειρισμός numerics και η σημασιολογία του SetPrecision τεκμηριώνονται στη σελίδα προϊόντος του losLab PDF Developer Library