Artigo Técnico

Um float Single corrompe flags de campos AcroForm no Delphi

O HotPDF Delphi Component guarda todos os números PDF em THPDFNumericObject.Value, declarado como Single, e um Single não consegue guardar todos os inteiros de 32 bits. As flags de campos AcroForm (/Ff) são uma máscara de 32 bits, pelo que qualquer máscara com um bit alto ativo era arredondada ao carregar: $8000FFFF voltava como $80010000. Desde a v2.989.5 que as leituras de flags de campos analisam o token inteiro original exatamente e copiam os seus bits para a API Integer existente

O bug merece um olhar atento mesmo que nunca toque em formulários PDF. Nada rebentava, nenhuma exceção era levantada, e a maioria dos ficheiros de teste passava, porque a maioria das máscaras reais é pequena. A falha só aparecia quando um bit alto e as flags baixas comuns estavam presentes em conjunto, e aí não perdia um bit, reescrevia vários de uma vez. Guardar uma máscara de bits num tipo de vírgula flutuante é uma armadilha clássica do Delphi, e é isto que ela parece em código de produção

Por que é que um Single perde bits de flags de campos AcroForm?

Um Single do Delphi tem um significando de 24 bits, pelo que representa todos os inteiros até 2^24 (16777216) exatamente e nada além disso sem buracos. Entre 2^31 e 2^32 os valores Single consecutivos distam 256 uns dos outros. Uma máscara de flags de campo com o bit 32 ativo vive exatamente nesse intervalo, pelo que os seus oito bits baixos, e muitas vezes mais, são arredondados para o múltiplo de 256 mais próximo

Diagrama de arredondamento das flags de campos AcroForm do HotPDF para Delphi: a máscara /Ff de 32 bits $8000FFFF, 2147549183, passa por um Single com significando de 24 bits e regressa como $80010000 porque os valores Single representáveis distam 256 entre 2^31 e 2^32
Acima de 2^31 um Single do Delphi só consegue guardar múltiplos de 256, pelo que o arredondar-para-o-mais-próximo reescreve o byte baixo de uma máscara de flags de campo de 32 bits sem qualquer exceção

A ISO 32000-1 §7.3.3 define objetos inteiro e real como tipos distintos de número, e o Anexo C lista limites de implementação diferentes para cada um, pelo que um inteiro PDF não tem obrigação de caber num float de 32 bits. A entrada de flags de campo está definida na Tabela 221 como um inteiro, e lê-se bit a bit, não como uma quantidade. Arredondar uma quantidade por uma fração minúscula é inofensivo; arredondar uma máscara propaga-se aos bits vizinhos. Pode reproduzir todo o efeito em poucas linhas de Delphi simples:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, não representável como Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: bits 1-16 limpos, bit 17 ativo

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: bits 1-14 limpos, bit 15 ativo
end;

A lição generaliza para além do PDF. Um Single, um Double, um Variant a guardar um float, ou um número JSON descodificado para um float é o contentor errado para um campo de bits, mesmo quando os valores com que testa por acaso são pequenos. O Double empurra o intervalo exato até 2^53, o que esconde o problema para máscaras de 32 bits mas deixa ainda o tipo a exprimir a intenção errada. Use Cardinal, Integer ou Int64 e converta a partir do texto de origem, não de um float que já foi arredondado

Que flags de campo viram: Radio, Pushbutton, Password e Multiline

As duas máscaras acima não são arbitrárias; mostram como um único passo de arredondamento muda o tipo de campo que um leitor vê. Os números PDF contam os bits de flags a partir de 1, pelo que o bit n tem o valor 2^(n-1). A ISO 32000-1 §12.7.3.1 Tabela 226 define os bits 1 a 3 para todos os campos, a Tabela 228 define as flags de botão, e a Tabela 230 define as flags de campo de texto

  • $8000FFFF para $80010000: o bit 16 (Radio, $8000, Tabela 228) é limpo e o bit 17 (Pushbutton, $10000) é ativado, pelo que um botão de opção (radio) se torna um botão de premir
  • O mesmo arredondamento limpa os bits 1 a 3, pelo que ReadOnly, Required e NoExport da Tabela 226 desaparecem junto com o Radio
  • $80003FFF para $80004000: o bit 13 (Multiline, $1000) e o bit 14 (Password, $2000) da Tabela 230 são ambos limpos, pelo que uma caixa de palavra-passe mascarada se torna um campo de texto simples de linha única
  • Abaixo do bit 32 o dano começa em 2^24: 16777219 (Comb mais ReadOnly e Required) arredonda para 16777220, o que deixa cair ReadOnly e Required e acrescenta NoExport

O último caso importava para o HotPDF para além da edição de formulários. O ExportLoadedPagesStructured constrói o seu modelo de formulários JSON e XML a partir da máscara efetiva e usa o NoExport na sua decisão de supressão de valores, pelo que uma máscara arredondada podia esconder um valor ordinário ou reportar os membros readOnly e required errados. O caminho de exportação estruturada recebeu a mesma leitura exata na v2.989.8

Mapa de bits das flags de campos AcroForm do HotPDF para Delphi: a máscara /Ff $8000FFFF com o bit 32 e os bits 1 a 16 ativos perde os bits 1 a 16 num único arredondamento Single e ganha o bit 17 Pushbutton, tornando-se $80010000
As flags das Tabelas 226, 228 e 230 da ISO 32000-1 movem-se em grupo: Radio vira Pushbutton enquanto ReadOnly, Required, NoExport, Multiline e Password podem desaparecer no mesmo passo de arredondamento

Como o HotPDF lê o token inteiro de 32 bits exato

O HotPDF Delphi Component corrige o lado da leitura sem mudar o modelo de objetos numérico. O parser já guarda o texto do token original ao lado do valor Single, pelo que o leitor de flags de campos analisa esse token como um inteiro, acumula-o num Int64 limitado, e copia os 32 bits resultantes para o Integer que a API pública devolve. As APIs numéricas genéricas e o antigo fallback não inteiro ficam como estavam, e só os consumidores de flags de campos tomam o caminho exato

Caminho de leitura exata das flags de campos AcroForm do HotPDF no Delphi: o parser guarda o token de origem ao lado do valor Single, o TryParseFlagMask acumula-o num Int64 limitado, e os 32 bits chegam ao GetLoadedFormFieldFlags e ao SetLoadedFormFieldFlags como um Integer
As flags de campos analisam o texto do token guardado em vez do Single arredondado, pelo que ambas as grafias de uma máscara de bit alto, 2147549183 e -2147418113, chegam com bits idênticos

O parser tem de aceitar ambas as formas como ficheiros reais escrevem uma máscara de bit alto. Alguns escritores emitem o valor sem sinal 2147549183, que está acima do limite de inteiro 2^31 - 1 do Anexo C mas é comum na prática; outros emitem o valor com sinal -2147418113. Ambos descrevem os mesmos bits. Uma versão do parser limitado tem este aspeto:

function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
  P, Digit: Integer;
  Negative: Boolean;
  Number, Limit: Int64;
begin
  Result := False;
  Mask := 0;
  if Token = '' then Exit;
  P := 1;
  Negative := Token[1] = '-';
  if Token[1] in ['+', '-'] then Inc(P);
  if P > Length(Token) then Exit;
  // tokens com sinal até -2^31, tokens sem sinal até 2^32 - 1
  if Negative then Limit := 2147483648 else Limit := 4294967295;
  Number := 0;
  while P <= Length(Token) do
  begin
    if not (Token[P] in ['0'..'9']) then Exit;  // os reais tomam o antigo fallback
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // limitado, sem overflow
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Dois detalhes são deliberados. O limite é verificado antes de cada multiplicação, pelo que um token de 400 dígitos falha limparamente em vez de fazer overflow do Int64. E um token como 3.0 é rejeitado, o que manda o chamador de volta ao Round(Value); o caminho exato melhora máscaras de inteiros sem inventar semântica nova para as malformadas

Do código da aplicação nada muda nas assinaturas. O THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer devolve a máscara efetiva, percorrendo a cadeia /Parent da mesma forma descrita em como o HotPDF resolve valores de campos AcroForm herdados e os repõe, e o THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) escreve uma máscara local completa. Uma máscara com o bit 32 ativo é simplesmente um Integer negativo, pelo que mova os bits em vez de converter o valor:

const
  FF_RADIO      = $00008000;  // bit 16, ISO 32000-1 Tabela 228
  FF_PUSHBUTTON = $00010000;  // bit 17, Tabela 228
var
  Pdf: THotPDF;
  I, Native: Integer;
  Bits: Cardinal;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    for I := 0 to Pdf.GetFormFieldCount - 1 do
    begin
      Native := Pdf.GetLoadedFormFieldFlags(I);
      Move(Native, Bits, SizeOf(Bits));   // os mesmos 32 bits, sem conversão
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // bit alto mais Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

Ler uma máscara herdada deixa o dicionário filho intocado; escrever substitui a máscara inteira por uma entrada local no campo selecionado. Os atalhos SetLoadedFormFieldReadOnly e SetLoadedFormFieldRequired partem da máscara herdada efetiva, alternam um bit, e mantêm todos os outros bits exatos. Para preencher valores em vez de flags, veja definir valores de campos de formulário num PDF carregado com o HotPDF

Por que importa a ordem de Value e RememberSourceToken?

A ordem importa porque o THPDFNumericObject só confia no seu token guardado enquanto o valor Single estiver inalterado. O RememberSourceToken guarda o token juntamente com uma fotografia do Value atual, e o getter SourceToken devolve uma string vazia assim que o Value difere dessa fotografia. Essa regra de invalidação é o que impede um número editado de ser guardado com o seu texto original obsoleto

A regra transforma-se numa armadilha do lado da escrita. Guarde o token primeiro e atribua o valor segundo, e a fotografia guarda o valor antigo, o getter reporta que não há token, e o serializador regressa a formatar o Single arredondado. Todos os escritores de flags dentro da biblioteca, incluindo os atalhos e os construtores nativos de campos, seguem a mesma sequência:

// Certo: a fotografia corresponde ao Value, pelo que o token exato sobrevive à gravação
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Errado: a fotografia é tirada antes de o Value mudar, pelo que o SourceToken
// é invalidado e o Single arredondado é escrito em vez disso
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Como o SaveNumericObject escreve o token guardado sempre que houver um válido, acertar na ordem é o que faz um ficheiro gravado e reaberto transportar o inteiro original, não uma aproximação arredondada dele

O token null que fez dois campos partilhar um nome

A mesma versão corrigiu um segundo defeito do parser encontrado ao testar estas máscaras: um valor null num dicionário lido do fluxo nunca era consumido. O HotPDF espreita o próximo token para o classificar, e essa verificação deixa a posição do fluxo no início do token. O ramo otNull do leitor de dicionários de fluxo criava o objeto null mas não avançava para além dos quatro bytes de null

A consequência não era uma pendurice mas uma leitura errada silenciosa. A próxima procura de chave varria para a frente a partir do sítio errado, passava o fim do dicionário atual, e lia chaves do objeto indireto seguinte, que então sobrescrevia o campo atual. Na lista de campos o sintoma eram dois campos diferentes a reportar o mesmo nome e o mesmo valor. Os parsers de matriz e de span já consumiam null corretamente, pelo que a correção avança o fluxo quatro bytes nesse ramo em vez de reescrever o parser geral. Um cursor que não se move é da mesma família de bug que a paragem do cursor do parser PDF que pendurava o HotPDF em entrada malformada, com um sintoma diferente

A correção também clarificou a herança. Um /Ff null local, ou uma referência que resolva para null, é tratado como ausente e a procura continua para o pai, enquanto um 0 explícito é uma máscara real que sobrepõe o pai

O que uma diretiva {$R-} prova e não prova

A primeira hipótese para o bug das flags era um erro de verificação de intervalo quando uma máscara grande era atribuída a um Integer. Estava errada, e a razão é um detalhe do Delphi que vale a pena conhecer: uma diretiva local {$R-} colocada antes do ParseSimpleDictionary em HPDFDoc.pas não tinha restauração correspondente, pelo que todas as funções depois dela na unidade compilavam com as verificações de intervalo desligadas. O Delphi não repõe uma diretiva de interruptor no fim de uma função, e um ficheiro include herda o estado da diretiva no ponto onde é incluído

Assim, um -$R+ ou -Cr na linha de comandos só prova a configuração inicial da unidade. Não diz nada sobre uma função específica mais abaixo. Para saber se a verificação de intervalo está ativa numa dada rotina, encontre a diretiva mais próxima acima dela, depois confirme o comportamento com um valor de fronteira real. Os dois consumidores antigos de flags locais nunca levantaram a exceção de intervalo que a hipótese previa, pelo que essa exceção em falta não podia ser citada como evidência de uma correção. O defeito real era o arredondamento Single pelos bits de flags conhecidos

A leitura de token exato é honesta quanto ao seu âmbito também. Não muda o estado da diretiva ao nível da unidade, não atribui significado a bits reservados, e não afirma que toda a combinação de bits é um campo válido. Garante que os 32 bits no ficheiro são os 32 bits que o seu código vê

FAQ

Por que não mudar THPDFNumericObject.Value para Double ou Int64?

Mudar o tipo de um campo público quebraria todos os chamadores que o leem ou lhe atribuem, e os números PDF são reais tantas vezes como inteiros. Manter o Single e ler as flags de campos do token original corrige o bug para máscaras de bits sem mudar qualquer declaração ou convenção de chamada existente

O SetLoadedFormFieldFlags aceita máscaras com o bit 32 ativo?

Sim. Passe a máscara como um Integer com sinal; $80000002 e -2147483646 são os mesmos 32 bits. O GetLoadedFormFieldFlags devolve o mesmo valor depois de gravar e recarregar, incluindo o Required

Que versão do HotPDF lê flags de campos AcroForm exatas?

As leituras de flags de campos exatas e a correção do token null chegaram no HotPDF v2.989.5, e a exportação estruturada JSON e XML adotou a máscara exata na v2.989.8. Versões anteriores arredondam qualquer máscara acima de 2^24 através do Single

Flags de campos AcroForm exatas, procura de campos herdados e edição de formulários carregados fazem parte do HotPDF Component for Delphi and C++Builder, uma biblioteca PDF nativa para criar, carregar e editar documentos PDF