Technický článek

Čísla PDF proti JSON: NaN, Infinity a null v Delphi

PDF Library for Delphi (PDFlibPas) vydává validní JSON pro každé PDF číslo od v3.539.31. GetObjectJSON přepisuje tokeny, které ISO 32000-1 přijímá, ale RFC 8259 odmítá — jako -.25, +1.5 a 007.5 — na -0.25, 1.5 a 7.5 číslici za číslicí; GetDocumentJSON a analytické reporty píší pro NaN a Infinity null; a PLDoubleToStr zapíše pro NaN 0 místo vyhození EInvalidOp v půlce exportu. Před opravou mohla knihovna vyrobit JSON, který její vlastní čtenář odmítl načíst zpět

Proč validní PDF číslo rozbije JSON?

Protože se obě gramatiky neshodnou ve čtyřech drobných detailech a PDF parser, který respektuje zdrojový text, ponese tyhle detaily rovnou do výstupu. ISO 32000-1 §7.3.3 dovolí číslu začít plusovým znaménkem, vynechat celou část (.5), skončit na holé tečce (4.) a nést úvodní nuly (007.5). RFC 8259 §6 nedovolí nic z toho: volitelné minus, celou část buď 0 nebo začínající 1 až 9 a aspoň jednu číslici po každé desetinné tečce. Producers si mohou PDF tvary psát svobodně a řada generátorů i ručně editovaných souborů to dělá

Únik přišel ze záměrné funkce pro přesnost. Od v3.539.19 vrací TPDFNumeric.Output pro reálná čísla přesný text, který tokenizer naparsoval — to je to, co udrží kalibrovanou barvovou hodnotu přesnou při uložení, jak popisuje zachování parsované desetinné přesnosti PDF. Tokenizer už na vstupu opravuje .5 na 0.5 a 4. na 4.0 a celá čísla se přeformátují z hodnoty, takže +3 se vrátí jako 3. Verbatim přežije zbytek: znaménkem začínající tečka (-.25), explicitní plus u reálného čísla (+1.5) a úvodní nuly (007.5). Starý objektový writer přilepil Output hned za "value": a TJSONParser.ParseNumber ve vlastním čtenáři knihovny se na každém z nich zastaví s hláškou „Invalid JSON number“, takže export uspěl a re-import selhal s PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

GetObjectJSON v PDFlibPas přepisuje PDF číselné tokeny, které RFC 8259 odmítá, číslici za číslicí: -.25 se stane -0.25, +1.5 ztratí plus, 007.5 shodí úvodní nuly a zlomkové číslice jako 1.250000 přežijí, protože formátování z uloženého Double by přidalo binární šum
Starý writer přilepil přesně parsovaný text, vlastní čtenář knihovny se zastavil s Invalid JSON number a chyba 105 rozbila round trip, který exportní strana volala úspěchem
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 je pole zapsané jako [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 a novější: hodnoty dorazí jako -0.25, 1.5 a 7.5

    // SetObjectJSON nebere žádné options, takže 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;

Jak PDFNumberTextToJSON drží každou číslici?

PDFNumberTextToJSON token přepisuje písmeno za písmenem místo přepočtu z Double. Funkce v PDFlibObjectJSON přečte volitelné znaménko, posbírá číslice před a za jedinou desetinnou tečkou a pak aplikuje jen úpravy, které JSON vyžaduje: zahodí plus, ořeže úvodní nuly, jednu ponechá, dodá 0, když je celá část prázdná, zahodí holou koncovou tečku a minus vrátí na místo. Token s jakýmkoli jiným znakem, nebo bez jediné číslice, skočí na zálohu PLJSONNumber(Value, 10), která zapíše null, když hodnota není konečná

PDFNumberTextToJSON v PDFlibPas přečte znaménko, posbírá číslice kolem jediné desetinné tečky a aplikuje jen úpravy, které JSON vyžaduje, zatímco jakýkoli jiný znak nebo prázdný běh číslic skočí na PLJSONNumber, která místo čísla zapíše null pro NaN a Infinity
Přepis písmeno po písmenu bije přepočet: tokenizer už na vstupu opravil .5 i 4., takže writer drží každou přeživší číslici a round trip znovu vyrobí přesně tutéž hodnotu
  • -.25 se stane -0.25 a +.5 se stane 0.5
  • +1.5 se stane 1.5
  • 007.5 se stane 7.5, zatímco 0.75 zůstane, jak je
  • 4. se stane 4, pokud se takový token k writerovi vůbec dostane
  • 2.22221 a 1.250000 drží každou zlomkovou číslici, koncové nuly včetně

Formátovat z uloženého Double by bylo kratší a špatně, ze stejného důvodu, proč přesnostní oprava existuje: defaultní výstupní přesnost je čtyři desetinná místa a i plnopřesná konverze může do desetinného literálu vmíchat binární šum. Držení číslic znamená, že SetObjectJSON a ImportObjectJSON, které předávají každý JSON číselný text PDF tokenizeru, znovu vyrobí přesně tutéž hodnotu. Garance se týká hodnoty, ne bajtů: po re-importu se -.25 ukládá a zapisuje jako -0.25. Oba zápisy jsou si podle §7.3.3 rovny, ale bajtový diff změnu označí, takže cyklus export a import neberte jako no-op u dokumentu, jehož bajty kryje podpis

Co se stane s číslem, které JSON nezvládne?

GetDocumentJSON teď píše null pro každé číslo, které je NaN nebo nekonečné, protože RFC 8259 §6 nemá pro obojí žádnou syntaxi. Nekonečno se vyrobí snáz, než zní: PDF tokenizer sčítá číslice opakovaným násobením do Double, který se vyčerpá kolem 1.8 × 10308, takže celočíselný literál o něco přes 300 číslic se potichu stane +Inf. Poctivé soubory takový literál nikdy neobsahují; fuzzované a zákeřné ano, a proto patří do téhož testovacího korpusu jako případy z otužování Pascal parseru PDF proti zákeřným souborům. Starý dokumentový writer formátoval necelá čísla přes Str(D:0:6) a pro +Inf zapíše text +Inf, který neparsuje žádný JSON konzument

null je záměrně ztrátové. Konzumenti výstupu GetDocumentJSON musí null přijmout všude, kde se může objevit číslo, a číst ho jako „hodnota byla přítomná, ale nedá se reprezentovat“, ne jako chybějící klíč. Původní literál se z dokumentového JSON nedozví, takže pipeline, které na něm záleží, by měla objekt zalogovat a soubor považovat za podezřelý, místo dosazení defaultu

Proč dokázalo jediné NaN přerušit SVG nebo JSON export?

Protože PLDoubleToStr, invariantní číselný formátovač za content streamy, SVG, XML, CSV a většinou JSON v knihovně, přenásobil svůj vstup a volal Round a Round(NaN) zvedá EInvalidOp na targetech jako Win32, kde Delphi nechá výjimku x87 invalid-operation odmaskovanou. Výjimka vypukla poté, co writer už část výstupu vyslal, takže jeden degenerovaný měřený údaj, 0/0 v metrice nebo NaN podstrčený volajícím, zanechal useknutý soubor. PLDoubleToStr teď vrací pro NaN 0 a celočíselná větev se stahuje na ±9.2e18 stejně jako zlomková, takže i Infinity vyleze jako konečný literál

Nula je správná odpověď pro content stream, kde číselný slot musí držet číslo, a špatná odpověď pro report, kde je 0 věrohodný měřený údaj. JSON writery, kterým záleží na rozdílu, použijí PLJSONNumber(Value, Decimals) z PDFlibExtra, která pro NaN nebo Infinity píše null a jindy invariantní číslice. PLJSONNumber teď stojí za GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON i reporty pro barcode, deskew, structured text a PDF/VCR; deskew report dřív psal pro nekonečný úhel 0 a teď píše null

PDFlibPas zastavuje NaN a Infinity třemi způsoby: AddPageMatrix, ScalePage a RedactRegion odmítnou nekonečné argumenty hned na vstupu, PLDoubleToStr píše 0 do slotů content streamů a PLJSONNumber píše null v reportech, kde by nula vypadala jako věrohodný měřený údaj, zatímco dřív Round(NaN) zvedalo EInvalidOp v půlce exportu
Nula je správná odpověď pro content stream a špatná pro report, takže report writery předávají každé Double do PLJSONNumber a nechají null říct, že hodnota byla přítomná, ale nedala se reprezentovat
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Nejdřív naformátujte každé Double na text; PLJSONNumber píše null
    // pro NaN nebo Infinity a pořád používá desetinnou tečku
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Nikdy B.Append(Angle): overload pro Double se řídí user locale
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Kam se user locale do JSONu ještě vkrade?

Přes jakýkoli formátovač, který sahá po regional settings, a úplný audit strojově čitelného výstupu našel přesně jeden zbývající: maxAcceptedMeanError v GetSimilarImageDeduplicationReportJSON, psaný přes PLFloatToStr, tenký obal kolem FloatToStr. Na ploše s čárkou jako desetinným oddělovačem report obsahoval "maxAcceptedMeanError":1,5, což JSON parser přečte jako hodnotu 1 následovanou tullým tokenem. Pole hlásí nejhorší přijatou pixelovou chybu z perceptuální deduplikace obrázků a teď jde přes PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Zbývající pastí je PLStringBuilder: na Delphi je to holý alias System.SysUtils.TStringBuilder, jehož overload Append(Double) formátuje přes user locale, zatímco FPC buildy používají knihovní třídu, takže test na Free Pascalu nebo en-US stroji ho nikdy nechytí

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reprodukujte německou či francouzskou plochu přímo v test runu
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Vezměte fixture, která doopravdy obsahuje téměř duplicitní obrázky,
    // jinak je mean error 0 a bug zůstane skrytý
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run s prahy 2, 2, 4: dokument se nezmění
    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;

Regresní suite pro JSON výstup potřebuje tři fixtury, aby zůstala poctivá: stránku nesoucí -.25, +1.5 a 007.5, objekt s celým číslem dlouhým 400 číslic a libovolný report puštěný pod čárkovým locale, každou validovanou přísným parserem, ne okem. Objektový JSON, dokumentový JSON i analytické reporty v PDF Library for Delphi sdílejí stejná pravidla pro čísla napříč Delphi, C++Builder a Free Pascal; kompletní seznam funkcí je na stránce produktu PDF Library for Delphi