Die PDF Library for Delphi (PDFlibPas) emittiert seit v3.539.31 gültiges JSON für jede PDF-Zahl. GetObjectJSON schreibt Tokens um, die ISO 32000-1 akzeptiert, RFC 8259 aber ablehnt — etwa -.25, +1.5 und 007.5 — ziffernweise in -0.25, 1.5 und 7.5; GetDocumentJSON und die Analyse-Reports schreiben null für NaN und Infinity; und PLDoubleToStr schreibt 0 für NaN, statt mitten im Export ein EInvalidOp zu werfen. Vor dem Fix konnte die Bibliothek JSON produzieren, das ihr eigener Reader nicht wieder einlud
Warum bricht eine gültige PDF-Zahl das JSON?
Weil die beiden Grammatiken in vier kleinen Details auseinandergehen, und ein PDF-Parser, der den Quelltext respektiert, trägt diese Details geradewegs in die Ausgabe. ISO 32000-1 §7.3.3 lässt eine Zahl mit Pluszeichen beginnen, den Ganzzahlteil weglassen (.5), auf einem nackten Punkt enden (4.) und führende Nullen tragen (007.5). RFC 8259 §6 erlaubt nichts davon: ein optionales Minus, ein Ganzzahlteil, der 0 ist oder mit 1 bis 9 beginnt, und mindestens eine Ziffer nach jedem Dezimalpunkt. Producer dürfen die PDF-Formen frei schreiben, und jede Menge Generatoren und handeditierter Dateien tun es auch
Das Leck kam von einem absichtlichen Präzisions-Feature. Seit v3.539.19 liefert TPDFNumeric.Output für echte Zahlen exakt den Text zurück, den der Tokenizer geparst hat — genau das hält einen kalibrierten Farbwert beim Speichern exakt, wie in Bewahren geparster PDF-Dezimalpräzision beschrieben. Der Tokenizer bügelt .5 bereits unterwegs in 0.5 und 4. in 4.0, und Integer werden aus ihrem Wert neu formatiert, +3 kommt also als 3 zurück. Was wörtlich überlebt, ist der Rest: ein vorzeichengeführter führender Punkt (-.25), ein explizites Plus an einer reellen Zahl (+1.5) und führende Nullen (007.5). Der alte Objekt-Writer hängte Output direkt hinter "value": an, und TJSONParser.ParseNumber im eigenen Reader der Bibliothek bricht bei jedem davon mit „Invalid JSON number“ ab — der Export meldete Erfolg, und das Wiedereinlesen scheiterte mit 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');
// Objekt 12 ist ein Array, geschrieben als [-.25 +1.5 007.5]
JSON := Lib.GetObjectJSON(12, 0);
// ab v3.539.31: die Werte kommen als -0.25, 1.5 und 7.5 an
// SetObjectJSON nimmt keine Options, also 0 übergeben
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;
Wie PDFNumberTextToJSON jede Ziffer behält
PDFNumberTextToJSON buchstabiert das Token um, statt es aus einem Double neu zu berechnen. Die Funktion in PDFlibObjectJSON liest ein optionales Vorzeichen, sammelt die Ziffern vor und nach einem einzelnen Dezimalpunkt und wendet dann nur die Eingriffe an, die JSON verlangt: Sie wirft ein Plus weg, stript führende Nullen, behält aber eine, liefert 0, wenn der Ganzzahlteil leer ist, entfernt einen nackten Endpunkt und setzt das Minus wieder davor. Ein Token mit irgendeinem anderen Zeichen oder ganz ohne Ziffern fällt auf PLJSONNumber(Value, 10) zurück, das null schreibt, wenn der Wert nicht endlich ist
-.25wird-0.25, und+.5wird0.5+1.5wird1.5007.5wird7.5, während0.75bleibt, wie es ist4.wird4, falls so ein Token den Writer überhaupt erreicht2.22221und1.250000behalten jede Nachkommastelle, Nachlaufnullen eingeschlossen
Aus dem gespeicherten Double zu formatieren wäre kürzer und falsch gewesen, aus demselben Grund, aus dem es den Präzisions-Fix gibt: Die Default-Ausgabegenauigkeit liegt bei vier Dezimalstellen, und selbst eine vollpräzise Konvertierung kann einem Dezimalliteral binäres Rauschen beimischen. Die Ziffern zu behalten bedeutet, dass SetObjectJSON und ImportObjectJSON, die jeden JSON-Zahlentext an den PDF-Tokenizer weiterreichen, exakt denselben Wert rekonstruieren. Die Garantie gilt dem Wert, nicht den Bytes: Nach einem Re-Import ist -.25 als -0.25 gespeichert und geschrieben. Beide Schreibweisen sind unter §7.3.3 gleich, aber ein Byte-Diff flaggt die Änderung — behandeln Sie einen Export-Import-Zyklus also nicht als No-op bei einem Dokument, dessen Bytes von einer Signatur abgedeckt sind
Was geschieht mit einer Zahl, die JSON nicht darstellen kann?
GetDocumentJSON schreibt jetzt null für jede Zahl, die NaN oder unendlich ist, denn RFC 8259 §6 hat für beides keine Syntax. Infinity ist leichter zu erzeugen, als es klingt: Der PDF-Tokenizer akkumuliert Ziffern durch wiederholte Multiplikation in ein Double, das bei etwa 1,8 × 10308 ausläuft, ein Integerliteral von gut 300 Stellen Länge wird also lautlos zu +Inf. Ehrliche Dateien enthalten nie so ein Literal, gefuzzte und feindliche schon — deshalb gehören sie in dasselbe Testkorpus wie die Fälle aus Härten eines Pascal-PDF-Parsers gegen bösartige Dateien. Der alte Dokument-Writer formatierte Nicht-Integer mit Str(D:0:6), und für +Inf schreibt das den Text +Inf, den kein JSON-Consumer parsen wird
Das null ist bewusst verlustbehaftet. Consumer der GetDocumentJSON-Ausgabe müssen null überall akzeptieren, wo eine Zahl auftauchen kann, und sollten es lesen als „ein Wert war da, lässt sich aber nicht darstellen“, nicht als fehlenden Schlüssel. Das ursprüngliche Literal ist aus dem Dokument-JSON nicht zurückholbar, eine Pipeline, der das wichtig ist, sollte das Objekt also loggen und die Datei als verdächtig behandeln, statt einen Default einzusetzen
Warum konnte ein einzelnes NaN einen SVG- oder JSON-Export abbrechen?
Weil PLDoubleToStr, der invariante Zahlenformatter hinter Content-Streams, SVG, XML, CSV und dem größten Teil des JSON in der Bibliothek, seine Eingabe skalierte und Round aufrief — und Round(NaN) löst auf Targets wie Win32 ein EInvalidOp aus, wo Delphi die x87-Invalid-Operation-Exception unmaskiert lässt. Die Exception feuerte, nachdem der Writer bereits einen Teil seiner Ausgabe emittiert hatte, eine einzige entartete Messung, ein 0/0 in einer Metrik oder ein von einem Aufrufer übergebenes NaN hinterließ also eine abgeschnittene Datei. PLDoubleToStr liefert für NaN jetzt 0, und sein Integer-Zweig klemmt wie der Bruchzweig bei ±9.2e18, sodass auch Infinity als endliches Literal herauskommt
Null ist die richtige Antwort für einen Content-Stream, wo ein Zahlslot eine Zahl halten muss, und die falsche Antwort für einen Report, wo 0 eine plausible Messung ist. JSON-Writer, die den Unterschied behalten müssen, benutzen PLJSONNumber(Value, Decimals) aus PDFlibExtra, das für NaN oder Infinity null schreibt und sonst invariante Ziffern. PLJSONNumber trägt jetzt GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON und die Barcode-, Deskew-, Structured-Text- und PDF/VCR-Reports; der Deskew-Report schrieb für einen nicht endlichen Winkel früher 0 und schreibt jetzt null
uses
SysUtils, PDFlibTypes, PDFlibExtra;
function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
B: PLStringBuilder;
begin
B := PLStringBuilder.Create(128);
try
// Erst jedes Double zu Text formatieren; PLJSONNumber schreibt null
// für NaN oder Infinity und benutzt immer einen Dezimalpunkt
B.Append('{"page":').Append(Page)
.Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
.Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
.Append('}');
// Niemals B.Append(Angle): die Double-Überladung folgt dem User-Locale
Result := B.ToString;
finally
B.Free;
end;
end;
Wo schleicht sich das User-Locale noch ins JSON ein?
Durch jeden Formatter, der die regionalen Einstellungen befragt — und eine vollständige Auditierung der maschinenlesbaren Ausgabe fand genau einen übrig: maxAcceptedMeanError in GetSimilarImageDeduplicationReportJSON, geschrieben mit PLFloatToStr, einem dünnen Wrapper um FloatToStr. Auf einem Desktop, dessen Dezimaltrenner ein Komma ist, enthielt der Report "maxAcceptedMeanError":1,5, was ein JSON-Parser als den Wert 1 gefolgt von einem herrenlosen Token liest. Das Feld meldet den schlechtesten akzeptierten Pixel-Fehler aus der perzeptuellen Bild-Deduplizierung und läuft jetzt durch PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Eine verbleibende Falle ist PLStringBuilder: Unter Delphi ist es ein schlichter Alias von System.SysUtils.TStringBuilder, dessen Append(Double)-Überladung durch das User-Locale formatiert, während FPC-Builds stattdessen eine Bibliotheksklasse benutzen — ein Test unter Free Pascal oder auf einer en-US-Maschine fängt das nie
uses
System.SysUtils, System.JSON, PDFlibrary;
var
Lib: TPDFlib;
Report: WideString;
Parsed: TJSONValue;
begin
// Einen deutschen oder französischen Desktop im Testlauf reproduzieren
FormatSettings.DecimalSeparator := ',';
Lib := TPDFlib.Create;
try
// Ein Fixture benutzen, das wirklich beinahe duplizierte Bilder enthält,
// sonst ist der mittlere Fehler 0 und der Bug bleibt versteckt
Lib.LoadFromFile('scanned-batch.pdf', '');
// Trockenlauf mit Schwellwerten 2, 2, 4: das Dokument wird nicht verändert
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;
Eine Regressionssuite für JSON-Ausgabe braucht drei Fixtures, um ehrlich zu bleiben: eine Seite mit -.25, +1.5 und 007.5, ein Objekt mit einem 400-stelligen Integer und einen beliebigen Report unter einem Komma-Locale — jeweils validiert mit einem strengen Parser statt mit bloßem Hinsehen. Objekt-JSON, Dokument-JSON und die Analyse-Reports in der PDF Library for Delphi teilen sich dieselben Zahlenregeln über Delphi, C++Builder und Free Pascal hinweg; die vollständige Feature-Liste steht auf der PDF Library for Delphi Produktseite