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)
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
-.25torna-se-0.25, e+.5torna-se0.5+1.5torna-se1.5007.5torna-se7.5, enquanto0.75fica como está4.torna-se4se tal token algum dia chegar ao escritor2.22221e1.250000guardam 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
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