Artigo Técnico

Formatação Invariant de Números em PDF no Delphi com HotPDF

Todo número real que chega a um arquivo PDF precisa ser escrito com ponto, não importa o que o sistema operacional acha que é o separador decimal. O HotPDF Delphi Component roteia todos eles por uma única camada de conversão invariant em HPDFTypes, então uma estação alemã, francesa ou brasileira emite 0.5 0 0 0.5 5.5 7.25 cm e não a variante separada por vírgula que nenhum leitor conforme vai fazer parse

Isso parece um fix de duas linhas. Não era. O defeito ficou camuflado por anos por um efeito colateral, reapareceu em três subsistemas que ninguém pensa como código de formatação de número, sumiu sob um compilador e sobreviveu sob outro, e a última camada dele não tinha nada a ver com o separador decimal

Por que um locale de vírgula decimal corrompe um content stream de 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. A vírgula não faz parte dessa produção, e sob a §7.2.2 ela também não é um delimitador, então o leitor não vê dois operandos onde você queria um. Ele vê um token que falha no lexing, e o operador que o continha é descartado. Um único 0,5 em uma matriz de graphics state derruba o bloco q ... Q inteiro ao redor dele. A falha também tem uma forma característica em campo: nunca reproduz na máquina do desenvolvedor, chega como ticket de suporte de um único país, e se lê como bug de renderização em vez de erro de sintaxe, porque a maioria dos viewers descarta o operador malformado silenciosamente e renderiza o resto da página

Diagrama de corrupção de locale em um content stream de PDF produzido pelo HotPDF: um processo host com vírgula decimal emite 0,5 dentro de um operador rg e um cm, a gramática de número da ISO 32000-1 aceita apenas ponto então o token falha no lexing, e o viewer descarta silenciosamente os operadores junto com o bloco q Q ao redor deles
Uma vírgula não faz parte da produção numérica e não é um delimitador, então um único operando malformado remove o retângulo de redação e a página costurada sem nenhum erro
// Processo host rodando 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 carregar:
//   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
//
// ISO 32000-1 7.3.3 não tem produção para "0,5", então o retângulo de redação
// e a página unida desaparecem sem erro

O efeito colateral que escondeu o defeito por anos

O HotPDF grava a maioria dos números do caminho de criação via _CutFloat, e revisões antigas dessa função faziam algo silenciosamente hostil: em toda chamada ela atribuía '.' ao FormatSettings.DecimalSeparator global do processo host. 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 que qualquer conteúdo tinha sido gerado, o separador do processo era ponto, então todo FloatToStr e StrToFloat puro em outro lugar da biblioteca ficava acidentalmente correto. A ordem criar-depois-editar que quase todo sample usa portanto passava, e o que isso mascarava era todo caminho que roda antes da primeira chamada a _CutFloat: os editores de documento carregado StitchLoadedPage e RedactLoadedRect, os stamps de texto e imagem, watermarks compostos, desenho de barcode via Format('%.2f'), o parser de default appearance /DA e o subsistema de fontes Type 1 inteiro

Linha do tempo do efeito colateral de _CutFloat no HotPDF: a primeira chamada atribui um ponto ao FormatSettings.DecimalSeparator global, o que faz todo FloatToStr puro posterior ficar acidentalmente correto enquanto os editores de carregado StitchLoadedPage e RedactLoadedRect, o caminho de barcode, o parser /DA e o subsistema de fontes Type 1 continuam quebrados
A ordem criar-depois-editar que todo sample usa manteve o defeito invisível, porque tudo depois da primeira conversão herdava o separador global invertido

O parsing de Type 1 falhava do jeito mais feio de todos. FontMatrix, BlueValues, hints de stem e os números reais dentro de charstrings Type 2 são reais PostScript escritos com ponto, conforme a especificação Adobe Type 1 Font Format. Fazer parse de 0.001 sob um locale de vírgula levanta EConvertError, um try/except envolvente o engolia, e a matriz da fonte ficava em zero. Todo glyph dessa fonte então colapsava, o que parece um problema de fonte em vez de um problema de locale e te manda debugar renderização de glyphs embutidos por uma tarde

Uma única camada de conversão em vez de patches espalhados

A regra que o HotPDF segue agora é franca: qualquer número gravado em ou lido de sintaxe PDF, PostScript, JSON ou JavaScript passa por uma conversão invariant explícita, e nenhum código em lugar nenhum depende de efeito colateral de um FormatSettings global. HPDFTypes é dona das quatro conversões, mais o record de settings que elas compartilham e um helper de precisão fixa para o caminho de barcode

// Lib/HPDFTypes.pas - a única superfície de conversão invariant
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á carregavam uma função privada InvariantFormatSettings própria, HPDFContentStream e HPDFTextExtraction, e ambas foram reduzidas a um forward de uma linha em vez de deixadas como implementações paralelas. O subsistema Type 1 é a exceção deliberada: ele não carrega dependência de VCL e não referencia HPDFTypes, então mantém um espelho local chamado HPDFType1Core.Type1StrToFloat com semântica idêntica, na teoria de que uma função de dez linhas duplicada é um passivo menor do que arrastar a camada gráfica para dentro de um parser de fontes. Outros dois lugares foram deixados em paz de propósito. HPDFHTMLImport faz parse de comprimentos CSS trocando deliberadamente o ponto pelo separador do locale e então fazendo parse com o locale, o que é o certo para essa entrada, e HPDFType1CB.GlyphCommands junto com as strings FontBBox é API pública que quem chama relê com seu próprio StrToFloat, onde produzir e consumir no mesmo locale é autoconsistente e uma saída invariant quebraria todo chamador em uma região de vírgula

O que HPDF_HAS_FORMAT_SETTINGS garante entre compiladores?

Ela garante que o caminho invariant de fato compila em toda toolchain suportada, o que o gate de versão óbvio não garantia. O Delphi XE2 introduziu TFormatSettings.Create e as overloads de FloatToStr / TryStrToFloat que recebem um TFormatSettings, então o primeiro instinto é escrever {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} em cada call site. Esse instinto tem um ponto cego: 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 Free Pascal 3.2.2 entrega essas overloads de qualquer forma. Cada um desses gates portanto degradava para o ramo dependente de locale em builds FPC, silenciosamente, exatamente nas plataformas de onde vieram os relatos originais. HPDF_HAS_FORMAT_SETTINGS é definido tanto no bloco FPC quanto no bloco XE2+ de HotPDF.inc, e é o único símbolo que os helpers de número 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 Free Pascal não tem TFormatSettings.Create e você precisa partir de DefaultFormatSettings. Apenas os dois separadores de número alimentam as conversões, então os campos de data e moeda mantêm o que o locale atual fornecer. Delphi pré-XE2 não tem overloads de jeito nenhum, e lá os helpers caem em converter com o locale do processo e então reescrever o caractere separador na string resultante, o que é feio mas nunca toca estado global

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

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

Pipeline mostrando como o HotPDF remove expoentes antes de gravar operandos de PDF: SVGFinite rejeita NaN e valores além de 1.0E30, a conversão invariant deixa um ponto, magnitudes minúsculas viram zero, e a saída de HPDFFloatToInvariantStr com um E sobrevivente tem o expoente removido e o ponto decimal deslocado à mão
O SVG 1.1 admite expoente e a ISO 32000-1 não, então a fronteira de conversão reescreve 1E-6 em notação decimal pura que um leitor conforme consegue fazer parse
// Lib/HPDFSafeSVG.pas - notação decimal para operandos de 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, preenchendo com '0'
  // à esquerda ou à direita, e depois reanexar o sinal
end;

Esse é o mesmo TSVGBuffer que alimenta exportação de página SVG e o tratamento de graphics state dela, e a mesma disciplina numérica vale no lado da importação quando convertendo documentos XPS e OXPS para PDF via HPDFCreateXPSDocumentHandler e o membro hdfXPS de THPDFHandledDocumentFormat. Formatos de markup são generosos com a sintaxe de números. O content stream de PDF não é, e a fronteira de conversão é onde essa diferença precisa ser resolvida

Testes de regressão que reproduzem o locale em vez de assumi-lo

Um bug de locale que só aparece na máquina de outra pessoa vale fixar com testes que definem o locale eles mesmos, e o HotPDF carrega dois. LoadedEditsEmitInvariantNumbersUnderCommaLocale atribui uma vírgula a FormatSettings.DecimalSeparator antes do primeiro edit, roda RedactLoadedRect, StampLoadedPageText, StitchLoadedPage e um stamp composto, então relê os bytes do conteúdo da página e asserta tanto que as strings exatas dos operadores estão presentes quanto que o buffer não contém vírgula em lugar nenhum. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale cobre a outra metade e o pecado original: dirige o caminho de criação sob o mesmo locale de vírgula, incluindo DirectDrawBarcode com sua formatação fixa de duas decimais, e então asserta que o content stream não carrega vírgula e que FormatSettings.DecimalSeparator ainda é uma vírgula quando EndDoc retorna

Essa segunda asserção é a que teria pego o defeito original no dia em que foi escrita, porque testa o que a biblioteca faz com seu host em vez de apenas o que ela grava em disco. A lição generaliza para além de PDF: qualquer formato com gramática lexical fixa, PostScript, JSON e JavaScript incluídos, precisa de uma conversão invariant explícita na fronteira. Buscar um setting global para obter a resposta certa funciona até alguém chamar seu código em uma ordem diferente, em uma thread diferente, ou de um compilador diferente, e aí a falha parece qualquer coisa 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, junto com os caminhos de edição de documento carregado, barcode, exportação SVG e conversão XPS que dependem dela