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
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
$8000FFFFpara$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
$80003FFFpara$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 para16777220, 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
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
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