Artigo Técnico

Números PDF neutros: vírgula decimal no Delphi

O PDFlibPas, a losLab PDF Developer Library para Delphi, grava todo número que põe num content stream com separador decimal ponto e sem expoente, não importe o que dizem as configurações regionais do Windows. Desde a v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, a saída de texto para path e o recolor formatam operandos pelo PLDoubleToStrConst, e desde a v3.539.33 os parsers que leem esses números de volta usam PLTryStrToFloatInvariant em vez do locale do sistema. Numa máquina alemã, francesa ou brasileira o mesmo código agora produz os mesmos bytes que numa americana, que é o único comportamento que um formato de arquivo tolera

Por que um locale com vírgula decimal corrompe um PDF sem erro?

Um locale com vírgula decimal corrompe um PDF em silêncio porque a vírgula não é caractere de número na sintaxe PDF, então o dano se lê como tokens válidos com significado errado. Antes do fix, o PLFloatToStr não passava de uma chamada nua ao FloatToStr, e o FloatToStr segue o FormatSettings.DecimalSeparator. Com separador vírgula, AddPageMatrix(0.5, 0.5, 0, 0) gravava 0,5 0 0 0,5 0 0 cm. A ISO 32000-1 §7.3.3 permite dígitos, um ponto e um sinal inicial num número e nada mais, então um parser de conteúdo lê essa linha como o número 0 seguido de um token desconhecido ,5, e o operador cm acaba com os operandos errados. Nada levanta exceção, nada registra log. A página simplesmente renderiza com uma matriz de transformação que derivou, e trabalhar de trás para frente de um desenho deslocado até uma configuração de locale é uma tarde miserável

O segundo defeito se esconde atrás do primeiro. O FloatToStr usa o formato ffGeneral, que troca para notação de expoente assim que a magnitude cai abaixo de 1E-4, então um offset minúsculo saía como 1E-5. A mesma §7.3.3 afirma que o PDF não suporta a forma de expoente, o que significa que até uma máquina com locale americano podia gravar um operando inválido dado um valor pequeno o bastante. Os testes de regressão desse release cravam as duas formas de falha: viram o separador para vírgula, chamam a API e varrem o conteúdo resultante por qualquer token que contenha vírgula ou expoente

uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  OldSeparator: Char;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetPageDimensions(300, 200);
    Lib.DrawBox(10, 10, 20, 20, 1);
    OldSeparator := FormatSettings.DecimalSeparator;
    try
      FormatSettings.DecimalSeparator := ',';   // simula um desktop de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 e posteriores gravam: 0.5 0 0 0.25 0.00001 12.75 cm
      // builds anteriores gravavam: 0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
O AddPageMatrix do PDFlibPas num desktop com vírgula decimal gravava 0,5 0 0 0,25 1E-5 12,75 cm antes do fix, que um parser de PDF lê como o número 0 mais tokens desconhecidos, deixando o cm com operandos errados e a página transformada em silêncio, enquanto o PLDoubleToStrConst grava decimais de ponto válidos
A vírgula não é caractere de número na sintaxe PDF, então o dano se lê como tokens válidos com significado errado — e expoentes ffGeneral como 1E-5 eram inválidos em todo locale, não só nos de vírgula

Dois tipos de números, duas famílias de helpers

O fix no PDFlibPas é uma divisão estrita: números mostrados a pessoas podem seguir o locale, e números escritos para uma máquina nunca seguem. O PLFloatToStr e o PLStrToFloat ficam no PDFlibExtra.pas para texto voltado ao usuário, e a declaração deles agora carrega um comentário dizendo exatamente isso. Tudo que acaba como sintaxe PDF passa pelo PLDoubleToStrConst com um número fixo de casas decimais escolhido para o trabalho: seis para matrizes, quatro para coordenadas e ajustes de TJ, três para cores e retângulos de FDF. A auditoria da v3.539.26 tocou mais call sites do que o relatório de bug original sugeria:

  • AddPageMatrix, ScalePage e DeskewPage, que todos precedem um cm ao conteúdo existente da página
  • Os construtores de elementos de página que emitem resets de Tm, avanços de TJ e transformações de cm
  • Matrizes de posicionamento de glyph e pontos de outline no conversor de texto para path
  • A caixa de fill preta que o RedactRegion precede, os valores de /Rect no export de FDF e os operandos que o recolor grava

O PLDoubleToStrConst é um formatador feito à mão em vez de um wrapper em volta do FloatToStrF, e três propriedades dele importam aqui. Ele sempre grava um ponto e remove zeros à direita, então 0.5 continua 0.5 em vez de 0.500000. Ele nunca grava expoente para entrada finita. E um valor não zero menor que a precisão pedida mantém os dígitos significativos dele em vez de colapsar para zero, então PLDoubleToStrConst(1E-9, 6) retorna 0.000000001; só valores abaixo de aproximadamente 5E-16 viram 0. Essa última regra existe porque arredondar um fator de escala minúsculo para zero transforma uma matriz válida numa singular, que é um bug pior do que o que está sendo corrigido

O PDFlibPas divide a formatação de números em duas: PLFloatToStr e PLStrToFloat ficam presos ao locale para texto voltado ao usuário, enquanto PLDoubleToStrConst e PLTryStrToFloatInvariant formatam tudo que vira sintaxe PDF com ponto, sem expoente e com precisão fixa por trabalho de seis, quatro ou três decimais
O formatador invariante é feito à mão de propósito: ele remove zeros à direita, nunca grava expoente e mantém os dígitos significativos de valores minúsculos, porque arredondar um fator de escala para zero tornaria uma matriz válida singular
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Saída de máquina: decimal ponto, sem expoente, zeros à direita removidos
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // mantém 4 dígitos significativos
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Entrada de máquina: falha suave em vez de EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // números de conteúdo nunca usam vírgula
end;

Por que o lado de leitura é mais perigoso que o lado de escrita?

O lado de leitura é mais perigoso porque um parser preso ao locale não produz um número errado, ele lança exceção. O PLStrToFloat chama StrToFloat, que levanta EConvertError quando o texto não bate com o separador do sistema. Num sistema com vírgula decimal isso significava que o RecolorPage abortava no momento em que encontrava um operador 0.5 g comum, então toda página real falhava, não só as exóticas. O RenderPageRegionToFile rejeitava o formato de clip documentado dele mesmo, "10.5,20.5,50.5,40.5", e atributos de comprimento SVG, cores de export SVG, listas de vértices de anotações e valores de solidez de output intent eram ou recusados ou silenciosamente trocados por defaults. Uma biblioteca que funciona perfeitamente na máquina do desenvolvedor e falha no primeiro cliente em Munique é exatamente o tipo de código que, como os casos em o artigo sobre código Delphi que funciona por acidente, só parece correto por causa de onde foi testado

A v3.539.33 classificou toda chamada de StrToFloat e TryStrToFloat pela origem da entrada dela. Operandos de content stream, atributos SVG, strings de cor do painter e listas de clip e vértices separadas por vírgula têm sintaxe de ponto fixa, então agora passam pelo PLTryStrToFloatInvariant, que corta o texto, interpreta com PLInvariantFormatSettings e retorna False para entrada vazia, malformada ou não finita em vez de levantar exceção. Uma lista separada por vírgula não deixa espaço para compromisso, porque a vírgula não pode ser ao mesmo tempo o delimitador de lista e a marca decimal. A mesma passada também corrigiu uma escrita fora dos limites: o RenderPageRegionToFile costumava guardar um quinto valor de clip além do buffer de quatro elementos dele. Para a pipeline de recolor descrita em o guia de conversão de um PDF para um color space, o resultado prático é que RecolorPage e RecolorDocument não abortam mais num sistema com vírgula decimal. Valores de regra que o chamador digita no CheckDocumentPolicy são o único caso de leitura que usa o helper leniente, pelo motivo que a próxima seção explica

O que acontece se você conserta só uma ponta de um round trip?

Consertar só uma ponta de um round trip de locale quebra código que funcionava, e é por isso que a mudança de atributos de estrutura na v3.539.32 moveu o gravador e o leitor juntos. Os wrappers SetStructElem* relayam números como strings: o SetStructElemBBox formata quatro valores numa string, a guarda pelo AddTagAttribute, e o gravador de /A depois interpreta essa string para decidir se ela vira um número, um array ou um name. As duas pontas usavam o locale do sistema, então num sistema com vírgula decimal o round trip era autoconsistente. O bug só aparecia quando um chamador seguia a documentação e passava "0.5" ao AddTagAttribute: o leitor não conseguia interpretar e emitia o name PDF /0.5. O placeholder de PDF/VCR tinha o problema espelhado, porque a biblioteca gerava GTS_BBox com ponto e depois o validava com o locale antes de salvar

Trocar só o gravador para ponto teria sido pior do que não fazer nada, já que todo valor de SetStructElem* então falharia no leitor preso ao locale e degradaria para um name. Então os gravadores agora usam PLDoubleToStrConst(v, 6), e o leitor usa o novo PLTryStrToFloatLenient, que tenta a forma de ponto primeiro e cai para o locale do sistema. Um chamador com locale de vírgula que passava "1,25" no passado ainda recebe o número 1.25. O trade-off é deliberado e documentado: num sistema alemão "1.500" costumava virar um name porque o StrToFloat rejeita separadores de milhar, e agora lê como 1.5, enquanto strings literais NAN e INF não são mais aceitas como números

O SetStructElemBBox do PDFlibPas e os irmãos dele relayam números como strings pelo AddTagAttribute, e o gravador de /A interpreta essas strings de volta, então a v3.539.32 moveu as duas pontas juntas: PLDoubleToStrConst grava com ponto e PLTryStrToFloatLenient lê ponto primeiro com fallback de locale, então um 1,25 de locale vírgula ainda lê como 1.25
Consertar só o gravador teria degradado todo atributo de elemento de estrutura para um name PDF, e é por isso que um round trip move as duas pontas juntas ou não move
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // chamador com vírgula decimal
  Lib := TPDFlib.Create;
  try
    Lib.BeginTag('Figure', 'Sales chart', '');
    Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
    Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5');   // /SpaceAfter 0.5, antes /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // continua /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Onde NaN e infinito são barrados

O AddPageMatrix, o ScalePage e o RedactRegion agora rejeitam argumentos NaN e infinitos logo de cara e retornam 0, porque número de PDF nenhum os representa. O ScalePage já recusava fatores de zero ou menos, mas NaN passa num teste <= 0, então um fator NaN viajava até o formatador. Na v3.539.26 esse formatador ainda chamava Round sobre NaN, o que levanta EInvalidOp no Win32, onde a unidade x87 não mascara operações inválidas; a v3.539.31 fez o PLDoubleToStrConst gravar 0 para NaN como última linha de defesa, mas um zero numa matriz é uma transformação singular, então a checagem no nível da API continua sendo o fix de verdade. Duas fronteiras ficam de propósito. Strings de estado de metafile são gravadas e lidas com o locale dentro de um processo e nunca saem dele, então ficaram como estão. E um teste que formata 1E-5 pelo caminho de elementos de página precisa ler o conteúdo antes de a camada ser reescrita, porque reemitir operandos na precisão do documento legitimamente transforma esse valor em 0

Se sua aplicação vai para clientes fora do mundo de ponto decimal, o hábito mais seguro é o que a suíte de testes do PDFlibPas agora usa: rode os caminhos que produzem PDF uma vez com FormatSettings.DecimalSeparator configurado como vírgula e varra a saída por vírgulas e expoentes. O artigo sobre preservar precisão decimal interpretada cobre a outra metade da mesma história, como números lidos de um arquivo existente mantêm o texto exato deles no save. Downloads, a referência completa de API e o build de teste estão na página de produto da PDFlibPas Delphi PDF library