Odborný článok

Gramatika PDF čísel vs JSON: NaN, Infinity a null v Delphi

PDF Library for Delphi (PDFlibPas) emituje platný JSON pre každé PDF číslo od v3.539.31. GetObjectJSON prepisuje tokeny, ktoré ISO 32000-1 prijíma, ale RFC 8259 odmieta, ako -.25, +1.5 a 007.5, na -0.25, 1.5 a 7.5 číslicu za číslicu; GetDocumentJSON a analytické reporty zapisujú pre NaN a Infinity null; a PLDoubleToStr zapíše pre NaN 0 namiesto vyhodenia EInvalidOp v polovici exportu. Pred opravou dokázala knižnica vyrobiť JSON, ktorý jej vlastný čítač odmietol načítať späť

Prečo pokazí platné PDF číslo JSON?

Pretože sa obe gramatiky nezhodnú na štyroch drobných detailoch a PDF parser, ktorý rešpektuje zdrojový text, zanesie tie detaily rovno do výstupu. ISO 32000-1 §7.3.3 dovoľuje číslu začať plusom, vynechať celú časť (.5), skončiť na holom bode (4.) a niesť úvodné nuly (007.5). RFC 8259 §6 nedovoľuje nič z toho: voliteľné mínus, celú časť buď 0, alebo začínajúcu číslicou 1 až 9, a aspoň jednu číslicu po desatinnej bodke. Producenti môžu PDF tvary pokojne zapisovať a kopec generátorov aj ručne upravovaných súborov to robí

Únik prišiel z cielenej funkcie pre presnosť. Od v3.539.19 vracia TPDFNumeric.Output pre reálne čísla presný text, ktorý tokenizer naparsoval, a práve to drží kalibrovanú farebnú hodnotu pri ukladaní presnú, ako popisuje článok o zachovaní naparsovanej PDF desatinnej presnosti. Tokenizer už na vstupe opraví .5 na 0.5 a 4. na 4.0 a celé čísla sa reformátujú z ich hodnoty, takže +3 sa vráti ako 3. Slovo od slova prežije zvyšok: bodka so znamienkom na začiatku (-.25), explicitný plus pri reálnom čísle (+1.5) a úvodné nuly (007.5). Starý objektový zapisovač pripojil Output hneď za "value": a TJSONParser.ParseNumber vo vlastnom čítači knižnice na každom z nich zastane s hláškou „Invalid JSON number“, takže export uspel a re-import zlyhal s PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

GetObjectJSON v PDFlibPas prepisuje PDF číselné tokeny, ktoré RFC 8259 odmieta, číslicu za číslicu: -.25 sa stane -0.25, +1.5 príde o plus, 007.5 stratí úvodné nuly a desatinné číslice ako 1.250000 prežijú, lebo formátovanie z uloženého Double by pridalo binárny šum
Starý zapisovač pripojil presný naparsovaný text, vlastný čítač knižnice zastal s Invalid JSON number a chyba 105 rozbila round trip, ktorý exportová strana označila za úspech
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 zapísané ako [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 a novšie: hodnoty dorazia ako -0.25, 1.5 a 7.5

    // SetObjectJSON neprijíma žiadne options, preto 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;

Ako PDFNumberTextToJSON zachová každú číslicu?

PDFNumberTextToJSON token prepíše po písmenách namiesto toho, aby ho prepočítavala z Double. Funkcia v PDFlibObjectJSON prečíta voliteľné znamienko, zozbiera číslice pred a po jedinej desatinnej bodke a potom aplikuje len úpravy, ktoré JSON vyžaduje: zloží plus, zoškrtnie úvodné nuly s tým, že jednu ponechá, dodá 0, keď je celá časť prázdna, zloží holú koncovú bodku a mínus vráti naspäť. Token obsahujúci akýkoľvek iný znak alebo žiadne číslice spadne k PLJSONNumber(Value, 10), ktoré zapíše null, keď hodnota nie je konečná

PDFNumberTextToJSON v PDFlibPas prečíta znamienko, zozbiera číslice okolo jedinej desatinnej bodky a aplikuje len úpravy, ktoré JSON vyžaduje, zatiaľ čo akýkoľvek iný znak alebo prázdny beh číslic spadne k PLJSONNumber, ktoré pre NaN a Infinity zapíše null namiesto čísla
Prepisovanie po písmenách bije prepočet: tokenizer už na vstupe opravil .5 a 4., takže zapisovač ponechá každú preživšiu číslicu a round trip znovu vytvorí presne tú istú hodnotu
  • -.25 sa stane -0.25 a +.5 sa stane 0.5
  • +1.5 sa stane 1.5
  • 007.5 sa stane 7.5, zatiaľ čo 0.75 ostanú, aké je
  • 4. sa stane 4, ak taký token niekedy dorazí k zapisovaču
  • 2.22221 a 1.250000 ponechajú každú desatinnú číslicu, koncové nuly vrátane

Formátovanie z uloženého Double by bolo kratšie a zlé, a to z rovnakého dôvodu, pre ktorý existuje oprava presnosti: predvolená výstupná presnosť je štyri desatinné miesta a aj konverzia s plnou presnosťou dokáže do desatinného literálu pridať binárny šum. Ponechanie číslic znamená, že SetObjectJSON a ImportObjectJSON, ktoré podávajú text každého JSON čísla PDF tokenizeru, znovu vytvoria presne tú istú hodnotu. Garancia sa týka hodnoty, nie bajtov: po re-importe sa -.25 ukladá a zapisuje ako -0.25. Obe hláskovania sú podľa §7.3.3 rovnaké, ale bajtovoúrovňový diff zmenu označí, takže cyklus exportu a importu neberte ako no-op na dokumente, ktorého bajty kryje podpis

Čo sa stane s číslom, ktoré JSON nedokáže vyjadriť?

GetDocumentJSON teraz zapisuje null pre každé číslo, ktoré je NaN alebo nekonečné, lebo RFC 8259 §6 pre žiadne z nich nemá syntax. Nekonečno sa vyrobiť dá ľahšie, než znie: PDF tokenizer zbieha číslice opakovaným násobením do Double, ktorý vrcholí pri 1.8 × 10308, takže celočíselný literál trochu cez 300 číslic sa potichu stane +Inf. Poctivé súbory taký literál nikdy neobsahujú; fuzzerom vyrobené a nepriateľské áno, a preto patria do toho istého testovacieho korpusu ako prípady z článku o posilňovaní Pascal PDF parsera proti škodlivým súborom. Starý dokumentový zapisovač formátoval neceločíselné hodnoty cez Str(D:0:6) a pre +Inf zapísal text +Inf, ktorý nerozparseuje žiadny JSON konzument

null je zámerne stratové. Konzumenti výstupu GetDocumentJSON musia akceptovať null hocikde, kde sa môže objaviť číslo, a mali by ho čítať ako „hodnota bola prítomná, ale nedá sa vyjadriť“, nie ako chýbajúci kľúč. Pôvodný literál sa z dokumentového JSON nedá obnoviť, takže pipeline, ktorej na tom záleží, má objekt zalogovať a súbor považovať za podozrivý namiesto náhrady predvolenou hodnotou

Prečo dokázalo jediné NaN prerušiť SVG či JSON export?

Lebo PLDoubleToStr, invariantný formatter čísel stojaci za content streammi, SVG, XML, CSV a väčšinou JSON v knižnici, prenásobil vstup a volal Round a Round(NaN) hodí EInvalidOp na cieľoch ako Win32, kde Delphi necháva výnimku x87 invalid-operation nezamaskovanú. Výnimka vystrelila potom, čo zapisovač už emitoval časť výstupu, takže jedno degenerované meranie, 0/0 v metrike alebo NaN podané volajúcim, zanechalo za sebou skrátený súbor. PLDoubleToStr teraz vracia pre NaN 0 a celočíselná vetva sa svorkuje na ±9.2e18 ako vetva desatinná, takže aj Infinity vychádza ako konečný literál

Nula je správna odpoveď pre content stream, kde číselný slot musí držať číslo, a zlá odpoveď pre report, kde 0 je vierohodné meranie. JSON zapisovače, ktoré musia zachovať ten rozdiel, používajú PLJSONNumber(Value, Decimals) z PDFlibExtra, ktoré pre NaN či Infinity zapíše null a inak invariantné číslice. PLJSONNumber teraz stojí za GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON a reportmi barcode, deskew, structured text a PDF/VCR; deskew report predtým zapisoval pre nekonečný uhol 0 a teraz zapisuje null

PDFlibPas zastaví NaN a Infinity troma spôsobmi: AddPageMatrix, ScalePage a RedactRegion odmietnu nekonečné argumenty hneď na vstupe, PLDoubleToStr zapisuje 0 do slotov content streamov a PLJSONNumber zapisuje null v reportoch, kde by nula pôsobila ako vierohodné meranie, potom čo Round(NaN) vyhadzovalo EInvalidOp v polovici exportu
Nula je správna odpoveď pre content stream a zlá odpoveď pre report, takže reportoví zapisovači podávajú každé Double PLJSONNumber a nechajú null povedať, že hodnota bola prítomná, ale nedala sa vyjadriť
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Každé Double najprv naformátuj na text; PLJSONNumber zapíše null
    // pre NaN či Infinity a vždy používa desatinnú bodku
    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 pre Double sa riadi locale používateľa
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Kde sa do JSON ešte vkráda locale používateľa?

Cez ktorýkoľvek formatter, ktorý nazrie do regionálnych nastavení, a úplný audit strojovo čitateľného výstupu našiel zostávajúci presne jeden: maxAcceptedMeanError v GetSimilarImageDeduplicationReportJSON, zapisovaný cez PLFloatToStr, tenký obal nad FloatToStr. Na ploche s čiarkou ako desatinným oddeľovačom obsahoval report "maxAcceptedMeanError":1,5, čo JSON parser prečíta ako hodnotu 1 nasledovanú túlavým tokenom. Pole hlási najhoršiu akceptovanú pixelovú chybu z perceptuálnej deduplikácie obrázkov a teraz prechádza cez PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Zvyšná pasca je PLStringBuilder: na Delphi je to obyčajný alias System.SysUtils.TStringBuilder, ktorého overload Append(Double) formátuje cez locale používateľa, zatiaľ čo FPC zostavenia používajú namiesto toho triedu z knižnice, takže test na Free Pascale alebo stroji en-US ho nikdy nechytí

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reprodukcia nemeckej či francúzskej plochy vnútri testového behu
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Použite fixtúru, ktorá naozaj obsahuje takmer duplicitné obrázky,
    // inak je priemerná chyba 0 a chyba zostane skrytá
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Suchý beh s prahmi 2, 2, 4: dokument sa neupraví
    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á sada pre JSON výstup potrebuje, aby ostala poctivá, tri fixtúry: stranu nesúcu -.25, +1.5 a 007.5, objekt držiaci 400-ciferné celé číslo a ktorýkoľvek report bežiaci pod čiarkovým locale, každú validovanú prísnym parserom namiesto odhadu okom. Object JSON, document JSON aj analytické reporty v PDF Library for Delphi zdieľajú tie isté číselné pravidlá naprieč Delphi, C++Builder a Free Pascal; kompletný zoznam funkcií je na produktovej stránke PDF Library for Delphi