Artigo Técnico

Preservar a precisão decimal de PDFs no save em Delphi

O PDFlibPas, a losLab PDF Developer Library, guarda o texto decimal exato que leu para todo número real de um documento e reescreve esse texto literalmente sempre que o valor nunca foi modificado. Desde a v3.539.19 a configuração SetPrecision governa apenas números que a biblioteca cria ou edita, então um load-and-save comum não arredonda mais um /Gamma de CalRGB de 2.22221 para 2.2222 nem desloca as cores de uma página que ninguém tocou. A mudança é pequena em código e grande no que ela diz sobre parsers: o valor que você decodifica e o literal que você emite são duas coisas diferentes, e um round trip por Double não é transformação de identidade

Por que um save que não mudou nada deslocou as cores da página?

Porque os parâmetros do color space estavam sendo reformatados, não a imagem. O arquivo que expôs isso é um documento de escritório de 35 páginas no corpus de regressão local, com uma imagem de header reutilizada em toda página. Carregá-lo e salvar de volta direto produzia image streams byte a byte idênticos à entrada, e uma comparação de hashes de stream reportava o documento inalterado. Uma comparação de renderização discordava: cada uma das 35 páginas mostrava diferenças de pixel no header, e em nenhum outro lugar

A imagem de header é desenhada através de um color space CalRGB, que a ISO 32000-1 §8.6.5.3 define por um /WhitePoint, um array /Gamma opcional de três elementos e uma /Matrix opcional de nove elementos. Esses arrays são objetos numéricos simples no dicionário do color space. O TPDFNumeric armazenava cada um como Double e nada mais, e TPDFNumeric.Output formatava esse Double via PDFPrecNum, que por padrão usa quatro casas decimais. Então o /Gamma foi de 2.22221 para 2.2222, uma entrada da matriz foi de 0.71519 para 0.7152, e o renderer produziu fielmente cores um pouco diferentes a partir de uma calibração um pouco diferente. Os bytes da imagem eram inocentes; os números em volta deles não. A parte desconfortável é o quão invisível isso era. Comparar bytes de streams decodificados não enxerga, porque os números moram num dicionário, não num stream. Comparar payloads de anexos não enxerga. Até o diff de revisão descrito no artigo sobre níveis de modificação faz fingerprint de um corpo de objeto normalizado, então as duas revisões geram o mesmo hash e o diff as reporta idênticas. Só a renderização pegou, e é por isso que a baseline do corpus renderiza todas as páginas em vez de confiar apenas em checagens estruturais

Onde o PDFlibPas perdeu precisão de CalRGB num save no-op no Delphi: o /Gamma 2.22221 lido e uma entrada 0.71519 da matriz vivem num TPDFNumeric como Double, Output os formata via PLDoubleToStr com PDFPrecNum em quatro casas decimais, toda checagem estrutural reporta o documento inalterado, e só a comparação de renderização mostra as 35 imagens de header deslocadas
Os bytes da imagem eram inocentes: o TPDFNumeric reformatava os números de calibração em volta deles via PDFPrecNum, então hashes de stream e o diff de fingerprint reportavam revisões idênticas enquanto o renderer produzia cores um pouco diferentes em toda página

O valor que você leu não é o literal que você deve escrever

Um número real de PDF é uma string decimal, e a ISO 32000-1 §7.3.3 é explícita: é apenas uma string decimal, sem notação de base, sem forma exponencial. O Annex C então lista a precisão que se espera que uma implementação honre, aproximadamente cinco dígitos decimais significativos na parte fracionária. Uma precisão padrão de saída de quatro já está abaixo disso, e perto de zero piora: PLDoubleToStr escala o valor, arredonda para inteiro e emite 0 quando o resultado é zero, então uma entrada de matriz -0.000012345 não perde um dígito, ela desaparece por completo

Aumentar o padrão só mudaria o penhasco de lugar. A correção é parar de fingir que um Double é o número. Quando o tokenizer em TPDFStructure.Decode reconhece um real padrão — ou seja, o token contém ponto decimal e nenhum marcador de expoente — ele guarda o texto de origem no novo campo FOriginalText, ao lado do valor convertido. O Output então prefere esse texto e só cai na formatação quando não há nada a preferir

Como o PDFlibPas preserva o texto decimal lido no Delphi: o tokenizer em TPDFStructure.Decode mantém o literal de origem em FOriginalText para todo token com ponto decimal e sem expoente, Output escreve esse texto literalmente em vez de chamar PLDoubleToStr, e SetTo o limpa porque um número editado é um número novo
O valor que você decodifica e o literal que você emite são duas coisas diferentes: preferir o texto lido mantém 2.22221 exato, enquanto números criados pela biblioteca e números editados continuam seguindo PDFPrecNum e a configuração nunca alcança entrada intocada
// Lib/PDFlibStruct.pas — a correção inteira do lado da saída
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // um número editado é um número novo
  FValue:= Value;
  FChanged:= True;
End;

Duas fronteiras são deliberadas. Inteiros não são preservados, porque a formatação de inteiro já é sem perda. Formas exponenciais como 6.02E23 são toleradas na entrada por causa de produtores quebrados, mas não são preservadas na saída, já que reescrevê-las perpetuaria uma sintaxe que a §7.3.3 proíbe; elas passam pelo formatador como qualquer número gerado pela biblioteca. O tokenizer também aplica seu reparo mínimo de sempre antes de guardar o texto, então um literal com ponto à frente como .5 fica como 0.5 e um literal com ponto no fim como 5. fica como 5.0. Os dois são o mesmo número para todo leitor e são muito mais amplamente aceitos

O que SetPrecision garante depois da v3.539.19?

O TPDFlib.SetPrecision agora controla as casas decimais dos números que a própria biblioteca produz: valores desenhados pelo painter, números criados a partir de um Double, como via NewNumeric, e qualquer valor lido que desde então foi editado com SetTo. Repare que texto decodificado pela API de objeto, por exemplo um literal passado a SetObjectFromString, passa pelo mesmo tokenizer e é preservado do mesmo jeito. Um decimal lido que nunca foi modificado mantém a precisão de entrada independentemente da configuração, e mudar a configuração depois do load não o toca retroativamente. A entrada de referência de SetPrecision foi atualizada na mesma release para dizer exatamente isso, porque o texto antigo dava a entender que a configuração valia para todo número do arquivo

A limpeza acontece no SetTo, em vez de ser derivada da flag Changed, e essa distinção importa. O pipeline de save reseta Changed nos objetos depois que eles foram escritos, então uma checagem do tipo "emita o texto original a menos que tenha mudado" começaria a emitir texto obsoleto para um valor que foi editado, salvo e editado de novo na mesma sessão. Amarrar o texto original à própria atribuição torna impossível que os dois discordem. O teste de regressão fixa cada um desses comportamentos com os valores do arquivo original

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // Entrada não editada sobrevive literalmente, incluindo o valor que
    // a formatação de quatro casas teria colapsado em 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Uma edição descarta o texto original e segue o PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Baixar a precisão depois não alcança entrada não editada
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Por que o content model ainda normaliza números?

Porque o TPDFContentProgram promete operandos numéricos canônicos, e essa promessa vale mais do que texto literal dentro de um content stream. O content model editável, o mesmo sobre o qual o tracker de graphics state é construído, existe para que NormalizeContentStreams, o otimizador e o Emit produzam saída estável e comparável a partir de entrada arbitrária. Se um operando lido carregasse o texto original para dentro do modelo, uma sequência de operadores como 0.50000 0 0 RG seria emitida diferente de 0.5 0 0 RG, e toda comparação a jusante derivaria conforme os hábitos de formatação do produtor

Então o modelo descarta o texto original nos seus dois pontos de entrada. O NormalizeContentNumbers roda em cada operando conforme o parser o empilha e de novo dentro de SetOperand quando código fornecido pelo chamador é decodificado, e ele recursiona por arrays e dicionários, de modo que dash patterns, arrays TJ e os dicionários de propriedades de marked content ficam cobertos. Chamar SetTo(AsDouble) em cada numérico já basta, já que é exatamente essa a operação que limpa o texto. Dados crus de inline image ficam como estão, como sempre ficaram

Por que o content model do PDFlibPas ainda normaliza números: NormalizeContentNumbers roda onde o parser empilha cada operando e de novo dentro de SetOperand, recursiona por arrays e dicionários de modo que dash patterns, arrays TJ e dicionários de propriedades de marked content ficam cobertos, e SetTo AsDouble limpa o texto original, fazendo 0.50000 e 0.5 emitirem de forma idêntica
Operandos numéricos canônicos são a promessa do content model: dados crus de inline image ficam intocados, e números de dicionário intocados fora dos content streams mantêm a garantia de literalidade, então um par LoadFromFile e SaveToFile comum ainda os preserva
// Lib/PDFlibContentModel.pas — o content model mantém seu contrato
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

A regra prática para quem chama é portanto simples. Um LoadFromFile comum seguido de SaveToFile deixa content streams intocados e números de dicionário intocados como estavam. Uma página que passa por NormalizeContentStreams, ou qualquer edição feita pelo content model, sai canônica por design, e o resto do documento continua preservado. São dois pedidos diferentes, e agora eles fazem duas coisas diferentes

Quanto custa, e onde a garantia termina

Todo TPDFNumeric agora carrega uma referência extra de AnsiString, e todo decimal lido mantém seu texto de origem vivo durante a vida do objeto. Num documento com milhões de números reais isso é memória de verdade, e pertence a qualquer medição de documento grande em vez de ser descartado com um aceno. A garantia também se limita ao documento do próprio número: copiar objetos entre documentos ou reconstruir valores pela API de objeto produz números novos, que seguem a precisão de saída como qualquer outro número novo. Vale ser preciso sobre o que a release afirma e o que não afirma. Um load-and-save de um documento intocado agora preserva os números de calibração que o renderer de fato consome, que é a propriedade que a baseline do corpus checa. Ela não afirma saída byte a byte idêntica, o que também depende de numeração de objetos, compressão de stream e do identificador de trailer discutido no artigo sobre o PDF ID determinístico. E ela não faz o diff de fingerprint enxergar diferenças de arredondamento em arquivos produzidos por outros softwares, já que esses continuam gerando hash do corpo normalizado. A lição generaliza bem além do CalRGB: quando um parser guarda apenas o valor convertido, todo save é uma edição, e a única forma de perceber é olhar o resultado renderizado. O tratamento numérico e a semântica do SetPrecision estão documentados na página do produto losLab PDF Developer Library