Το PDF Library for Delphi (PDFlibPas) εκπέμπει έγκυρο JSON για κάθε αριθμό PDF από το v3.539.31. Το GetObjectJSON ξαναγράφει tokens που το ISO 32000-1 δέχεται αλλά το RFC 8259 απορρίπτει, όπως -.25, +1.5 και 007.5, σε -0.25, 1.5 και 7.5 ψηφίο προς ψηφίο· το GetDocumentJSON και οι αναφορές ανάλυσης γράφουν null για NaN και Infinity· και το PLDoubleToStr γράφει 0 για NaN αντί να πετάει EInvalidOp στη μέση ενός export. Πριν το fix, η βιβλιοθήκη μπορούσε να παράγει JSON που ο δικός της reader αρνιόταν να φορτώσει
Γιατί ένας έγκυρος αριθμός PDF χαλάει το JSON;
Επειδή οι δύο γραμματικές διαφωνούν σε τέσσερις μικρές λεπτομέρειες, και ένας PDF parser που σέβεται το κείμενο της πηγής θα κουβαλήσει αυτές τις λεπτομέρειες κατευθείαν στην έξοδο. Το ISO 32000-1 §7.3.3 αφήνει έναν αριθμό να ξεκινήσει με σύμπλεον, να παραλείψει το ακέραιο μέρος (.5), να τελειώσει σε γυμνή τελεία (4.) και να κουβαλήσει αρχικά μηδενικά (007.5). Το RFC 8259 §6 δεν επιτρέπει τίποτα από αυτά: ένα προαιρετικό μείον, ένα ακέραιο μέρος που είναι είτε 0 είτε ξεκινά από 1 έως 9, και τουλάχιστον ένα ψηφίο μετά από κάθε δεκαδική τελεία. Οι producers είναι ελεύθεροι να γράψουν τις PDF μορφές, και αρκετές γεννήτριες και χειροκίνητα επεξεργασμένα αρχεία το κάνουν
Η διαρροή ερχόταν από ένα επίτηδες χαρακτηριστικό ακρίβειας. Από το v3.539.19, το TPDFNumeric.Output επιστρέφει το ακριβές κείμενο που έκανε parse ο tokenizer για πραγματικούς αριθμούς, που είναι αυτό που κρατά μια βαθμονομημένη τιμή χρώματος ακριβή στην αποθήκευση, όπως περιγράφεται στο διατήρηση της ακρίβειας των parsed δεκαδικών του PDF. Ο tokenizer ήδη επιδιορθώνει το .5 σε 0.5 και το 4. σε 4.0 στην είσοδο, και οι ακέραιοι αναμορφοποιούνται από την τιμή τους, οπότε το +3 γυρνά ως 3. Αυτό που επιζεί αναλλοίωτο είναι το υπόλοιπο: μια πρόσημη αρχική τελεία (-.25), ένα ρητό σύμπλεον σε πραγματικό αριθμό (+1.5) και αρχικά μηδενικά (007.5). Ο παλιός object writer πρόσθετε το Output αμέσως μετά το "value":, και το TJSONParser.ParseNumber στον δικό της reader της βιβλιοθήκης σταματά σε καθένα από αυτά με «Invalid JSON number», οπότε το export πέτυχε και η επανεισαγωγή απέτυχε με PDFLIB_ERROR_OBJECT_JSON_INVALID (105)
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
JSON: AnsiString;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
raise Exception.Create('load failed');
// Το object 12 είναι πίνακας γραμμένος ως [-.25 +1.5 007.5]
JSON := Lib.GetObjectJSON(12, 0);
// το v3.539.31 και μετά: οι τιμές φτάνουν ως -0.25, 1.5 και 7.5
// το SetObjectJSON δεν δέχεται options, οπότε περάστε 0
if Lib.SetObjectJSON(12, JSON, 0) = 0 then
raise Exception.CreateFmt('round trip rejected, error %d',
[Lib.LastErrorCode]);
Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
finally
Lib.Free;
end;
end;
Πώς το PDFNumberTextToJSON κρατά κάθε ψηφίο;
Το PDFNumberTextToJSON ξανασυλλαβίζει το token αντί να το υπολογίσει εκ νέου από ένα Double. Η συνάρτηση στο PDFlibObjectJSON διαβάζει ένα προαιρετικό πρόσημο, μαζεύει ψηφία πριν και μετά από μία δεκαδική τελεία, και μετά εφαρμόζει μόνο τις διορθώσεις που απαιτεί το JSON: ρίχνει ένα σύμπλεον, κόβει τα αρχικά μηδενικά κρατώντας ένα, προμηθεύει 0 όταν το ακέραιο μέρος είναι κενό, ρίχνει μια γυμνή τελική τελεία και βάζει πίσω το μείον. Ένα token που περιέχει οποιονδήποτε άλλον χαρακτήρα, ή καθόλου ψηφία, γυρνά στο PLJSONNumber(Value, 10), που γράφει null όταν η τιμή δεν είναι πεπερασμένη
- Το
-.25γίνεται-0.25, και το+.5γίνεται0.5 - Το
+1.5γίνεται1.5 - Το
007.5γίνεται7.5, ενώ το0.75μένει ως έχει - Το
4.γίνεται4αν ένα τέτοιο token φτάσει ποτέ στον writer - Το
2.22221και το1.250000κρατούν κάθε κλασματικό ψηφίο, τελικά μηδενικά συμπεριλαμβανομένων
Η μορφοποίηση από το αποθηκευμένο Double θα ήταν πιο σύντομη και λάθος, για τον ίδιο λόγο που υπάρχει το fix ακρίβειας: η default ακρίβεια εξόδου είναι τέσσερα δεκαδικά, και ακόμα και μια μετατροπή πλήρους ακρίβειας μπορεί να προσθέσει δυαδικό θόρυβο σε ένα δεκαδικό literal. Το να κρατάμε τα ψηφία σημαίνει ότι το SetObjectJSON και το ImportObjectJSON, που δίνουν το κείμενο κάθε αριθμού JSON στον tokenizer του PDF, αναδημιουργούν ακριβώς την ίδια τιμή. Η εγγύηση καλύπτει την τιμή, όχι τα bytes: μετά από μια επανεισαγωγή, το -.25 αποθηκεύεται και σώζεται ως -0.25. Και οι δύο συλλαβές είναι ίσες κατά το §7.3.3, αλλά ένα diff σε επίπεδο byte θα σημειώσει την αλλαγή, οπότε μην μεταχειρίζεστε έναν κύκλο export και import ως no-op σε έγγραφο του οποίου τα bytes καλύπτονται από υπογραφή
Τι γίνεται με έναν αριθμό που το JSON δεν μπορεί να αναπαραστήσει;
Το GetDocumentJSON γράφει τώρα null για κάθε αριθμό που είναι NaN ή άπειρος, επειδή το RFC 8259 §6 δεν έχει σύνταξη για ούτε από τα δύο. Το Infinity παράγεται πιο εύκολα απ' όσο ακούγεται: ο tokenizer του PDF συσσωρεύει ψηφία με επαναλαμβανόμενο πολλαπλασιασμό σε ένα Double, που κορυφώνεται κοντά στο 1.8 × 10308, οπότε ένα ακέραιο literal λίγο πάνω από 300 ψηφία γίνεται αθόρυβα +Inf. Τίμια αρχεία δεν περιέχουν ποτέ ένα τέτοιο literal· τα fuzzed και εχθρικά το κάνουν, γι' αυτό ανήκουν στο ίδιο test corpus με τις περιπτώσεις του θωράκιση ενός Pascal PDF parser εναντίον κακόβουλων αρχείων. Ο παλιός document writer μορφοποιούσε μη ακέραιους με Str(D:0:6), και για +Inf αυτό γράφει το κείμενο +Inf, που δεν θα κάνει parse κανένας JSON consumer
Το null είναι επίτηδες απωλεστικό. Οι consumers της εξόδου του GetDocumentJSON πρέπει να δέχονται null όπουδήποτε μπορεί να εμφανιστεί αριθμός, και να το διαβάζουν ως «μια τιμή υπήρχε αλλά δεν μπορεί να αναπαρασταθεί», όχι ως κλειδί που λείπει. Το αρχικό literal δεν είναι ανακτήσιμο από το JSON του εγγράφου, οπότε ένας pipeline που νοιάζεται πρέπει να γράψει το object στο log και να μεταχειριστεί το αρχείο ως ύποπτο αντί να αντικαταστήσει με default
Γιατί ένα μοναδικό NaN μπορούσε να ματαιώσει ένα export SVG ή JSON;
Επειδή το PLDoubleToStr, ο invariant formatter αριθμών πίσω από τα content streams, το SVG, το XML, το CSV και τα περισσότερα JSON της βιβλιοθήκης, κλιμάκωνε την είσοδό του και καλούσε Round, και το Round(NaN) πετάει EInvalidOp σε στόχους όπως το Win32, όπου η Delphi αφήνει το exception x87 invalid-operation αποκρυμμένο. Το exception χτυπούσε αφού ο writer είχε ήδη εκπέμψει μέρος της εξόδου του, οπότε μια εκφυλισμένη μέτρηση, ένα 0/0 σε μια metric ή ένα NaN που πέρναε ο caller, άφηνε πίσω του ένα κομμένο αρχείο. Το PLDoubleToStr τώρα επιστρέφει 0 για NaN, και ο ακέραιος κλάδος του περιορίζεται στο ±9.2e18 όπως ο κλάδος των κλασματικών, οπότε και το Infinity βγαίνει ως πεπερασμένο literal
Το μηδέν είναι η σωστή απάντηση για ένα content stream, όπου μια θέση αριθμού πρέπει να κρατά αριθμό, και η λάθος απάντηση για μια αναφορά, όπου το 0 είναι μια πειστική μέτρηση. Writers JSON που πρέπει να κρατούν τη διαφορά χρησιμοποιούν το PLJSONNumber(Value, Decimals) από το PDFlibExtra, που γράφει null για NaN ή Infinity και invariant ψηφία αλλιώς. Το PLJSONNumber πλέον στηρίζει το GetSimilarImageDeduplicationReportJSON, το GetAnnotationHitsJSON και τις αναφορές barcode, deskew, structured text και PDF/VCR· η αναφορά deskew έγραφε παλιά 0 για μια μη πεπερασμένη γωνία και τώρα γράφει null
uses
SysUtils, PDFlibTypes, PDFlibExtra;
function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
B: PLStringBuilder;
begin
B := PLStringBuilder.Create(128);
try
// Μορφοποιήστε κάθε Double σε κείμενο πρώτα· το PLJSONNumber γράφει null
// για NaN ή Infinity και πάντα χρησιμοποιεί δεκαδική τελεία
B.Append('{"page":').Append(Page)
.Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
.Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
.Append('}');
// Ποτέ B.Append(Angle): το overload Double ακολουθεί το locale του χρήστη
Result := B.ToString;
finally
B.Free;
end;
end;
Πού μπαίνει ακόμα λαθραία το locale του χρήστη στο JSON;
Μέσα από οποιονδήποτε formatter που συμβουλεύεται regional settings, και ένας πλήρης έλεγχος του machine-readable output βρήκε ακριβώς έναν να έχει μείνει: το maxAcceptedMeanError στο GetSimilarImageDeduplicationReportJSON, γραμμένο με το PLFloatToStr, ένα λεπτό wrapper πάνω στο FloatToStr. Σε desktop του οποίου το δεκαδικό διαχωριστικό είναι κόμμα, η αναφορά περιείχε "maxAcceptedMeanError":1,5, που ένας JSON parser διαβάζει ως την τιμή 1 ακολουθούμενη από ένα παραπλανητικό token. Το πεδίο αναφέρει το χειρότερο αποδεκτό σφάλμα pixel από το perceptual image deduplication, και πλέον περνά από το PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Μια υπόλοιπη παγίδα είναι το PLStringBuilder: στη Delphi είναι ένας σκέτος alias του System.SysUtils.TStringBuilder, του οποίου το overload Append(Double) μορφοποιεί μέσω του locale του χρήστη, ενώ τα FPC builds χρησιμοποιούν μια κλάση της βιβλιοθήκης, οπότε ένα τεστ σε Free Pascal ή σε μηχανή en-US δεν θα το πιάσει ποτέ
uses
System.SysUtils, System.JSON, PDFlibrary;
var
Lib: TPDFlib;
Report: WideString;
Parsed: TJSONValue;
begin
// Αναπαραγωγή γερμανικού ή γαλλικού desktop μέσα στο τεστ
FormatSettings.DecimalSeparator := ',';
Lib := TPDFlib.Create;
try
// Χρησιμοποιήστε fixture που περιέχει πράγματι σχεδόν-διπλότυπες εικόνες,
// αλλιώς το mean error είναι 0 και το bug μένει κρυφό
Lib.LoadFromFile('scanned-batch.pdf', '');
// Dry run με κατώφλια 2, 2, 4: το έγγραφο δεν τροποποιείται
Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
Parsed := TJSONObject.ParseJSONValue(Report);
if Parsed = nil then
raise Exception.Create('report is not valid JSON on a comma locale');
Parsed.Free;
finally
Lib.Free;
end;
end;
Μια σουίτα regression για έξοδο JSON θέλει τρία fixtures για να μείνει ειλικρινής: μια σελίδα που κουβαλά -.25, +1.5 και 007.5, ένα object με έναν ακέραιο 400 ψηφίων, και οποιαδήποτε αναφορά τρέχει κάτω από locale κόμματος, καθεμία επικυρωμένη με αυστηρό parser αντί για μάτι. Το object JSON, το document JSON και οι αναφορές ανάλυσης στο PDF Library for Delphi μοιράζονται τους ίδιους κανόνες αριθμών σε Delphi, C++Builder και Free Pascal· η πλήρης λίστα χαρακτηριστικών βρίσκεται στη σελίδα προϊόντος PDF Library for Delphi