Artigo Técnico

Números PDF neutros de locale para apps Delphi com vírgula

O PDFlibPas, a losLab PDF Developer Library for Delphi, escreve todos os números que mete num content stream com separador decimal por ponto e sem expoente, digam o que disserem as configurações regionais do Windows. Desde a v3.539.26 que o AddPageMatrix, o ScalePage, o DeskewPage, o RedactRegion, o output de text-to-path e a recoloração formatam operandos através do PLDoubleToStrConst, e desde a v3.539.33 que os parsers que leem esses números de volta usam o PLTryStrToFloatInvariant em vez da locale do sistema. Numa máquina alemã, francesa ou brasileira o mesmo código produz agora os mesmos bytes que numa americana, que é o único comportamento que um formato de ficheiro tolera

Porque é que uma locale de vírgula decimal corrompe um PDF sem erro nenhum?

Uma locale de vírgula decimal corrompe um PDF em silêncio porque a vírgula não é um carácter de número na sintaxe PDF, por isso o dano lê-se como tokens válidos com o significado errado. Antes da correção, o PLFloatToStr não passava de uma chamada nua ao FloatToStr, e o FloatToStr segue o FormatSettings.DecimalSeparator. Com separador por vírgula, o AddPageMatrix(0.5, 0.5, 0, 0) escrevia 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 mais nada, por isso 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, nada regista. A página simplesmente renderiza com uma matriz de transformação que deslizou, e retroceder de um desenho deslocado até a uma configuração regional é uma tarde miserável

O segundo defeito esconde-se atrás do primeiro. O FloatToStr usa o formato ffGeneral, que passa a notação de expoente mal a magnitude desça abaixo de 1E-4, por isso um offset minúsculo saía como 1E-5. A mesma §7.3.3 declara que o PDF não suporta a forma de expoente, o que significa que até uma máquina com locale americana podia escrever um operando inválido dado um valor pequeno suficiente. Os testes de regressão desta versão pregam as duas formas de falha: viram o separador para vírgula, chamam a API e vasculham o conteúdo resultante à procura de qualquer token com 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 := ',';   // simular um desktop de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 e posteriores escrevem: 0.5 0 0 0.25 0.00001 12.75 cm
      // builds antigas escreviam:         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 de vírgula decimal escrevia 0,5 0 0 0,25 1E-5 12,75 cm antes da correção, o que um parser PDF lê como o número 0 mais tokens desconhecidos, deixando o cm com operandos errados e a página silenciosamente transformada, enquanto o PLDoubleToStrConst escreve decimais por ponto válidos
A vírgula não é um carácter de número na sintaxe PDF, por isso o dano lê-se como tokens válidos com o significado errado — e expoentes ffGeneral como 1E-5 eram inválidos em qualquer locale, não só nas de vírgula

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

A correção no PDFlibPas é uma divisão estrita: os números mostrados a pessoas podem seguir a locale, e os números escritos para uma máquina nunca a seguem. O PLFloatToStr e o PLStrToFloat ficam no PDFlibExtra.pas para texto voltado ao utilizador, e a declaração deles agora carrega um comentário a dizer precisamente isso. Tudo o 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 TJ, três para cores e retângulos FDF. A auditoria da v3.539.26 tocou mais sítios de chamada do que o relatório de bug original sugeria:

  • AddPageMatrix, ScalePage e DeskewPage, que todos antepõem um cm ao conteúdo existente da página
  • Os construtores de elementos de página que emitem resets Tm, avanços TJ e transformações cm
  • Matrizes de posicionamento de glifos e pontos de contorno no conversor text-to-path
  • A caixa de preenchimento preta que o RedactRegion antepõe, os valores /Rect na exportação FDF e os operandos que a recoloração escreve

O PLDoubleToStrConst é um formatador escrito à mão em vez de um wrapper em volta do FloatToStrF, e três das suas propriedades interessa aqui. Escreve sempre um ponto e corta os zeros finais, por isso 0.5 continua 0.5 em vez de 0.500000. Nunca escreve expoente para input finito. E um valor não nulo menor do que a precisão pedida guarda os seus dígitos significativos em vez de colapsar para zero, por isso o PLDoubleToStrConst(1E-9, 6) devolve 0.000000001; só valores abaixo de cerca de 5E-16 se tornam 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 se estava a corrigir

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

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Output de máquina: decimal por ponto, sem expoente, zeros finais cortados
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // guarda 4 dígitos significativos
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

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

Porque é que o lado da interpretação é mais perigoso do que o da escrita?

O lado da interpretação é mais perigoso porque um parser preso à locale não produz um número errado, ele rebenta. O PLStrToFloat chama o StrToFloat, que levanta EConvertError quando o texto não casa com o separador do sistema. Num sistema de vírgula decimal isso significava que o RecolorPage abortava mal encontrasse um operador 0.5 g banal, por isso toda a página real falhava, e não só as exóticas. O RenderPageRegionToFile recusava o formato de recorte "10.5,20.5,50.5,40.5" que a própria documentação descreve, e atributos de comprimento SVG, cores de exportação SVG, listas de vértices de anotações e valores de solidez de output intent eram recusados ou substituídos em silêncio por predefinições. Uma biblioteca que funciona na perfeição na máquina do programador 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 cada chamada a StrToFloat e TryStrToFloat pela origem do input. Operandos de content stream, atributos SVG, strings de cor do painter e listas de recorte e vértices separadas por vírgulas têm todos sintaxe de ponto fixa, por isso agora passam pelo PLTryStrToFloatInvariant, que corta os espaços do texto, interpreta-o com PLInvariantFormatSettings e devolve False para input vazio, mal formado ou não finito em vez de levantar. Uma lista separada por vírgulas não deixa espaço para compromisso, porque uma vírgula não pode ser ao mesmo tempo delimitador de lista e marca decimal. O mesmo passe corrigiu também uma escrita fora dos limites: o RenderPageRegionToFile guardava um quinto valor de recorte para lá do seu buffer de quatro elementos. Para a pipeline de recoloração descrita em o guia para converter um PDF para um só espaço de cor, o resultado prático é que o RecolorPage e o RecolorDocument já não abortam num sistema de vírgula decimal. Os valores de regras que um chamador digita no CheckDocumentPolicy são o caso de interpretação que usa o helper leniente em vez disso, pela razão que a secção seguinte explica

O que acontece se corrigir só um lado de um round trip?

Corrigir só um lado de um round trip de locale parte código que funcionava, e é por isso que a alteração dos atributos de estrutura na v3.539.32 moveu o escritor e o leitor juntos. Os wrappers SetStructElem* transmitem os números como strings: o SetStructElemBBox formata quatro valores numa string, guarda-a através do AddTagAttribute, e o escritor de /A interpreta depois essa string para decidir se se torna num número, numa matriz ou num nome. Os dois lados usavam a locale do sistema, por isso num sistema de vírgula decimal o round trip era auto-consistente. O bug aparecia só quando um chamador seguia a documentação e passava "0.5" ao AddTagAttribute: o leitor não conseguia interpretá-lo e emitia o nome PDF /0.5. O placeholder PDF/VCR tinha o problema em espelho, porque a biblioteca gerava GTS_BBox com ponto e depois validava-o com a locale antes de guardar

Mudar só o escritor para ponto teria sido pior do que não fazer nada, já que todos os valores SetStructElem* falhariam então no leitor preso à locale e degradariam para nome. Por isso os escritores agora usam PLDoubleToStrConst(v, 6), e o leitor usa o novo PLTryStrToFloatLenient, que tenta primeiro a forma com ponto e recua para a locale do sistema. Um chamador de locale de vírgula que passasse "1,25" no passado continua a obter o número 1.25. A troca é deliberada e documentada: num sistema alemão "1.500" tornava-se num nome porque o StrToFloat recusa separadores de milhares, e agora lê-se como 1.5, enquanto as strings literais NAN e INF já não são aceites como números

O SetStructElemBBox do PDFlibPas e os seus irmãos transmitem números como strings através do AddTagAttribute, e o escritor de /A interpreta essas strings de volta, por isso a v3.539.32 moveu os dois lados juntos: o PLDoubleToStrConst escreve com ponto e o PLTryStrToFloatLenient lê primeiro ponto com recuo à locale, de modo a que um 1,25 de locale de vírgula continue a ler-se 1.25
Corrigir só o escritor teria degradado todos os atributos de elementos estruturados para nome PDF, e é por isso que um round trip move os dois lados juntos ou não move nada
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // chamador de 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 travados

O AddPageMatrix, o ScalePage e o RedactRegion agora recusam argumentos NaN e infinitos à partida e devolvem 0, porque número PDF nenhum os consegue representar. O ScalePage já recusava fatores zero ou negativos, mas NaN passa num teste <= 0, por isso uma escala NaN viajava até ao formatador. Na v3.539.26 esse formatador ainda chamava Round sobre NaN, o que levanta EInvalidOp em Win32 onde a unidade x87 não mascara operações inválidas; a v3.539.31 fez o PLDoubleToStrConst escrever 0 para NaN como última linha de defesa, mas um zero numa matriz é uma transformação singular, por isso a verificação ao nível da API continua a ser a correção verdadeira. Duas fronteiras ficam de propósito. As strings de estado de metafile são escritas e lidas com a locale dentro de um processo e nunca saem dele, por isso ficaram como estão. E um teste que formata 1E-5 pelo caminho dos elementos de página tem de ler o conteúdo antes de a camada ser reescrita, porque reemitir operandos à precisão do documento transforma legitimamente esse valor em 0

Se a sua aplicação chega a clientes fora do mundo do ponto decimal, o hábito mais seguro é o que a suite de testes do PDFlibPas agora usa: corra os caminhos que produzem PDF uma vez com o FormatSettings.DecimalSeparator posto em vírgula e vasculhe o output à procura de vírgulas e expoentes. O artigo sobre preservar a precisão decimal interpretada cobre a outra metade da mesma história, como os números lidos de um ficheiro existente mantêm o texto exato ao guardar. Downloads, a referência completa da API e a build de avaliação estão na página de produto da PDFlibPas Delphi PDF library