Artículo técnico

Números PDF vs JSON: NaN, Infinity y null en Delphi

PDF Library for Delphi (PDFlibPas) emite JSON válido para cada número de PDF desde la v3.539.31. GetObjectJSON reescribe dígito a dígito los tokens que ISO 32000-1 acepta pero RFC 8259 rechaza, como -.25, +1.5 y 007.5, convirtiéndolos en -0.25, 1.5 y 7.5; GetDocumentJSON y los reportes de análisis escriben null para NaN e Infinity; y PLDoubleToStr escribe 0 para NaN en lugar de elevar EInvalidOp a mitad de un export. Antes del fix, la librería podía producir JSON que su propio lector se negaba a cargar de vuelta

¿Por qué un número de PDF válido rompe el JSON?

Porque las dos gramáticas discrepan en cuatro detalles chicos, y un parser de PDF que respeta el texto de la fuente arrastra esos detalles directo a la salida. ISO 32000-1 §7.3.3 permite que un número arranque con signo más, omita la parte entera (.5), termine en un punto pelado (4.) y lleve ceros a la izquierda (007.5). RFC 8259 §6 no permite nada de eso: un menos opcional, una parte entera que es 0 o arranca con 1 a 9, y al menos un dígito después de cualquier punto decimal. Los productores tienen libertad de escribir las formas de PDF, y montones de generadores y archivos editados a mano lo hacen

El escape venía de una feature de precisión deliberada. Desde la v3.539.19, TPDFNumeric.Output devuelve el texto exacto que el tokenizer parseó para los números reales, que es lo que mantiene exacto un valor de color calibrado al guardar, como se describe en preservar la precisión decimal parseada de PDF. El tokenizer ya parcheaba .5 a 0.5 y 4. a 4.0 en la entrada, y los enteros se reformatean desde su valor, así que +3 vuelve como 3. Lo que sobrevive verbatim es el resto: un punto con signo adelante (-.25), un más explícito en un real (+1.5) y ceros a la izquierda (007.5). El viejo escritor de objetos pegaba Output justo después de "value":, y TJSONParser.ParseNumber en el propio lector de la librería se frena en cada uno de esos con «Invalid JSON number», así que el export terminaba en éxito y la reimportación fallaba con PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

GetObjectJSON de PDFlibPas reescribe dígito a dígito los tokens de números PDF que RFC 8259 rechaza: -.25 se vuelve -0.25, +1.5 pierde el más, 007.5 suelta sus ceros a la izquierda, y los dígitos fraccionarios como 1.250000 sobreviven, porque formatear desde el Double guardado sumaría ruido binario
El viejo escritor pegaba el texto parseado exacto, el propio lector de la librería se frenaba con Invalid JSON number, y el error 105 rompía un round-trip que el lado del export llamaba éxito
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');

    // El objeto 12 es un array escrito como [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 y posteriores: los valores llegan como -0.25, 1.5 y 7.5

    // SetObjectJSON no acepta opciones, así que pase 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;

¿Cómo mantiene PDFNumberTextToJSON cada dígito?

PDFNumberTextToJSON re-escribe el token en lugar de recomputarlo desde un Double. La función en PDFlibObjectJSON lee un signo opcional, junta los dígitos antes y después de un único punto decimal, y después aplica solo las ediciones que el JSON exige: suelta un más, recorta los ceros a la izquierda conservando uno, suministra 0 cuando la parte entera queda vacía, suelta un punto final pelado y repone el menos. Un token que contenga cualquier otro carácter, o ningún dígito, cae de vuelta a PLJSONNumber(Value, 10), que escribe null cuando el valor no es finito

PDFNumberTextToJSON de PDFlibPas lee el signo, junta los dígitos alrededor de un único punto decimal y aplica solo las ediciones que el JSON exige, mientras que cualquier otro carácter o una tanda de dígitos vacía cae de vuelta a PLJSONNumber, que escribe null para NaN e Infinity en lugar de un número
Re-escribir le gana a recomputar: el tokenizer ya parcheó .5 y 4. en la entrada, así que el escritor conserva cada dígito sobreviviente y el round-trip recrea exactamente el mismo valor
  • -.25 se vuelve -0.25, y +.5 se vuelve 0.5
  • +1.5 se vuelve 1.5
  • 007.5 se vuelve 7.5, mientras que 0.75 se queda como está
  • 4. se vuelve 4 si un token así llega alguna vez al escritor
  • 2.22221 y 1.250000 conservan cada dígito fraccionario, ceros finales incluidos

Formatear desde el Double guardado habría sido más corto y equivocado, por la misma razón por la que existe el fix de precisión: la precisión de salida por defecto es de cuatro decimales, y hasta una conversión de precisión completa puede sumarle ruido binario a un literal decimal. Conservar los dígitos significa que SetObjectJSON y ImportObjectJSON, que le entregan cada texto de número JSON al tokenizer de PDF, recrean exactamente el mismo valor. La garantía cubre el valor, no los bytes: tras una reimportación, -.25 queda guardado como -0.25. Ambas grafías son iguales bajo §7.3.3, pero un diff a nivel de bytes va a marcar el cambio, así que no trate un ciclo de export e import como no-op en un documento cuyos bytes están cubiertos por una firma

¿Qué pasa con un número que el JSON no puede representar?

GetDocumentJSON ahora escribe null para cualquier número que sea NaN o infinito, porque RFC 8259 §6 no tiene sintaxis para ninguno de los dos. Infinity es más fácil de producir de lo que suena: el tokenizer de PDF acumula dígitos con multiplicaciones repetidas en un Double, que se topa cerca de 1.8 × 10308, así que un literal entero de un poco más de 300 dígitos se vuelve +Inf en silencio. Los archivos honestos nunca contienen un literal así; los fuzzed y los hostiles sí, y por eso pertenecen al mismo corpus de test que los casos de endurecer un parser de PDF en Pascal contra archivos maliciosos. El viejo escritor de documentos formateaba los no enteros con Str(D:0:6), y para +Inf eso escribe el texto +Inf, que ningún consumidor de JSON va a parsear

El null es con pérdida a propósito. Quienes consuman la salida de GetDocumentJSON deben aceptar null en cualquier lugar donde pueda aparecer un número, y deberían leerlo como «había un valor pero no se puede representar», no como una clave ausente. El literal original no es recuperable desde el JSON del documento, así que un pipeline al que le importe debería registrar el objeto y tratar el archivo como sospechoso en lugar de sustituir un default

¿Por qué un solo NaN podía abortar un export SVG o JSON?

Porque PLDoubleToStr, el formateador invariante de números detrás de los content streams, el SVG, el XML, el CSV y la mayoría del JSON de la librería, escalaba su entrada y llamaba a Round, y Round(NaN) eleva EInvalidOp en targets como Win32, donde Delphi deja sin enmascarar la excepción de operación inválida del x87. La excepción se disparaba después de que el escritor ya había emitido parte de su salida, así que una medición degenerada, un 0/0 en una métrica o un NaN pasado por un llamador dejaba un archivo truncado. PLDoubleToStr ahora devuelve 0 para NaN, y su rama entera se acota a ±9.2e18 igual que la rama fraccionaria, así que Infinity también sale como un literal finito

Cero es la respuesta correcta para un content stream, donde un slot de número tiene que sostener un número, y la respuesta equivocada para un reporte, donde 0 es una medición plausible. Los escritores de JSON que necesiten mantener la diferencia usan PLJSONNumber(Value, Decimals) de PDFlibExtra, que escribe null para NaN o Infinity y dígitos invariantes en el resto de los casos. PLJSONNumber ahora respalda a GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON y los reportes de barcode, deskew, structured text y PDF/VCR; el reporte de deskew antes escribía 0 para un ángulo no finito y ahora escribe null

PDFlibPas frena a NaN e Infinity de tres maneras: AddPageMatrix, ScalePage y RedactRegion rechazan de entrada los argumentos no finitos, PLDoubleToStr escribe 0 en los slots de content stream, y PLJSONNumber escribe null en los reportes, donde un cero se leería como medición plausible, después de que Round(NaN) elevaba EInvalidOp a mitad del export
Cero es la respuesta correcta para un content stream y la equivocada para un reporte, así que los escritores de reportes le entregan cada Double a PLJSONNumber y dejan que null diga que el valor estaba presente pero no era representable
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatee cada Double a texto primero; PLJSONNumber escribe null
    // para NaN o Infinity y siempre usa punto decimal
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Nunca B.Append(Angle): la sobrecarga del Double sigue el locale del usuario
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

¿Por dónde se cuela todavía el locale del usuario en el JSON?

Por cualquier formateador que consulte las configuraciones regionales, y una auditoría completa de la salida legible por máquinas encontró exactamente uno restante: maxAcceptedMeanError en GetSimilarImageDeduplicationReportJSON, escrito con PLFloatToStr, un wrapper delgado sobre FloatToStr. En un escritorio cuyo separador decimal es coma, el reporte contenía "maxAcceptedMeanError":1,5, que un parser de JSON lee como el valor 1 seguido de un token suelto. El campo reporta el peor error de píxel aceptado de la deduplicación perceptual de imágenes, y ahora pasa por PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Una trampa restante es PLStringBuilder: en Delphi es un alias pelado de System.SysUtils.TStringBuilder, cuya sobrecarga Append(Double) formatea vía el locale del usuario, mientras que los builds FPC usan una clase de la librería, así que un test en Free Pascal o en una máquina en-US jamás la va a cazar

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduzca un escritorio alemán o francés dentro de la corrida de test
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Use un fixture que de verdad contenga imágenes casi duplicadas,
    // si no el error medio es 0 y el bug queda escondido
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run con thresholds 2, 2, 4: el documento no se modifica
    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 de regresión para salida JSON necesita tres fixtures para mantenerse honesta: una página que cargue -.25, +1.5 y 007.5, un objeto que sostenga un entero de 400 dígitos, y cualquier reporte corrido bajo un locale coma, cada uno validado con un parser estricto en lugar de a ojo. El JSON de objetos, el JSON de documentos y los reportes de análisis en PDF Library for Delphi comparten las mismas reglas de números en Delphi, C++Builder y Free Pascal; la lista completa de features está en la página de producto de PDF Library for Delphi