Artigo Técnico

Números PDF vs JSON: NaN, Infinity e null no Delphi

A PDF Library for Delphi (PDFlibPas) emite JSON válido para todos os números PDF desde a v3.539.31. O GetObjectJSON reescreve tokens que a ISO 32000-1 aceita mas a RFC 8259 rejeita, como -.25, +1.5 e 007.5, em -0.25, 1.5 e 7.5 dígito a dígito; o GetDocumentJSON e os relatórios de análise escrevem null para NaN e Infinity; e o PLDoubleToStr escreve 0 para NaN em vez de levantar EInvalidOp a meio de uma exportação. Antes da correção, a biblioteca podia produzir JSON que o seu próprio leitor recusava carregar

Porque é que um número PDF válido parte o JSON?

Porque as duas gramáticas discordam em quatro pormenores pequenos, e um parser PDF que respeita o texto de origem transporta esses pormenores direitinho para o output. A ISO 32000-1 §7.3.3 deixa um número começar por sinal mais, omitir a parte inteira (.5), acabar num ponto nu (4.) e levar zeros à esquerda (007.5). A RFC 8259 §6 não permite nada disso: um menos opcional, uma parte inteira que é 0 ou começa por 1 a 9, e pelo menos um dígito depois de qualquer ponto decimal. Os produtores têm liberdade para escrever as formas PDF, e muitos geradores e ficheiros editados à mão fazem-no

A fuga veio de uma funcionalidade deliberada de precisão. Desde a v3.539.19 que o TPDFNumeric.Output devolve o texto exato que o tokenizer interpretou para números reais, que é o que mantém um valor de cor calibrado exato ao guardar, como descrito em preservar a precisão decimal PDF interpretada. O tokenizer já remenda .5 para 0.5 e 4. para 4.0 à entrada, e os inteiros são reformatados a partir do valor, por isso +3 volta como 3. O que sobrevive à letra é o resto: um ponto com sinal (-.25), um mais explícito num real (+1.5) e zeros à esquerda (007.5). O antigo escritor de objetos acrescentava o Output logo a seguir a "value":, e o TJSONParser.ParseNumber no próprio leitor da biblioteca para em qualquer um desses com «Invalid JSON number», por isso a exportação tinha sucesso e a reimportação falhava com PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

O GetObjectJSON do PDFlibPas reescreve os tokens de números PDF que a RFC 8259 rejeita dígito a dígito: -.25 torna-se -0.25, +1.5 perde o mais, 007.5 deixa cair os zeros à esquerda, e dígitos fracionários como 1.250000 sobrevivem, porque formatar a partir do Double guardado acrescentaria ruído binário
O antigo escritor acrescentava o texto interpretado exato, o próprio leitor da biblioteca parava com Invalid JSON number, e o erro 105 partia um round trip que o lado da exportação chamava de sucesso
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');

    // O objeto 12 é uma matriz escrita como [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 e posteriores: os valores chegam como -0.25, 1.5 e 7.5

    // O SetObjectJSON não aceita opções, por isso passe 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;

Como é que o PDFNumberTextToJSON guarda todos os dígitos?

O PDFNumberTextToJSON torna a soletrar o token em vez de o recalcular a partir de um Double. A função no PDFlibObjectJSON lê um sinal opcional, recolhe dígitos antes e depois de um único ponto decimal, e depois aplica só as edições que o JSON exige: larga um mais, corta zeros à esquerda mantendo um, fornece 0 quando a parte inteira está vazia, larga um ponto final nu e repõe o menos. Um token com qualquer outro carácter, ou sem dígito nenhum, recua para o PLJSONNumber(Value, 10), que escreve null quando o valor não é finito

O PDFNumberTextToJSON do PDFlibPas lê o sinal, recolhe dígitos à volta de um único ponto decimal e aplica só as edições que o JSON exige, enquanto qualquer outro carácter ou uma sequência vazia de dígitos recua para o PLJSONNumber, que escreve null para NaN e Infinity em vez de um número
Reescrever vence recalcular: o tokenizer já remendou .5 e 4. à entrada, por isso o escritor guarda todos os dígitos sobreviventes e o round trip recria exatamente o mesmo valor
  • -.25 torna-se -0.25, e +.5 torna-se 0.5
  • +1.5 torna-se 1.5
  • 007.5 torna-se 7.5, enquanto 0.75 fica como está
  • 4. torna-se 4 se tal token algum dia chegar ao escritor
  • 2.22221 e 1.250000 guardam todos os dígitos fracionários, zeros finais incluídos

Formatar a partir do Double guardado teria sido mais curto e errado, pela mesma razão que a correção de precisão existe: a precisão de output predefinida é de quatro decimais, e até uma conversão de precisão total pode acrescentar ruído binário a um literal decimal. Guardar os dígitos significa que o SetObjectJSON e o ImportObjectJSON, que entregam cada texto de número JSON ao tokenizer PDF, recriam exatamente o mesmo valor. A garantia cobre o valor, não os bytes: depois de uma reimportação, -.25 é guardado e gravado como -0.25. Ambas as grafias são iguais à luz da §7.3.3, mas um diff ao nível de bytes vai marcar a alteração, por isso não trate um ciclo de exportação e importação como no-op num documento cujos bytes estão cobertos por uma assinatura

O que acontece a um número que o JSON não consegue representar?

O GetDocumentJSON agora escreve null para qualquer número que seja NaN ou infinito, porque a RFC 8259 §6 não tem sintaxe para nenhum dos dois. O infinito é mais fácil de produzir do que parece: o tokenizer PDF acumula dígitos por multiplicações repetidas num Double, que satura perto de 1.8 × 10308, por isso um literal inteiro um pouco acima de 300 dígitos torna-se silenciosamente +Inf. Ficheiros honestos nunca contêm um literal desses; os que vêm de fuzzing e os hostis contêm, e é por isso que pertencem ao mesmo corpus de testes que os casos em endurecer um parser PDF em Pascal contra ficheiros maliciosos. O antigo escritor de documentos formatava não inteiros com Str(D:0:6), e para +Inf isso escreve o texto +Inf, que consumidor JSON nenhum consegue interpretar

O null é deliberadamente com perdas. Os consumidores do output do GetDocumentJSON têm de aceitar null em qualquer sítio onde possa aparecer um número, e devem lê-lo como «um valor estava presente mas não pode ser representado», e não como uma chave em falta. O literal original não é recuperável a partir do JSON do documento, por isso uma pipeline que se importe deve registar o objeto e tratar o ficheiro como suspeito em vez de substituir por um predefinido

Porque é que um único NaN podia abortar uma exportação SVG ou JSON?

Porque o PLDoubleToStr, o formatador de números invariante por trás dos content streams, do SVG, do XML, do CSV e da maior parte do JSON na biblioteca, escalava o input e chamava Round, e Round(NaN) levanta EInvalidOp em alvos como o Win32, onde o Delphi deixa a exceção de operação inválida do x87 sem máscara. A exceção disparava depois de o escritor já ter emitido parte do output, por isso uma medição degenerada, um 0/0 numa métrica ou um NaN passado por um chamador deixava para trás um ficheiro truncado. O PLDoubleToStr agora devolve 0 para NaN, e o seu ramo inteiro satura em ±9.2e18 como o ramo fracionário, por isso o Infinity também sai como literal finito

Zero é a resposta certa para um content stream, onde um lugar de número tem de segurar um número, e a resposta errada para um relatório, onde 0 é uma medição plausível. Escritores JSON que têm de manter a diferença usam o PLJSONNumber(Value, Decimals) do PDFlibExtra, que escreve null para NaN ou Infinity e dígitos invariantes caso contrário. O PLJSONNumber agora suporta o GetSimilarImageDeduplicationReportJSON, o GetAnnotationHitsJSON e os relatórios de barcode, deskew, texto estruturado e PDF/VCR; o relatório de deskew escrevia antes 0 para um ângulo não finito e agora escreve null

O PDFlibPas trava NaN e Infinity de três formas: o AddPageMatrix, o ScalePage e o RedactRegion recusam argumentos não finitos à partida, o PLDoubleToStr escreve 0 nos lugares de content stream, e o PLJSONNumber escreve null nos relatórios, onde zero leria como medição plausível, depois de Round(NaN) levantar EInvalidOp a meio da exportação
Zero é a resposta certa para um content stream e a errada para um relatório, por isso os escritores de relatórios entregam cada Double ao PLJSONNumber e deixam o null dizer que o valor estava presente mas não era representável
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formate primeiro cada Double para texto; o PLJSONNumber escreve null
    // para NaN ou Infinity e usa sempre ponto 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): o overload Double segue a locale do utilizador
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Onde é que a locale do utilizador ainda se esgueira no JSON?

Pelo caminho de qualquer formatador que consulte as configurações regionais, e uma auditoria completa do output legível por máquina encontrou exatamente um que restava: o maxAcceptedMeanError no GetSimilarImageDeduplicationReportJSON, escrito com o PLFloatToStr, um wrapper fino sobre o FloatToStr. Num desktop cujo separador decimal é a vírgula, o relatório continha "maxAcceptedMeanError":1,5, que um parser JSON lê como o valor 1 seguido de um token a mais. O campo reporta o pior erro de píxel aceito deduzido da deduplicação percetiva de imagens, e agora passa pelo PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Uma armadilha que resta é o PLStringBuilder: no Delphi é um alias simples de System.SysUtils.TStringBuilder, cujo overload Append(Double) formata pela locale do utilizador, enquanto as builds FPC usam uma classe da biblioteca, por isso um teste em Free Pascal ou numa máquina en-US nunca o apanha

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduza um desktop alemão ou francês dentro da corrida de testes
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Use um fixture que contenha de facto imagens quase duplicadas,
    // caso contrário o erro médio é 0 e o bug fica escondido
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Execução a seco com limiares 2, 2, 4: o documento não é modificado
    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;

Uma suite de regressão para output JSON precisa de três fixtures para se manter honesta: uma página que carregue -.25, +1.5 e 007.5, um objeto que segure um inteiro de 400 dígitos, e qualquer relatório corrido sob uma locale de vírgula, cada um validado com um parser estrito em vez de olho. O JSON de objetos, o JSON de documentos e os relatórios de análise na PDF Library for Delphi partilham as mesmas regras de números em Delphi, C++Builder e Free Pascal; a lista completa de funcionalidades está na página de produto da PDF Library for Delphi