Artigo Técnico

Preservar a precisão decimal de um PDF ao gravar no Delphi

O PDFlibPas, a losLab PDF Developer Library, guarda o texto decimal exato que leu para cada número real de um documento e escreve esse texto de volta tal e qual sempre que o valor nunca foi modificado. Desde a v3.539.19 a definição SetPrecision governa apenas os números que a biblioteca cria ou edita, pelo que um carregar-e-gravar normal já não arredonda um /Gamma de CalRGB de 2.22221 para 2.2222 nem desloca as cores de uma página em que ninguém tocou. A mudança é pequena em código e grande naquilo que diz sobre os parsers: o valor que descodifica e o literal que emite são duas coisas diferentes, e uma ida e volta por um Double não é uma transformação de identidade

Porque é que uma gravação que não mudou nada deslocou as cores da página?

Porque estavam a ser reformatados os parâmetros do espaço de cor, não a imagem. O ficheiro que expôs isto é um documento de escritório de 35 páginas do corpus de regressão local, com uma imagem de cabeçalho reutilizada em todas as páginas. Carregá-lo e gravá-lo de volta produzia content 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 píxeis no cabeçalho, e em mais nenhum sítio

A imagem de cabeçalho desenha-se através de um espaço de cor 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 espaço de cor. O TPDFNumeric guardava cada um como um Double e nada mais, e o TPDFNumeric.Output formatava esse Double através do PDFPrecNum, que por defeito são quatro casas decimais. Assim o /Gamma passava de 2.22221 para 2.2222, uma entrada da matriz passava de 0.71519 para 0.7152, e o renderizador produzia fielmente cores ligeiramente diferentes a partir de uma calibração ligeiramente diferente. Os bytes da imagem eram inocentes; os números à volta deles não eram. A parte incómoda é o quão invisível isto era. Comparar bytes de stream descodificados não consegue vê-lo, porque os números vivem num dicionário, não num stream. Comparar cargas de anexos também não. Até o diff de revisões descrito em o artigo sobre níveis de modificação tira a impressão digital a um corpo de objeto normalizado, pelo que as duas revisões dão o mesmo hash e o diff reporta-as idênticas. Só a renderização o apanhou, e é por isso que a linha de base do corpus desenha todas as páginas em vez de confiar apenas em verificações estruturais

Onde o PDFlibPas perdeu precisão de CalRGB numa gravação sem alterações no Delphi: o /Gamma 2.22221 lido e uma entrada de matriz 0.71519 vivem no TPDFNumeric como um Double, o Output formata-os através do PLDoubleToStr com o PDFPrecNum a quatro casas decimais, todas as verificações estruturais reportam o documento inalterado, e só a comparação de renderização mostra as 35 imagens de cabeçalho deslocadas
Os bytes da imagem eram inocentes: o TPDFNumeric reformatava os números de calibração à volta deles através do PDFPrecNum, pelo que os hashes de stream e o diff por impressão digital reportavam revisões idênticas enquanto o renderizador produzia cores ligeiramente diferentes em todas as páginas

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

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

Aumentar o valor predefinido só deslocaria o precipício. A correção é deixar de fingir que um Double é o número. Quando o tokenizer do TPDFStructure.Decode reconhece um real padrão, ou seja, o token contém um ponto decimal e nenhum marcador de expoente, guarda o texto de origem no novo campo FOriginalText ao lado do valor convertido. O Output passa então a preferir esse texto e só recorre à 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 qualquer token com ponto decimal e sem expoente, o Output escreve esse texto tal e qual em vez de chamar o PLDoubleToStr, e o SetTo limpa-o porque um número editado é um número novo
O valor que descodifica e o literal que emite são duas coisas diferentes: preferir o texto lido mantém 2.22221 exato, enquanto os números criados ou editados pela biblioteca continuam a seguir o PDFPrecNum e a definição nunca chega à entrada intocada
// Lib/PDFlibStruct.pas — a correção completa 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. Os inteiros não são preservados, porque a formatação de inteiros já é sem perdas. As formas exponenciais como 6.02E23 são toleradas na entrada por causa de produtores avariados, mas não são preservadas na saída, já que escrevê-las de volta perpetuaria uma sintaxe que a §7.3.3 proíbe; passam pelo formatador como qualquer número gerado pela biblioteca. O tokenizer aplica também a sua reparação mínima habitual antes de guardar o texto, pelo que um literal com ponto inicial como .5 fica como 0.5 e um literal com ponto final como 5. fica como 5.0. Ambos são o mesmo número para qualquer leitor e são muito mais amplamente aceites

O que garante o SetPrecision depois da v3.539.19?

O TPDFlib.SetPrecision controla agora as casas decimais dos números que a própria biblioteca produz: valores desenhados através do painter, números criados a partir de um Double, por exemplo através do NewNumeric, e qualquer valor lido que tenha sido entretanto editado com SetTo. Note que o texto descodificado através da API de objetos, por exemplo um literal passado ao SetObjectFromString, passa pelo mesmo tokenizer e é preservado da mesma forma. Um decimal lido que nunca foi modificado mantém a precisão da entrada independentemente da definição, e mudar a definição depois do carregamento não o toca retroativamente. A entrada de referência do SetPrecision foi atualizada na mesma versão para dizer exatamente isto, porque o texto antigo dava a entender que a definição se aplicava a todos os números do ficheiro

A limpeza acontece no SetTo em vez de ser derivada do flag Changed, e essa distinção importa. O pipeline de gravação reinicia o Changed nos objetos depois de os ter escrito, pelo que uma verificação do tipo «emite o texto original a menos que tenha mudado» começaria a emitir texto obsoleto para um valor que foi editado, gravado e editado outra vez na mesma sessão. Atar o texto original à própria atribuição torna impossível que os dois discordem. O teste de regressão fixa cada um destes comportamentos com os valores do ficheiro 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]'));
    // A entrada não editada sobrevive tal e qual, incluindo o valor que
    // a formatação com 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 chega à entrada não editada
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Porque é que o modelo de conteúdo continua a normalizar números?

Porque o TPDFContentProgram promete operandos numéricos canónicos, e essa promessa vale mais do que texto tal e qual dentro de um content stream. O modelo de conteúdo editável, aquele sobre o qual o rastreador de estado gráfico é construído, existe para que o NormalizeContentStreams, o otimizador e o Emit produzam saída estável e comparável a partir de entrada arbitrária. Se um operando lido transportasse o seu texto original para o modelo, uma sequência de operadores como 0.50000 0 0 RG emitiria de forma diferente de 0.5 0 0 RG, e todas as comparações a jusante desviar-se-iam conforme os hábitos de formatação do produtor

Por isso o modelo retira o texto original nos seus dois pontos de entrada. O NormalizeContentNumbers corre sobre cada operando à medida que o parser o empurra e outra vez dentro do SetOperand quando é descodificada fonte fornecida pelo chamador, e recorre por arrays e dicionários para que os dash patterns, os arrays TJ e os dicionários de propriedades de conteúdo marcado fiquem cobertos. Chamar SetTo(AsDouble) em cada numérico chega, já que é precisamente essa a operação que limpa o texto. Os dados em bruto de imagens inline ficam como estão, como sempre estiveram

Porque é que o modelo de conteúdo do PDFlibPas continua a normalizar números: o NormalizeContentNumbers corre onde o parser empurra cada operando e outra vez dentro do SetOperand, recorre por arrays e dicionários para cobrir dash patterns, arrays TJ e dicionários de propriedades de conteúdo marcado, e o SetTo AsDouble limpa o texto original para que 0.50000 e 0.5 emitam de forma idêntica
Operandos numéricos canónicos são a promessa do modelo de conteúdo: os dados em bruto de imagens inline ficam como estão, e os números intocados fora dos content streams mantêm a garantia de texto tal e qual, pelo que um simples par LoadFromFile e SaveToFile continua a preservá-los
// Lib/PDFlibContentModel.pas — o modelo de conteúdo mantém o 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 os chamadores é portanto simples. Um LoadFromFile simples seguido de SaveToFile deixa os content streams intocados e os números de dicionário intocados tal como estavam. Uma página que passa pelo NormalizeContentStreams, ou qualquer edição feita através do modelo de conteúdo, sai canónica por desenho, e o resto do documento continua preservado. São dois pedidos diferentes, e agora fazem duas coisas diferentes

Quanto custa, e onde acaba a garantia

Cada TPDFNumeric transporta agora uma referência AnsiString extra, e cada decimal lido mantém o seu texto de origem vivo durante toda a vida do objeto. Num documento com milhões de números reais isso é memória a sério, e pertence a qualquer medição de documentos grandes em vez de ser despachado com um encolher de ombros. A garantia está também limitada ao documento do próprio número: copiar objetos entre documentos ou reconstruir valores através da API de objetos produz números novos, que seguem a precisão de saída como qualquer outro número novo. Vale a pena ser preciso quanto ao que a versão afirma e ao que não afirma. Um carregar-e-gravar de um documento intocado preserva agora os números de calibração que o renderizador realmente consome, que é a propriedade que a linha de base do corpus verifica. Não afirma saída byte a byte idêntica, que depende também da numeração de objetos, da compressão de streams e do identificador de trailer abordado em o artigo sobre o ID determinístico de PDF. E não faz com que o diff por impressão digital veja diferenças de arredondamento em ficheiros produzidos por outro software, já que esses continuam a dar o mesmo hash sobre o corpo normalizado. A lição generaliza-se muito para lá do CalRGB: quando um parser guarda apenas o valor convertido, cada gravação é uma edição, e a única forma de dar por isso é olhar para o resultado desenhado. O tratamento numérico e a semântica do SetPrecision estão documentados na página do produto losLab PDF Developer Library