Technischer Artikel

PDF-Zahlen vs. JSON: NaN, Infinity und null in Delphi

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)

PDFlibPas GetObjectJSON schreibt die von RFC 8259 abgelehnten PDF-Zahl-Tokens ziffernweise um: -.25 wird -0.25, +1.5 verliert das Plus, 007.5 wirft seine führenden Nullen ab, und Nachkommastellen wie 1.250000 überleben, weil eine Formatierung aus dem gespeicherten Double binäres Rauschen hinzufügen würde
Der alte Writer hängte den exakt geparsten Text an, der eigene Reader der Bibliothek brach mit Invalid JSON number ab, und Fehler 105 zerbrach einen Roundtrip, den die Exportseite Erfolg genannt hatte
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

PDFlibPas PDFNumberTextToJSON liest das Vorzeichen, sammelt die Ziffern um einen einzelnen Dezimalpunkt und wendet nur die von JSON verlangten Eingriffe an, während jedes andere Zeichen oder eine leere Ziffernfolge auf PLJSONNumber zurückfällt, das für NaN und Infinity null statt einer Zahl schreibt
Umbuchstabieren schlägt Neuberechnen: Der Tokenizer hat .5 und 4. bereits unterwegs gebügelt, der Writer behält also jede überlebende Ziffer, und der Roundtrip rekonstruiert exakt denselben Wert
  • -.25 wird -0.25, und +.5 wird 0.5
  • +1.5 wird 1.5
  • 007.5 wird 7.5, während 0.75 bleibt, wie es ist
  • 4. wird 4, falls so ein Token den Writer überhaupt erreicht
  • 2.22221 und 1.250000 behalten 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

PDFlibPas stoppt NaN und Infinity auf drei Wegen: AddPageMatrix, ScalePage und RedactRegion weisen nicht endliche Argumente vorne ab, PLDoubleToStr schreibt 0 für Content-Stream-Slots, und PLJSONNumber schreibt in Reports null, wo null als plausible Messung gelesen würde — nachdem Round(NaN) mitten im Export ein EInvalidOp ausgelöst hatte
Null ist die richtige Antwort für einen Content-Stream und die falsche für einen Report, deshalb reichen die Report-Writer jedes Double an PLJSONNumber durch und lassen null sagen: Der Wert war da, ließ sich aber nicht darstellen
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