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
// 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
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
// 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