Artigo Técnico

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

A PDF Library for Delphi (PDFlibPas) emite JSON válido para todo número de PDF desde a v3.539.31. O GetObjectJSON reescreve tokens que a ISO 32000-1 aceita mas o RFC 8259 rejeita, como -.25, +1.5 e 007.5, em -0.25, 1.5 e 7.5 dígito por dígito; o GetDocumentJSON e os relatórios de análise gravam null para NaN e Infinity; e o PLDoubleToStr grava 0 para NaN em vez de levantar EInvalidOp no meio de um export. Antes do fix, a biblioteca podia produzir JSON que o próprio leitor dela recusava carregar

Por que um número de PDF válido quebra o JSON?

Porque as duas gramáticas discordam em quatro detalhes pequenos, e um parser de PDF que respeita o texto de origem carrega esses detalhes direto para a saída. A ISO 32000-1 §7.3.3 deixa um número começar com sinal de mais, omitir a parte inteira (.5), terminar num ponto solto (4.) e carregar zeros à esquerda (007.5). O RFC 8259 §6 não permite nada disso: um menos opcional, uma parte inteira que é ou 0 ou começa com 1 a 9, e pelo menos um dígito depois de qualquer ponto decimal. Produtores são livres para gravar as formas de PDF, e muitos geradores e arquivos editados à mão o fazem

O vazamento veio de um recurso de precisão deliberado. Desde a v3.539.19, o TPDFNumeric.Output retorna o texto exato que o tokenizer interpretou para números reais, que é o que mantém um valor de cor calibrado exato no save, como descrito em preservar precisão decimal de PDF interpretada. O tokenizer já conserta .5 em 0.5 e 4. em 4.0 na entrada, e inteiros são reformatados a partir do valor deles, então +3 volta como 3. O que sobrevive verbatim é o resto: um ponto com sinal na frente (-.25), um mais explícito num real (+1.5) e zeros à esquerda (007.5). O antigo gravador de objetos anexava Output logo depois de "value":, e o TJSONParser.ParseNumber no próprio leitor da biblioteca para em cada um deles com "Invalid JSON number", então o export tinha sucesso e o reimport falhava com PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

O GetObjectJSON do PDFlibPas reescreve os tokens de número de PDF que o RFC 8259 rejeita dígito por dígito: -.25 vira -0.25, +1.5 perde o mais, 007.5 descarta os zeros à esquerda, e dígitos fracionários como 1.250000 sobrevivem, porque formatar a partir do Double armazenado acrescentaria ruído binário
O antigo gravador anexava o texto interpretado exato, o próprio leitor da biblioteca parava com Invalid JSON number, e o erro 105 quebrava um round trip que o lado de export 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 é um array gravado 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

    // SetObjectJSON não aceita opções, então 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 o PDFNumberTextToJSON mantém cada dígito?

O PDFNumberTextToJSON reescreve a grafia do token em vez de recompilá-lo a partir de um Double. A função no PDFlibObjectJSON lê um sinal opcional, coleta dígitos antes e depois de um único ponto decimal e então aplica só as edições que o JSON exige: descarta o mais, remove zeros à esquerda mantendo um, fornece 0 quando a parte inteira está vazia, descarta um ponto solto no fim e recoloca o menos. Um token contendo qualquer outro caractere, ou nenhum dígito, cai no PLJSONNumber(Value, 10), que grava null quando o valor não é finito

O PDFNumberTextToJSON do PDFlibPas lê o sinal, coleta dígitos em volta de um único ponto decimal e aplica só as edições que o JSON exige, enquanto qualquer outro caractere ou uma sequência vazia de dígitos cai no PLJSONNumber, que grava null para NaN e Infinity em vez de um número
Reescrever a grafia ganha de recompilar: o tokenizer já tinha consertado .5 e 4. na entrada, então o gravador mantém cada dígito sobrevivente e o round trip recria exatamente o mesmo valor
  • -.25 vira -0.25, e +.5 vira 0.5
  • +1.5 vira 1.5
  • 007.5 vira 7.5, enquanto 0.75 fica como está
  • 4. vira 4 se tal token algum dia chegar ao gravador
  • 2.22221 e 1.250000 mantêm cada dígito fracionário, zeros à direita incluídos

Formatar a partir do Double armazenado teria sido mais curto e errado, pelo mesmo motivo que o fix de precisão existe: a precisão de saída default é de quatro decimais, e até uma conversão de precisão total pode acrescentar ruído binário a um literal decimal. Manter os dígitos significa que o SetObjectJSON e o ImportObjectJSON, que entregam cada texto de número JSON ao tokenizer de PDF, recriam exatamente o mesmo valor. A garantia cobre o valor, não os bytes: depois de um reimport, -.25 é armazenado e salvo como -0.25. As duas grafias são iguais sob a §7.3.3, mas um diff a nível de bytes vai sinalizar a mudança, então não trate um ciclo de export e import como no-op num documento cujos bytes estão cobertos por uma assinatura

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

O GetDocumentJSON agora grava null para qualquer número que seja NaN ou infinito, porque o RFC 8259 §6 não tem sintaxe para nenhum dos dois. Infinity é mais fácil de produzir do que parece: o tokenizer de PDF acumula dígitos por multiplicação repetida num Double, que satura perto de 1.8 × 10308, então um literal inteiro um pouco acima de 300 dígitos silenciosamente vira +Inf. Arquivos honestos nunca contêm tal literal; os fuzzados e hostis contêm, e é por isso que eles pertencem ao mesmo corpus de teste que os casos em endurecer um parser de PDF em Pascal contra arquivos maliciosos. O antigo gravador de documentos formatava não inteiros com Str(D:0:6), e para +Inf isso grava o texto +Inf, que nenhum consumidor de JSON interpreta

O null é deliberadamente com perda. Consumidores da saída do GetDocumentJSON precisam aceitar null em qualquer lugar onde um número pode aparecer, e devem lê-lo como "um valor estava presente mas não pode ser representado", não como chave ausente. O literal original não é recuperável do JSON do documento, então uma pipeline que se importa deve registrar o objeto em log e tratar o arquivo como suspeito em vez de substituir por um default

Por que um único NaN podia abortar um export de SVG ou JSON?

Porque o PLDoubleToStr, o formatador de números invariante por trás de content streams, SVG, XML, CSV e da maior parte do JSON na biblioteca, escalava a entrada dele 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 gravador já ter emitido parte da saída dele, então uma medida degenerada, um 0/0 numa métrica ou um NaN passado por um chamador deixava um arquivo truncado para trás. O PLDoubleToStr agora retorna 0 para NaN, e o ramo inteiro dele satura em ±9.2e18 como o ramo fracionário, então Infinity também sai como um literal finito

Zero é a resposta certa para um content stream, onde um slot de número precisa segurar um número, e a resposta errada para um relatório, onde 0 é uma medida plausível. Gravadores de JSON que precisam manter a diferença usam PLJSONNumber(Value, Decimals) do PDFlibExtra, que grava null para NaN ou Infinity e dígitos invariantes caso contrário. O PLJSONNumber agora sustenta o GetSimilarImageDeduplicationReportJSON, o GetAnnotationHitsJSON e os relatórios de barcode, deskew, structured text e PDF/VCR; o relatório de deskew antes gravava 0 para um ângulo não finito e agora grava null

O PDFlibPas barra NaN e Infinity de três formas: AddPageMatrix, ScalePage e RedactRegion rejeitam argumentos não finitos logo de cara, PLDoubleToStr grava 0 para slots de content stream, e PLJSONNumber grava null em relatórios, onde zero leria como medida plausível, depois que Round(NaN) levantava EInvalidOp no meio do export
Zero é a resposta certa para um content stream e a errada para um relatório, então os gravadores de relatório entregam todo 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 todo Double para texto primeiro; PLJSONNumber grava null
    // para NaN ou Infinity e sempre usa 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): a sobrecarga Double segue o locale do usuário
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Onde o locale do usuário ainda escapa para dentro do JSON?

Por qualquer formatador que consulta configurações regionais, e uma auditoria completa da saída legível por máquina encontrou exatamente um restante: maxAcceptedMeanError no GetSimilarImageDeduplicationReportJSON, gravado com PLFloatToStr, um wrapper fino sobre o FloatToStr. Num desktop cujo separador decimal é vírgula, o relatório continha "maxAcceptedMeanError":1,5, que um parser de JSON lê como o valor 1 seguido de um token solto. O campo reporta o pior erro de pixel aceito da deduplicação perceptual de imagens, e agora passa pelo PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Uma armadilha restante é o PLStringBuilder: no Delphi ele é um alias simples de System.SysUtils.TStringBuilder, cuja sobrecarga Append(Double) formata pelo locale do usuário, enquanto builds de FPC usam uma classe da biblioteca em vez disso, então um teste no Free Pascal ou numa máquina en-US nunca vai pegá-la

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduz um desktop alemão ou francês dentro da execução de teste
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Use um fixture que realmente contenha imagens quase duplicadas,
    // senão o mean error é 0 e o bug fica escondido
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run com thresholds 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 suíte de regressão para saída de JSON precisa de três fixtures para se manter honesta: uma página carregando -.25, +1.5 e 007.5, um objeto segurando um inteiro de 400 dígitos, e qualquer relatório rodado sob um locale de vírgula, cada um validado com um parser estrito em vez de olho nú. Object JSON, document JSON e os relatórios de análise na PDF Library for Delphi compartilham as mesmas regras de número pelo Delphi, C++Builder e Free Pascal; a lista completa de recursos está na página de produto da PDF Library for Delphi