HotPDF Delphi Component memorizza ogni numero PDF in THPDFNumericObject.Value, dichiarato come Single, e un Single non può contenere ogni intero a 32 bit. I field flag AcroForm (/Ff) sono una maschera a 32 bit, quindi qualunque maschera con un bit alto impostato veniva arrotondata al caricamento: $8000FFFF tornava come $80010000. Dalla v2.989.5 le letture dei field flag eseguono il parsing del token intero originale in modo esatto e ne copiano i bit nella API Integer esistente
Il bug merita uno sguardo ravvicinato anche se non toccate mai i moduli PDF. Nulla è andato in crash, nessuna eccezione è stata sollevata, e la maggior parte dei file di test passava, perché la maggior parte delle maschere reali è piccola. Il fallimento compariva solo quando un bit alto e i flag bassi comuni erano presenti insieme, e allora non perdeva un bit: ne riscriveva parecchi in una volta. Memorizzare una maschera di bit in un tipo in virgola mobile è una trappola classica di Delphi, e questo è l'aspetto che ha nel codice di produzione
Perché un Single perde bit dei field flag AcroForm?
Un Single Delphi ha una mantissa a 24 bit, quindi rappresenta ogni intero fino a 2^24 (16777216) in modo esatto e oltre quella soglia nulla senza buchi. Tra 2^31 e 2^32 i valori Single consecutivi distano 256. Una maschera di field flag con il bit 32 impostato vive esattamente in quell'intervallo, quindi i suoi otto bit bassi, e spesso di più, vengono arrotondati al multiplo di 256 più vicino
ISO 32000-1 §7.3.3 definisce gli oggetti integer e real come tipi di numero distinti, e l'Allegato C elenca limiti di implementazione diversi per ciascuno, quindi un intero PDF non ha alcun obbligo di stare in un float a 32 bit. La voce field flags è definita nella Tabella 221 come intero, e si legge bit per bit, non come quantità. Arrotondare una quantità di una frazione minuscola è innocuo; arrotondare una maschera propaga il riporto nei bit vicini. Potete riprodurre l'intero effetto in poche righe di Delphi puro:
var
S: Single;
Mask: Int64;
begin
S := $8000FFFF; // 2147549183, non rappresentabile come Single
Mask := Round(S); // 2147549184
Writeln(IntToHex(Mask, 8)); // 80010000: bit da 1 a 16 azzerati, bit 17 impostato
S := $80003FFF;
Mask := Round(S);
Writeln(IntToHex(Mask, 8)); // 80004000: bit da 1 a 14 azzerati, bit 15 impostato
end;
La lezione si generalizza oltre il PDF. Un Single, un Double, un Variant che contiene un float, o un numero JSON decodificato in un float è il contenitore sbagliato per un bit field, anche quando i valori con cui vi capita di testare sono piccoli. Il Double spinge l'intervallo esatto fino a 2^53, il che nasconde il problema per le maschere a 32 bit ma lascia comunque il tipo a esprimere l'intento sbagliato. Usate Cardinal, Integer o Int64 e convertite dal testo sorgente, non da un float già arrotondato
Quali field flag si ribaltano: Radio, Pushbutton, Password e Multiline
Le due maschere sopra non sono arbitrarie; mostrano come un solo passo di arrotondamento cambi il tipo di campo che un reader vede. Il PDF numera i bit dei flag da 1, quindi il bit n vale 2^(n-1). ISO 32000-1 §12.7.3.1 Tabella 226 definisce i bit da 1 a 3 per ogni campo, la Tabella 228 definisce i flag dei bottoni, e la Tabella 230 definisce i flag dei campi di testo
- da
$8000FFFFa$80010000: il bit 16 (Radio,$8000, Tabella 228) viene azzerato e il bit 17 (Pushbutton,$10000) impostato, così un radio button diventa un push button - Lo stesso arrotondamento azzera i bit da 1 a 3, così ReadOnly, Required e NoExport della Tabella 226 scompaiono insieme a Radio
- da
$80003FFFa$80004000: il bit 13 (Multiline,$1000) e il bit 14 (Password,$2000) della Tabella 230 vengono entrambi azzerati, così una casella password mascherata diventa un campo di testo semplice a riga singola - Sotto il bit 32 il danno comincia da 2^24:
16777219(Comb più ReadOnly e Required) si arrotonda a16777220, che perde ReadOnly e Required e aggiunge NoExport
L'ultimo caso contava per HotPDF oltre la modifica dei moduli. ExportLoadedPagesStructured costruisce il suo modello di modulo JSON e XML a partire dalla maschera effettiva e usa NoExport nella decisione di soppressione dei valori, quindi una maschera arrotondata poteva nascondere un valore ordinario o riportare i membri readOnly e required sbagliati. Il percorso di esportazione strutturata ha ricevuto la stessa lettura esatta nella v2.989.8
Come HotPDF legge il token intero a 32 bit in modo esatto
HotPDF Delphi Component corregge il lato lettura senza cambiare il modello a oggetti numerici. Il parser ricorda già il testo del token originale accanto al valore Single, quindi il lettore dei field flag esegue il parsing di quel token come intero, lo accumula in un Int64 limitato, e copia i 32 bit risultanti nell'Integer che la API pubblica restituisce. Le API numeriche generiche e il vecchio fallback non intero restano come erano, e solo i consumatori dei field flag prendono il percorso esatto
Il parser deve accettare entrambi i modi in cui i file reali scrivono una maschera a bit alto. Alcuni writer emettono il valore senza segno 2147549183, che sta sopra il limite intero di 2^31 - 1 dell'Allegato C ma è comune in pratica; altri emettono il valore con segno -2147418113. Entrambi descrivono gli stessi bit. Una versione del parser limitato è questa:
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;
// Token con segno fino a -2^31, token senza segno fino a 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; // i real prendono il vecchio fallback
Digit := Ord(Token[P]) - Ord('0');
if Number > (Limit - Digit) div 10 then Exit; // limitato, nessun overflow
Number := Number * 10 + Digit;
Inc(P);
end;
if Negative and (Number <> 0) then Number := 4294967296 - Number;
Mask := Cardinal(Number);
Result := True;
end;
Due dettagli sono deliberati. Il limite viene controllato prima di ogni moltiplicazione, quindi un token di 400 cifre fallisce in pulizia invece di far overflow su Int64. E un token come 3.0 viene rifiutato, il che rimanda il chiamante a Round(Value); il percorso esatto migliora le maschere intere senza inventare semantiche nuove per quelle malformate
Dal codice applicativo nulla cambia nelle firme. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer restituisce la maschera effettiva, risalendo la catena /Parent allo stesso modo descritto in come HotPDF risolve i valori di campo AcroForm ereditati e li azzera, e THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) scrive una maschera locale completa. Una maschera con il bit 32 impostato è semplicemente un Integer negativo, quindi spostate i bit invece di convertire il valore:
const
FF_RADIO = $00008000; // bit 16, ISO 32000-1 Tabella 228
FF_PUSHBUTTON = $00010000; // bit 17, Tabella 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)); // stessi 32 bit, nessuna conversione
if (Bits and FF_RADIO) <> 0 then
Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
end;
Bits := $80000002; // bit alto più Required
Move(Bits, Native, SizeOf(Native)); // Native = -2147483646
Pdf.SetLoadedFormFieldFlags(0, Native);
Pdf.SaveLoadedDocument('survey-out.pdf');
finally
Pdf.Free;
end;
end;
Leggere una maschera ereditata lascia intatto il dizionario figlio; scrivere sostituisce l'intera maschera con una voce locale sul campo selezionato. Le scorciatoie SetLoadedFormFieldReadOnly e SetLoadedFormFieldRequired partono dalla maschera ereditata effettiva, commutano un bit, e mantengono ogni altro bit esatto. Per riempire valori invece che flag, vedete impostare i valori dei campi modulo in un PDF caricato con HotPDF
Perché conta l'ordine di Value e RememberSourceToken?
L'ordine conta perché THPDFNumericObject si fida del proprio token ricordato solo finché il valore Single non cambia. RememberSourceToken memorizza il token insieme a uno snapshot del Value corrente, e il getter SourceToken restituisce una stringa vuota non appena Value differisce da quello snapshot. Quella regola di invalidamento è ciò che impedisce a un numero modificato di essere salvato con il suo testo originale stantio
La regola diventa una trappola sul lato scrittura. Ricordate prima il token e assegnate il valore dopo, e lo snapshot contiene il vecchio valore, il getter non riporta alcun token, e il serializzatore ripiega sulla formattazione del Single arrotondato. Ogni writer di flag dentro la libreria, comprese le scorciatoie e i costruttori nativi di campo, segue la stessa sequenza:
// Corretto: lo snapshot combacia con Value, quindi il token esatto sopravvive al salvataggio
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
// Sbagliato: lo snapshot viene preso prima che Value cambi, quindi SourceToken
// viene invalidato e al suo posto viene scritto il Single arrotondato
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;
Poiché SaveNumericObject scrive il token ricordato ogni volta che uno è valido, mettere l'ordine giusto è ciò che fa sì che un file salvato e riaperto porti l'intero originale, non una sua approssimazione arrotondata
Il token null che faceva condividere lo stesso nome a due campi
La stessa release ha corretto un secondo difetto del parser trovato testando queste maschere: un valore null in un dizionario letto dallo stream non veniva mai consumato. HotPDF sbircia il token successivo per classificarlo, e quel controllo lascia la posizione dello stream all'inizio del token. Il ramo otNull del lettore di dizionari in streaming creava l'oggetto null ma non avanzava oltre i quattro byte di null
La conseguenza non fu un blocco ma una lettura sbagliata silenziosa. La successiva ricerca di chiave scannerizzava in avanti dal posto sbagliato, superava la fine del dizionario corrente, e leggeva chiavi dall'oggetto indiretto seguente, che poi sovrascriveva il campo corrente. Nella lista dei campi il sintomo era due campi diversi che riportavano lo stesso nome e lo stesso valore. I parser di array e span consumavano già correttamente null, quindi la correzione avanza lo stream di quattro byte in quel solo ramo invece di riscrivere il parser generale. Un cursore che non si muove è la stessa famiglia di bug di gli stalli del cursore del parser PDF che bloccavano HotPDF su input malformati, con un sintomo diverso
La correzione ha anche chiarito l'ereditarietà. Un /Ff null locale, o un riferimento che si risolve in null, viene trattato come assente e la ricerca continua verso il padre, mentre uno 0 esplicito è una maschera vera che scavalca il padre
Cosa dimostra e cosa non dimostra una direttiva {$R-}
La prima ipotesi per il bug dei flag era un errore di range check quando una maschera grande veniva assegnata a un Integer. Era sbagliata, e il motivo è un dettaglio di Delphi che vale la pena conoscere: una direttiva locale {$R-} piazzata prima di ParseSimpleDictionary in HPDFDoc.pas non aveva un ripristino corrispondente, così ogni funzione dopo di lei nell'unità compilava con i range check spenti. Delphi non resetta una direttiva switch alla fine di una funzione, e un file incluso eredita lo stato delle direttive nel punto in cui viene incluso
Quindi un -$R+ o -Cr da riga di comando dimostra solo l'impostazione iniziale dell'unità. Non dice nulla su una funzione particolare più avanti. Per sapere se il range checking è attivo per una data routine, trovate la direttiva più vicina sopra di essa, poi confermate il comportamento con un valore al bordo reale. I due consumatori locali di flag più vecchi non hanno mai sollevato l'eccezione di range che l'ipotesi prevedeva, quindi quell'eccezione mancante non poteva essere citata come prova di una correzione. Il difetto vero era l'arrotondamento Single attraverso i bit di flag noti
Anche la lettura del token esatto è onesta sul proprio perimetro. Non cambia lo stato delle direttive a livello di unità, non assegna significato ai bit riservati, e non pretende che ogni combinazione di bit sia un campo valido. Garantisce che i 32 bit nel file siano i 32 bit che il vostro codice vede
FAQ
Perché non cambiare THPDFNumericObject.Value in Double o Int64?
Cambiare il tipo di un campo pubblico romperebbe ogni chiamante che lo legge o lo assegna, e i numeri PDF sono real almeno spesso quanto sono interi. Mantenere Single e leggere i field flag dal token originale corregge il bug per le maschere di bit senza cambiare alcuna dichiarazione esistente o convenzione di chiamata
SetLoadedFormFieldFlags accetta maschere con il bit 32 impostato?
Sì. Passate la maschera come Integer con segno; $80000002 e -2147483646 sono gli stessi 32 bit. GetLoadedFormFieldFlags restituisce lo stesso valore dopo salvataggio e ricaricamento, Required compreso
Quale versione di HotPDF legge i field flag AcroForm in modo esatto?
Le letture esatte dei field flag e la correzione del token null sono arrivate in HotPDF v2.989.5, e l'esportazione strutturata JSON e XML ha adottato la maschera esatta nella v2.989.8. Le versioni precedenti arrotondano qualsiasi maschera sopra 2^24 attraverso Single
Field flag AcroForm esatti, ricerca dei campi ereditati e modifica dei moduli caricati fanno parte di HotPDF Component per Delphi e C++Builder, una libreria PDF nativa per creare, caricare e modificare documenti PDF