Tehnički članak

PDF brojevi vs JSON: NaN, Infinity i null u Delphiju

PDF Library for Delphi (PDFlibPas) ispušta valjani JSON za svaki PDF broj od v3.539.31. GetObjectJSON prepravlja tokene koje ISO 32000-1 prima, a RFC 8259 odbija, poput -.25, +1.5 i 007.5, u -0.25, 1.5 i 7.5 znamenku po znamenku; GetDocumentJSON i analitička izvješća zapisuju null za NaN i Infinity; a PLDoubleToStr zapisuje 0 za NaN umjesto da usred izvoza podigne EInvalidOp. Prije ispravka biblioteka je mogla proizvesti JSON koji njezin vlastiti reader odbija učitati

Zašto valjani PDF broj slomi JSON?

Jer se dvije gramatike ne slažu u četiri sitna detalja, a PDF parser koji poštuje izvorni tekst nosit će te detalje ravno u izlaz. ISO 32000-1 §7.3.3 dopušta broju da kreće plusom, izostavi cijeli dio (.5), završi na goloj točki (4.) i nosi vodeće nule (007.5). RFC 8259 §6 ne dopušta ništa od toga: neobavezni minus, cijeli dio koji je ili 0 ili kreće znamenkom 1 do 9, i barem jedna znamenka iza svake decimalne točke. Proizvođači smiju pisati PDF forme, i dosta generatora te ručno uređivanih datoteka to i čini

Curenje je došlo iz namjerne preciznosti. Od v3.539.19 TPDFNumeric.Output vraća točan tekst koji je tokenizer parsirao za realne brojeve, i to je ono što kalibriranu vrijednost boje čini točnom pri spremanju, kako opisuje očuvanje parsirane PDF decimalne preciznosti. Tokenizer već na ulazu krpi .5 u 0.5 i 4. u 4.0, a cijeli brojevi preformiliraju se iz svoje vrijednosti, pa se +3 vraća kao 3. Ono što preživi doslovno jest ostatak: točka s predznakom na početku (-.25), izričit plus na realnom broju (+1.5) i vodeće nule (007.5). Stari je object writer nadodavao Output odmah iza "value":, a TJSONParser.ParseNumber u vlastitom readeru biblioteke staje na svakom od tih s „Invalid JSON number", pa je izvoz uspio, a ponovni uvoz pao s PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON prepravlja PDF brojne tokene koje RFC 8259 odbija znamenku po znamenku: -.25 postaje -0.25, +1.5 gubi plus, 007.5 baca vodeće nule, a razlomkove znamenke poput 1.250000 prežive, jer bi formatiranje iz pohranjenog Doublea dodalo binarni šum
Stari je writer nadodavao točno parsirani tekst, vlastiti je reader biblioteke stao s Invalid JSON number, a pogreška 105 slomila je round trip koji je strana izvoza zvala uspjehom
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 jest polje zapisano kao [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 i noviji: vrijednosti stižu kao -0.25, 1.5 i 7.5

    // SetObjectJSON ne prima opcije, pa predaj 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;

Kako PDFNumberTextToJSON čuva svaku znamenku?

PDFNumberTextToJSON token ponovno izgovara umjesto da ga ponovno izračuna iz Doublea. Funkcija u PDFlibObjectJSON pročita neobavezni predznak, pokupi znamenke prije i iza jedne decimalne točke, a onda primijeni samo izmjene koje JSON traži: baca plus, reže vodeće nule uz zadržavanje jedne, doda 0 kad je cijeli dio prazan, baca golu završnu točku i vrati minus. Token koji sadrži bilo koji drugi znak, ili nijednu znamenku, vraća se na PLJSONNumber(Value, 10), koja zapisuje null kad vrijednost nije konačna

PDFlibPas PDFNumberTextToJSON pročita predznak, pokupi znamenke oko jedne decimalne točke i primijeni samo izmjene koje JSON traži, dok se za bilo koji drugi znak ili prazan niz znamenki vraća PLJSONNumber, koja za NaN i Infinity zapiše null umjesto broja
Ponovno izgovaranje bije ponovno računanje: tokenizer je već na ulazu krpio .5 i 4., pa writer zadržava svaku preživjelu znamenku i round trip stvara točno istu vrijednost
  • -.25 postaje -0.25, a +.5 postaje 0.5
  • +1.5 postaje 1.5
  • 007.5 postaje 7.5, dok 0.75 ostaje kakav jest
  • 4. postaje 4 ako takav token ikad stigne do writera
  • 2.22221 i 1.250000 zadržavaju svaku razlomkovnu znamenku, zaostale nule uključeno

Formatiranje iz pohranjenog Doublea bilo bi kraće i pogrešno, iz istog razloga iz kojeg postoji ispravak preciznosti: zadana izlazna preciznost je četiri decimale, pa čak i pretvorba punom preciznošću može dodati binarni šum decimalnom literalu. Zadržavanje znamenki znači da SetObjectJSON i ImportObjectJSON, koji svaki JSON brojni tekst predaju PDF tokenizeru, stvaraju točno istu vrijednost. Jamstvo pokriva vrijednost, ne bajtove: nakon ponovnog uvoza -.25 čuva se i sprema kao -0.25. Oba su zapisa jednaka pod §7.3.3, ali će byte-level diff označiti promjenu, pa ciklus izvoza i uvoza ne tretirajte kao no-op na dokumentu čije bajtove pokriva potpis

Što se dogodi s brojem koji JSON ne može predstaviti?

GetDocumentJSON sada zapisuje null za svaki broj koji je NaN ili beskonačan, jer RFC 8259 §6 nema sintaksu ni za jedno ni za drugo. Infinity je lakše proizvesti nego što zvuči: PDF tokenizer nakuplja znamenke ponavljanim množenjem u Double, koji vrhunac ima blizu 1.8 × 10308, pa cijelobrojni literal malo preko 300 znamenki tiho postane +Inf. Poštene datoteke nikad ne sadrže takav literal; fuzzirane i neprijateljske ga sadrže, zato pripadaju istom test korpusu kao slučajevi u otvrđivanju Pascal PDF parsera protiv zlonamjernih datoteka. Stari je document writer formatirao necijele brojeve s Str(D:0:6), a za +Inf to zapiše tekst +Inf, koji nijedan JSON konzument neće parsirati

null je namjerno gubitkiv. Konzumenti izlaza GetDocumentJSON moraju prihvatiti null bilo gdje gdje se broj može pojaviti, i trebaju ga čitati kao „vrijednost je bila prisutna ali se ne može predstaviti", a ne kao nepostojeći ključ. Izvorni literal nije oporavljiv iz JSON-a dokumenta, pa pipeline kojem je stalo neka zapiše objekt u log i tretira datoteku kao sumnjivu umjesto da nametne zadanu vrijednost

Zašto je jedan NaN mogao prekinuti SVG ili JSON izvoz?

Jer se PLDoubleToStr, invarijantni formater brojeva iza content streamova, SVG-a, XML-a, CSV-a i većine JSON-a u biblioteci, skalirao svoj ulaz i zvao Round, a Round(NaN) podiže EInvalidOp na targetima poput Win32, gdje Delphi ostavlja x87 iznimku nevaljane operacije nemaskiranom. Iznimka je prasnula nakon što je writer već ispuštao dio svog izlaza, pa je jedno degenerirano mjerenje, 0/0 u metrici ili NaN koji je uputio pozivatelj, ostavilo za sobom odrezanu datoteku. PLDoubleToStr sada vraća 0 za NaN, a njegova cijelobrojna grana steže se na ±9.2e18 kao i razlomkovna, pa i Infinity izađe kao konačan literal

Nula je pravi odgovor za content stream, gdje brojno mjesto mora držati broj, i krivi odgovor za izvješće, gdje je 0 vjerodostojno mjerenje. JSON writeri koji moraju čuvati tu razliku koriste PLJSONNumber(Value, Decimals) iz PDFlibExtra, koji za NaN ili Infinity zapisuje null, a inače invarijantne znamenke. PLJSONNumber sada stoji iza GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON te izvješća o barkodovima, deskewu, strukturiranom tekstu i PDF/VCR-u; deskew izvješće je nekada za ne-konačni kut zapisivalo 0, a sada zapisuje null

PDFlibPas zaustavlja NaN i Infinity na tri načina: AddPageMatrix, ScalePage i RedactRegion unaprijed odbijaju ne-konačne argumente, PLDoubleToStr zapisuje 0 za mjesta u content streamu, a PLJSONNumber zapisuje null u izvješćima, gdje bi nula čitala kao vjerodostojno mjerenje, nakon što je Round(NaN) nekada podizao EInvalidOp usred izvoza
Nula je pravi odgovor za content stream i krivi za izvješće, pa writeri izvješća predaju svaki Double u PLJSONNumber i puste null da kaže da je vrijednost bila prisutna ali nepredstavljiva
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Svaki Double prvo formatiraj u tekst; PLJSONNumber zapisuje null
    // za NaN ili Infinity i uvijek koristi decimalnu točku
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Nikad B.Append(Angle): Double overload slijedi korisnički lokal
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Gdje se korisnički lokal još uvijek ušulja u JSON?

Kroz bilo koji formater koji konzultira regionalne postavke, a potpuna revizija strojno čitljivog izlaza našla je točno jedan preostali: maxAcceptedMeanError u GetSimilarImageDeduplicationReportJSON, zapisivan s PLFloatToStr, tankim omotom oko FloatToStr. Na radnoj površini čiji je decimalni separator zarez, izvješće je sadržavalo "maxAcceptedMeanError":1,5, što JSON parser čita kao vrijednost 1 iza koje slijedi strani token. Polje javlja najgoru prihvaćenu pixel pogrešku iz perceptual deduplikacije slika, i sada prolazi kroz PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Preostala je zamka PLStringBuilder: na Delphiju to je puki alias od System.SysUtils.TStringBuilder, čiji Append(Double) overload formatira kroz korisnički lokal, dok FPC buildovi umjesto toga koriste klasu biblioteke, pa to test na Free Pascalu ili en-US stroju nikad ne uhvati

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduciraj njemačku ili francusku radnu površinu unutar test runa
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Koristi fixture koji stvarno sadrži gotovo duplicirane slike,
    // inače je srednja pogreška 0 i bug ostaje skriven
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Suhi run s pragovima 2, 2, 4: dokument se ne mijenja
    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;

Regresijskom suiteu za JSON izlaz trebaju tri fixturea da ostane pošten: stranica koja nosi -.25, +1.5 i 007.5, objekt koji drži 400-znamenkasti cijeli broj, i bilo koje izvješće vrtjeno pod zarez-lokalom, svako validirano strogim parserom umjesto pogledom. Object JSON, document JSON i analitička izvješća u PDF Library for Delphi dijele ista pravila brojeva na Delphiju, C++Builderu i Free Pascalu; potpuni popis značajki je na stranici proizvoda PDF Library for Delphi