Artigo Técnico

Por que um Single corrompe flags de AcroForm em Delphi

O HotPDF Delphi Component guarda todo número PDF em THPDFNumericObject.Value, declarado como Single, e um Single não consegue guardar todo inteiro de 32 bits. As field flags do AcroForm (/Ff) são uma máscara de 32 bits, então qualquer máscara com um bit alto ligado era arredondada no carregamento: $8000FFFF voltava como $80010000. Desde a v2.989.5, as leituras de field flags fazem o parse do token inteiro original de forma exata e copiam seus bits para a API Integer existente

O bug merece um olhar de perto mesmo que você nunca mexa com formulários PDF. Nada quebrou, nenhuma exceção foi levantada, e a maioria dos arquivos de teste passava, porque a maioria das máscaras reais é pequena. A falha só aparecia quando um bit alto e as flags comuns baixas estavam presentes juntos, e então ela não perdia um bit, ela reescrevia vários de uma vez. Guardar uma máscara de bits em um tipo de ponto flutuante é uma armadilha clássica do Delphi, e é isso que ela parece em código de produção

Por que um Single perde bits de flags de campo AcroForm?

Um Single do Delphi tem um significando de 24 bits, então ele representa todo inteiro até 2^24 (16777216) de forma exata e nada além disso sem buracos. Entre 2^31 e 2^32, valores Single consecutivos estão 256 apartados. Uma máscara de field flag com o bit 32 ligado vive exatamente nessa faixa, então seus oito bits baixos, e muitas vezes mais, são arredondados ao múltiplo de 256 mais próximo

Diagrama de arredondamento de field flags AcroForm do HotPDF para Delphi: a máscara /Ff de 32 bits $8000FFFF, 2147549183, passa por um Single com significando de 24 bits e volta como $80010000 porque valores Single representáveis ficam 256 apartados entre 2^31 e 2^32
Acima de 2^31 um Single do Delphi só consegue guardar múltiplos de 256, então o round-to-nearest reescreve o byte baixo de uma máscara de field flag de 32 bits sem nenhuma 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, então um inteiro PDF não tem obrigação de caber em um float de 32 bits. A entrada de field flags é definida na Tabela 221 como um inteiro, e é lida bit a bit, não como uma quantidade. Arredondar uma quantidade por uma fração minúscula é inofensivo; arredondar uma máscara transborda para bits vizinhos. Você pode reproduzir o efeito todo em poucas linhas de Delphi puro:

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 ligado

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

A lição se generaliza além do PDF. Um Single, um Double, um Variant guardando um float, ou um número JSON decodificado em um float é o contêiner errado para um bit field, mesmo quando os valores que você testa por acaso são pequenos. O Double empurra a faixa exata para 2^53, o que esconde o problema para máscaras de 32 bits, mas ainda deixa o tipo expressando 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

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

As duas máscaras acima não são arbitrárias; elas mostram como um único passo de arredondamento muda o tipo de campo que o leitor vê. O PDF numera bits de flag a partir de 1, então 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 todo campo, 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) é ligado, então um radio button vira um push button
  • O mesmo arredondamento limpa os bits 1 a 3, então 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, então uma caixa de senha mascarada vira 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 descarta ReadOnly e Required e adiciona NoExport

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

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

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

O HotPDF Delphi Component conserta o lado da leitura sem mudar o modelo de objetos numéricos. O parser já lembra o texto do token original ao lado do valor Single, então o leitor de field flags faz o parse desse token como um inteiro, acumula-o em um Int64 limitado e copia os 32 bits resultantes para o Integer que a API pública devolve. APIs numéricas genéricas e o fallback antigo para não inteiros ficam como estavam, e apenas os consumidores de field flags seguem o caminho exato

Caminho de leitura exato de field flags AcroForm do HotPDF em Delphi: o parser guarda o token de origem ao lado do valor Single, o TryParseFlagMask o acumula em um Int64 limitado, e os 32 bits chegam ao GetLoadedFormFieldFlags e ao SetLoadedFormFieldFlags como um Integer
As field flags fazem o parse do texto de token lembrado em vez do Single arredondado, então ambas as grafias de uma máscara de bit alto, 2147549183 e -2147418113, chegam com bits idênticos

O parser precisa aceitar as duas formas com que arquivos reais grafam uma máscara de bit alto. Alguns escritores emitem o valor sem sinal 2147549183, que está acima do limite de inteiro de 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 fica assim:

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;  // reais seguem o fallback antigo
    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 é checado antes de cada multiplicação, então um token de 400 dígitos falha de forma limpa em vez de estourar o Int64. E um token como 3.0 é rejeitado, o que manda o chamador de volta ao Round(Value); o caminho exato melhora máscaras inteiras sem inventar semântica nova para as malformadas

Do código da aplicação, nada muda nas assinaturas. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer devolve a máscara efetiva, subindo a cadeia de /Parent do mesmo jeito descrito em como o HotPDF resolve valores de campo AcroForm herdados e faz reset, e THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) grava uma máscara local completa. Uma máscara com o bit 32 ligado é simplesmente um Integer negativo, então 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));   // 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 todo os outros bits exatos. Para preencher valores em vez de flags, veja definir valores de form field em um PDF carregado com o HotPDF

Por que a ordem de Value e RememberSourceToken importa?

A ordem importa porque o THPDFNumericObject só confia em seu token lembrado enquanto o valor Single não mudou. O RememberSourceToken guarda o token junto com um snapshot do Value atual, e o getter SourceToken devolve string vazia assim que o Value difere desse snapshot. Essa regra de invalidação é o que impede um número editado de ser salvo com seu texto original obsoleto

A regra vira uma armadilha no lado da gravação. Lembre o token primeiro e atribua o valor depois, e o snapshot guarda o valor antigo, o getter reporta que não há token, e o serializador recua para formatar o Single arredondado. Todo escritor de flags dentro da biblioteca, incluindo os atalhos e os construtores nativos de campos, segue a mesma sequência:

// Correto: o snapshot casa com Value, então o token exato sobrevive ao salvamento
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Errado: o snapshot é tirado antes de Value mudar, então o SourceToken
// é invalidado e o Single arredondado é gravado em vez disso
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Como o SaveNumericObject grava o token lembrado sempre que um é válido, acertar a ordem é o que faz um arquivo salvo e reaberto carregar o inteiro original, não uma aproximação arredondada dele

O token null que fez dois campos compartilhar um nome

A mesma versão consertou um segundo defeito de parser achado enquanto testávamos essas máscaras: um valor null em um dicionário lido do stream nunca era consumido. O HotPDF espia o próximo token para classificá-lo, e essa checagem deixa a posição do stream no início do token. O ramo otNull do leitor de dicionários de streaming criava o objeto null mas não avançava além dos quatro bytes de null

A consequência não era uma travada, mas uma leitura errada silenciosa. A próxima busca por chave varria para frente do lugar errado, passava do 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 reportando o mesmo nome e o mesmo valor. Os parsers de array e de span já consumiam null corretamente, então a correção avança o stream em quatro bytes nesse único ramo em vez de reescrever o parser geral. Um cursor que não se move é da mesma família de bug que o dos travamentos de cursor do parser PDF que penduravam o HotPDF em entrada malformada, com um sintoma diferente

A correção também esclareceu a herança. Um /Ff null local, ou uma referência que resolve para null, é tratado como ausente e a busca 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 o que não prova

A primeira hipótese para o bug de flags era um erro de range check quando uma máscara grande era atribuída a um Integer. Estava errada, e a razão é um detalhe do Delphi que vale conhecer: uma diretiva local {$R-} colocada antes de ParseSimpleDictionary em HPDFDoc.pas não tinha restauração correspondente, então toda função depois dela na unit compilava com range checks desligados. O Delphi não reseta uma diretiva de switch no fim de uma função, e um arquivo include herda o estado de diretiva no ponto em que é incluído

Então um -$R+ ou -Cr na linha de comando só prova a configuração inicial da unit. Não diz nada sobre uma função específica mais abaixo. Para saber se o range checking está ativo para uma rotina dada, ache a diretiva mais próxima acima dela, depois confirme o comportamento com um valor de fronteira real. Os dois consumidores antigos locais de flags nunca levantaram a exceção de range que a hipótese previa, então essa exceção ausente não podia ser citada como evidência de correção. O defeito real era o arredondamento Single pelos bits de flag conhecidos

A leitura por token exato é honesta quanto ao seu escopo também. Ela não muda o estado de diretiva da unit, não atribui significado a bits reservados, e não afirma que toda combinação de bits é um campo válido. Ela garante que os 32 bits no arquivo são os 32 bits que seu código vê

FAQ

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

Mudar o tipo de um campo público quebraria todo chamador que o lê ou atribui, e números PDF são reais com a mesma frequência que inteiros. Manter o Single e ler field flags do token original conserta o bug para máscaras de bits sem mudar nenhuma declaração ou convenção de chamada existente

SetLoadedFormFieldFlags aceita máscaras com o bit 32 ligado?

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 salvar e recarregar, incluindo Required

Qual versão do HotPDF lê flags de campo AcroForm exatas?

Leituras exatas de field flags e a correção do token null chegaram no HotPDF v2.989.5, e a export estruturada JSON e XML adotou a máscara exata na v2.989.8. Versões anteriores arredondam qualquer máscara acima de 2^24 pelo Single

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