Artigo Técnico

Formatação de números PDF invariante em Delphi com o HotPDF

Todos os números reais que chegam a um ficheiro PDF têm de ser escritos com um ponto final, seja qual for o separador decimal que o sistema operativo acha que é. O HotPDF Delphi Component encaminha todos através de uma camada de conversão invariante em HPDFTypes, para que uma estação de trabalho alemã, francesa ou brasileira emita 0.5 0 0 0.5 5.5 7.25 cm e não a variante com vírgula que nenhum leitor conforme vai fazer parse

Isso parece uma correção de duas linhas. Não era. O defeito tinha estado camuflado durante anos por um efeito secundário, reapareceu em três subsistemas em que ninguém pensa como código de formatação de números, desapareceu sob um compilador e sobreviveu sob outro, e a última camada dele não tinha nada a ver com o separador decimal

Porque é que uma locale de vírgula decimal corrompe um content stream PDF?

Porque a ISO 32000-1 §7.3.3 define um objeto numérico como um sinal opcional, dígitos decimais e no máximo um ponto final. Uma vírgula não faz parte dessa produção, e sob §7.2.2 também não é um delimitador, pelo que o leitor não vê dois operandos onde queria um. Vê um token que falha o lex, e o operador que o continha é descartado. Um único 0,5 numa matriz de graphics-state leva consigo todo o bloco q ... Q à volta. A falha também tem uma forma característica no campo: nunca se reproduz na máquina do developer, chega como um ticket de suporte de um país, e lê-se como um bug de rendering em vez de um erro de sintaxe, porque a maioria dos visualizadores larga o operador malformado silenciosamente e renderiza o resto da página

Diagrama da corrupção por locale num content stream PDF produzido pelo HotPDF: um processo anfitrião com vírgula decimal emite 0,5 dentro de um operador rg e de um cm, a gramática de números da ISO 32000-1 aceita apenas o ponto final pelo que o token falha o lex, e o visualizador descarta silenciosamente os operadores juntamente com o bloco q Q à volta
Uma vírgula não faz parte da produção numérica nem é um delimitador, pelo que um único operando malformado remove o retângulo de redação e a página cosida sem qualquer erro
// Processo anfitrião a correr com DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// O que o /Contents anexado costumava transportar:
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// A ISO 32000-1 7.3.3 não tem produção para "0,5", pelo que o retângulo
// de redação e a página cosida desaparecem ambos sem erro

O efeito secundário que escondeu o defeito durante anos

O HotPDF escreve a maioria dos números do caminho de criação através de _CutFloat, e as primeiras revisões dessa função faziam algo silenciosamente hostil: em cada chamada atribuíam '.' ao FormatSettings.DecimalSeparator global do processo anfitrião. Isso corrompe o estado de locale da aplicação que hospeda o componente e não é thread safe, mas a consequência interessante é a terceira. Uma vez gerado qualquer conteúdo, o separador do processo era um ponto final, pelo que todos os FloatToStr e StrToFloat simples noutros lados da biblioteca ficavam acidentalmente corretos. A ordem criar-depois-editar que quase todos os exemplos usam passava portanto, e o que isso mascarava era todos os caminhos que correm antes da primeira chamada a _CutFloat: os editores de documentos carregados StitchLoadedPage e RedactLoadedRect, os carimbos de texto e imagem, as marcas de água compostas, o desenho de códigos de barras através de Format('%.2f'), o parser de /DA default-appearance e todo o subsistema de fontes Type 1

Cronologia do efeito secundário do _CutFloat no HotPDF: a primeira chamada atribui um ponto final ao FormatSettings.DecimalSeparator global, o que torna acidentalmente corretos todos os FloatToStr simples posteriores enquanto os editores de documentos carregados StitchLoadedPage e RedactLoadedRect, o caminho de códigos de barras, o parser /DA e o subsistema de fontes Type 1 ficam avariados
A ordem criar-depois-editar que todos os exemplos usam manteve o defeito invisível, porque tudo o que vinha depois da primeira conversão herdava o separador global trocado

O parse Type 1 falhava da forma mais feia de todas. FontMatrix, BlueValues, stem hints e os números reais dentro de charstrings Type 2 são reais PostScript escritos com ponto final, segundo a especificação Adobe Type 1 Font Format. Fazer parse de 0.001 sob uma locale de vírgula levanta EConvertError, um try/except à volta engolia-o, e a matriz da fonte ficava em zero. Cada glyph dessa fonte colapsava depois, o que parece um problema de fontes em vez de um problema de locale e manda-o debugar rendering de glyphs embutidos durante uma tarde

Uma camada de conversão em vez de patches espalhados

A regra que o HotPDF segue agora é direta: qualquer número escrito ou lido de sintaxe PDF, PostScript, JSON ou JavaScript passa por uma conversão invariante explícita, e nenhum código em lado nenhum depende de um efeito secundário global de FormatSettings. O HPDFTypes é dono das quatro conversões, mais o record de settings que partilham e um helper de precisão fixa para o caminho dos códigos de barras

// Lib/HPDFTypes.pas - a superfície única de conversão invariante
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

Duas units já transportavam uma função privada InvariantFormatSettings própria, HPDFContentStream e HPDFTextExtraction, e ambas foram reduzidas a um forward de uma linha em vez de ficarem como implementações paralelas. O subsistema Type 1 é a exceção deliberada: não tem dependência VCL e não referencia HPDFTypes, por isso mantém um espelho local chamado HPDFType1Core.Type1StrToFloat com semântica idêntica, na teoria de que uma função duplicada de dez linhas é um passivo menor do que arrastar a camada de gráficos para dentro de um parser de fontes. Dois outros sítios foram deixados em paz de propósito. O HPDFHTMLImport faz parse de comprimentos CSS trocando deliberadamente o ponto final pelo separador da locale e depois fazendo parse com a locale, o que é correto para essa entrada, e HPDFType1CB.GlyphCommands juntamente com as strings FontBBox é API pública que os chamadores leem de volta com o seu próprio StrToFloat, onde produzir e consumir na mesma locale é auto-consistente e a saída invariante partiria todos os chamadores numa região de vírgula

O que garante o HPDF_HAS_FORMAT_SETTINGS entre compiladores?

Garante que o caminho invariante compila mesmo em todas as toolchains suportadas, o que o gate de versão óbvio não garantia. O Delphi XE2 introduziu TFormatSettings.Create e os overloads FloatToStr / TryStrToFloat que recebem um TFormatSettings, pelo que o primeiro instinto é escrever {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} em cada call site. Esse instinto tem um ponto cego: o HotPDF.inc define D2009+ sob Free Pascal mas nunca define XE2+, porque XE2+ responde a uma pergunta sobre números de versão do Delphi, enquanto o Free Pascal 3.2.2 traz esses overloads independentemente disso. Todos esses gates degradavam portanto para o ramo dependente da locale em builds FPC, silenciosamente, exatamente nas plataformas de onde vieram os relatórios originais. O HPDF_HAS_FORMAT_SETTINGS é definido tanto no bloco FPC como no bloco XE2+ do HotPDF.inc, e é o único símbolo que os helpers de números testam

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // não há TFormatSettings.Create no FPC
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Um ramo de compilador sobrevive dentro da função, porque o Free Pascal não tem TFormatSettings.Create e é preciso começar de DefaultFormatSettings. Apenas os dois separadores de números alimentam as conversões, pelo que os campos de data e moeda mantêm o que a locale atual fornecer. O Delphi pré-XE2 não tem overloads de todo, e aí os helpers caem em converter com a locale do processo e depois reescrever o caractere separador na string resultante, o que é feio mas nunca toca no estado global

A terceira camada: expoentes que o SVG permite e o PDF não

Corrigir o separador decimal é necessário e não é suficiente. O SVG 1.1 da W3C §4.2 admite um expoente na sua produção de números e o markup XPS faz o mesmo, pelo que 1E-6 é uma coordenada perfeitamente válida à entrada, enquanto a ISO 32000-1 §7.3.3 não admite expoente nenhum. Um valor que sobrevive à formatação invariante ainda pode aterrar num content stream como 1E-6 e ser rejeitado, e o FloatToStrF em formato fixo pode ele próprio cair para a forma de expoente quando o valor sai do alcance de dígitos da RTL. O HPDFSafeSVG.SVGFloat faz portanto o seu próprio trabalho depois da conversão invariante: reduz a zero qualquer coisa abaixo de 1.0E-7, e depois, se um E sobreviver, remove o expoente e move o ponto decimal à mão, com padding de zeros no lado que o deslocamento exigir. O SVGFinite guarda a entrada rejeitando NaN e qualquer coisa fora de ±1.0E30 antes de chegar ao formatador, e o SVGReadNumber com SVGSkipSeparators trata a direção de entrada caractere a caractere em vez de adiar para a RTL

Pipeline que mostra como o HotPDF remove expoentes antes de escrever operandos PDF: o SVGFinite rejeita NaN e valores para lá de 1.0E30, a conversão invariante deixa um ponto final, magnitudes minúsculas reduzem a zero, e a saída do HPDFFloatToInvariantStr com um E sobrevivente tem o expoente removido e o ponto decimal deslocado à mão
O SVG 1.1 admite um expoente e a ISO 32000-1 não, pelo que a fronteira de conversão reescreve 1E-6 em notação decimal simples que um leitor conforme consegue fazer parse
// Lib/HPDFSafeSVG.pas - notação decimal para operandos PDF
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... deslocar o ponto decimal por ExponentValue, com padding de '0'
  // à esquerda ou à direita, e depois reaplicar o sinal
end;

É o mesmo TSVGBuffer que alimenta a exportação de páginas SVG e o seu tratamento de graphics-state, e a mesma disciplina numérica aplica-se no lado da importação quando se convertem documentos XPS e OXPS para PDF através de HPDFCreateXPSDocumentHandler e do membro hdfXPS de THPDFHandledDocumentFormat. Os formatos de markup são generosos com a sintaxe de números. O content stream PDF não é, e a fronteira de conversão é onde essa diferença tem de ser resolvida

Testes de regressão que reproduzem a locale em vez de a assumir

Um bug de locale que só aparece na máquina de outra pessoa vale a pena ser fixado com testes que definem a locale eles próprios, e o HotPDF tem dois. O LoadedEditsEmitInvariantNumbersUnderCommaLocale atribui uma vírgula ao FormatSettings.DecimalSeparator antes da primeira edição, corre RedactLoadedRect, StampLoadedPageText, StitchLoadedPage e um carimbo composto, depois lê os bytes de conteúdo da página de volta e afirma tanto que as strings exatas dos operadores estão presentes como que o buffer não contém nenhuma vírgula em lado nenhum. O DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale cobre a outra metade e o pecado original: conduz o caminho de criação sob a mesma locale de vírgula, incluindo DirectDrawBarcode com a sua formatação fixa de duas casas decimais, e depois afirma que o content stream não transporta nenhuma vírgula e que o FormatSettings.DecimalSeparator ainda é uma vírgula quando EndDoc regressa

Essa segunda afirmação é a que teria apanhado o defeito original no dia em que foi escrito, porque testa o que a biblioteca faz ao seu anfitrião em vez de apenas o que escreve em disco. A lição generaliza para lá do PDF: qualquer formato com uma gramática lexical fixa, PostScript e JSON e JavaScript incluídos, precisa de uma conversão invariante explícita na fronteira. Estender a mão a uma definição global para obter a resposta certa funciona até alguém chamar o seu código numa ordem diferente, numa thread diferente ou de um compilador diferente, e aí a falha parece tudo menos um problema de formatação

A camada de formatação de números descrita aqui vem como parte do HotPDF Delphi Component para Delphi e C++Builder, juntamente com os caminhos de edição de documentos carregados, códigos de barras, exportação SVG e conversão XPS que dela dependem