Articolo tecnico

Numeri PDF vs JSON: NaN, Infinity e null in Delphi

PDF Library for Delphi (PDFlibPas) emette JSON valido per ogni numero PDF dalla v3.539.31. GetObjectJSON riscrive i token che ISO 32000-1 accetta ma RFC 8259 rifiuta, come -.25, +1.5 e 007.5, in -0.25, 1.5 e 7.5 cifra per cifra; GetDocumentJSON e i report di analisi scrivono null per NaN e Infinity; e PLDoubleToStr scrive 0 per NaN invece di sollevare EInvalidOp a export a metà. Prima della correzione, la libreria poteva produrre JSON che il suo stesso lettore rifiutava di ricaricare

Perché un numero PDF valido rompe il JSON?

Perché le due grammatiche non sono d'accordo su quattro piccoli dettagli, e un parser PDF che rispetta il testo sorgente porta quei dettagli dritti nell'output. ISO 32000-1 §7.3.3 consente a un numero di iniziare con un segno più, omettere la parte intera (.5), finire su un punto nudo (4.) e portare zeri iniziali (007.5). RFC 8259 §6 non ne consente nessuno: un segno meno opzionale, una parte intera che è 0 o inizia con 1 a 9, e almeno una cifra dopo ogni punto decimale. I producer sono liberi di scrivere le forme PDF, e un sacco di generatori e file modificati a mano lo fanno

La fuga veniva da una funzionalità di precisione deliberata. Dalla v3.539.19, TPDFNumeric.Output restituisce il testo esatto che il tokenizer ha analizzato per i numeri reali, che è ciò che mantiene esatto un valore di colore calibrato al salvataggio, come descritto in conservare la precisione decimale PDF analizzata. Il tokenizer già corregge .5 in 0.5 e 4. in 4.0 all'ingresso, e gli interi vengono riformattati dal loro valore, così +3 torna come 3. Ciò che sopravvive verbatim è il resto: un punto iniziale con segno (-.25), un più esplicito su un reale (+1.5) e zeri iniziali (007.5). Il vecchio scrittore di oggetti appendeva Output subito dopo "value":, e TJSONParser.ParseNumber nel lettore della libreria stessa si fermava su ognuno di questi con «Invalid JSON number», così l'export riusciva e il re-import falliva con PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON riscrive i token numerici PDF che RFC 8259 rifiuta cifra per cifra: -.25 diventa -0.25, +1.5 perde il più, 007.5 toglie i suoi zeri iniziali, e le cifre frazionarie come 1.250000 sopravvivono, perché formattare dal Double memorizzato aggiungerebbe rumore binario
Il vecchio scrittore appendeva il testo analizzato esatto, il lettore della libreria stessa si fermava con Invalid JSON number, e l'errore 105 rompeva un round trip che il lato export dichiarava un successo
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');

    // L'oggetto 12 è un array scritto come [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 e successive: i valori arrivano come -0.25, 1.5 e 7.5

    // SetObjectJSON non accetta opzioni, quindi passa 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;

Come fa PDFNumberTextToJSON a conservare ogni cifra?

PDFNumberTextToJSON rispella il token invece di ricalcolarlo da un Double. La funzione in PDFlibObjectJSON legge un segno opzionale, raccoglie le cifre prima e dopo un singolo punto decimale, e poi applica solo le modifiche che il JSON esige: toglie un più, toglie gli zeri iniziali mantenendone uno, fornisce 0 quando la parte intera è vuota, toglie un punto finale nudo e rimette il meno. Un token che contiene qualsiasi altro carattere, o nessuna cifra affatto, ripiega su PLJSONNumber(Value, 10), che scrive null quando il valore non è finito

PDFlibPas PDFNumberTextToJSON legge il segno, raccoglie le cifre attorno a un singolo punto decimale e applica solo le modifiche che il JSON esige, mentre qualsiasi altro carattere o una sequenza di cifre vuota ripiega su PLJSONNumber, che scrive null per NaN e Infinity invece di un numero
Rispellare batte ricalcolare: il tokenizer ha già corretto .5 e 4. all'ingresso, quindi lo scrittore conserva ogni cifra superstite e il round trip ricrea esattamente lo stesso valore
  • -.25 diventa -0.25, e +.5 diventa 0.5
  • +1.5 diventa 1.5
  • 007.5 diventa 7.5, mentre 0.75 resta com'è
  • 4. diventa 4 se un token simile arriva mai allo scrittore
  • 2.22221 e 1.250000 conservano ogni cifra frazionaria, zeri finali inclusi

Formattare dal Double memorizzato sarebbe stato più corto e sbagliato, per la stessa ragione per cui esiste la correzione di precisione: la precisione di output di default è quattro decimali, e persino una conversione a piena precisione può aggiungere rumore binario a un letterale decimale. Conservare le cifre significa che SetObjectJSON e ImportObjectJSON, che passano ogni testo numerico JSON al tokenizer PDF, ricreano esattamente lo stesso valore. La garanzia copre il valore, non i byte: dopo un re-import, -.25 viene memorizzato e salvato come -0.25. Entrambe le grafie sono uguali sotto §7.3.3, ma un diff a livello di byte segnalerà la modifica, quindi non trattare un ciclo export e import come un no-op su un documento i cui byte sono coperti da una firma

Che cosa accade a un numero che il JSON non può rappresentare?

GetDocumentJSON ora scrive null per qualsiasi numero che sia NaN o infinito, perché RFC 8259 §6 non ha sintassi per nessuno dei due. Infinity è più facile da produrre di quanto suoni: il tokenizer PDF accumula le cifre con moltiplicazioni ripetute in un Double, che ha un tetto vicino a 1.8 × 10308, quindi un letterale intero poco sopra le 300 cifre diventa silenziosamente +Inf. I file onesti non contengono mai un letterale simile; quelli fuzzati e ostili sì, ed è per questo che appartengono allo stesso corpus di test dei casi in hardening di un parser PDF Pascal contro file ostili. Il vecchio scrittore di documenti formattava i non interi con Str(D:0:6), e per +Inf scrive il testo +Inf, che nessun consumatore JSON analizzerà

Il null è volutamente lossy. I consumatori dell'output di GetDocumentJSON devono accettare null ovunque possa apparire un numero, e dovrebbero leggerlo come «un valore era presente ma non può essere rappresentato», non come chiave mancante. Il letterale originale non è recuperabile dal JSON del documento, quindi una pipeline che ci tiene dovrebbe loggare l'oggetto e trattare il file come sospetto invece di sostituire un default

Perché un singolo NaN poteva abortire un export SVG o JSON?

Perché PLDoubleToStr, il formattatore numerico invariante dietro ai content stream, SVG, XML, CSV e alla maggior parte del JSON della libreria, scalava il proprio input e chiamava Round, e Round(NaN) solleva EInvalidOp su target come Win32, dove Delphi lascia non mascherata l'eccezione x87 di operazione non valida. L'eccezione scattava dopo che lo scrittore aveva già emesso parte del proprio output, quindi una sola misura degenerata, uno 0/0 in una metrica o un NaN passato da un chiamante, lasciava dietro un file troncato. PLDoubleToStr ora restituisce 0 per NaN, e il suo ramo intero si limita a ±9.2e18 come il ramo frazionario, quindi anche Infinity esce come letterale finito

Zero è la risposta giusta per un content stream, dove uno slot numerico deve contenere un numero, e la risposta sbagliata per un report, dove 0 è una misura plausibile. Gli scrittori JSON che devono conservare la differenza usano PLJSONNumber(Value, Decimals) da PDFlibExtra, che scrive null per NaN o Infinity e cifre invarianti altrimenti. PLJSONNumber ora sostiene GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON e i report barcode, deskew, structured text e PDF/VCR; il report deskew in precedenza scriveva 0 per un angolo non finito e ora scrive null

PDFlibPas ferma NaN e Infinity in tre modi: AddPageMatrix, ScalePage e RedactRegion rifiutano argomenti non finiti all'ingresso, PLDoubleToStr scrive 0 per gli slot dei content stream, e PLJSONNumber scrive null nei report, dove zero leggerebbe come una misura plausibile, dopo che Round(NaN) sollevava EInvalidOp a metà export
Zero è la risposta giusta per un content stream e quella sbagliata per un report, quindi gli scrittori di report passano ogni Double a PLJSONNumber e lasciano che null dica che il valore era presente ma non rappresentabile
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatta ogni Double in testo prima; PLJSONNumber scrive null
    // per NaN o Infinity e usa sempre un punto decimale
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Mai B.Append(Angle): l'overload Double segue il locale dell'utente
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Dove il locale dell'utente si intrufola ancora nel JSON?

Attraverso qualunque formattatore che consulta le impostazioni regionali, e un audit completo dell'output leggibile dalla macchina ne ha trovato esattamente uno rimasto: maxAcceptedMeanError in GetSimilarImageDeduplicationReportJSON, scritto con PLFloatToStr, un sottile wrapper su FloatToStr. Su un desktop il cui separatore decimale è una virgola, il report conteneva "maxAcceptedMeanError":1,5, che un parser JSON legge come il valore 1 seguito da un token vagante. Il campo riporta il peggiore errore di pixel accettato dalla deduplicazione percettiva delle immagini, e ora passa per PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Una trappola residua è PLStringBuilder: su Delphi è un semplice alias di System.SysUtils.TStringBuilder, il cui overload Append(Double) formatta attraverso il locale dell'utente, mentre le build FPC usano una classe della libreria, quindi un test su Free Pascal o su una macchina en-US non lo coglierà mai

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Riproduci un desktop tedesco o francese dentro l'esecuzione del test
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Usa una fixture che contiene davvero immagini quasi duplicate,
    // altrimenti l'errore medio è 0 e il bug resta nascosto
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run con soglie 2, 2, 4: il documento non viene modificato
    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;

Una suite di regression per l'output JSON ha bisogno di tre fixture per restare onesta: una pagina che porta -.25, +1.5 e 007.5, un oggetto che contiene un intero di 400 cifre, e un qualunque report eseguito sotto un locale a virgola, ognuno validato con un parser rigoroso piuttosto che a occhio. Object JSON, document JSON e i report di analisi in PDF Library for Delphi condividono le stesse regole numeriche su Delphi, C++Builder e Free Pascal; la lista completa delle funzionalità è sulla pagina di prodotto di PDF Library for Delphi