Teknisk artikel

Därför förstör en Single AcroForm-fältflaggor i Delphi

HotPDF Delphi Component lagrar varje PDF-tal i THPDFNumericObject.Value, som är deklarerat som Single, och en Single kan inte hålla varje 32-bitars heltal. AcroForm-fältflaggor (/Ff) är en 32-bitars mask, så varje mask med en hög bit satt avrundades vid inläsning: $8000FFFF kom tillbaka som $80010000. Sedan v2.989.5 parsar fältflaggläsningarna den ursprungliga heltals-token exakt och kopierar dess bitar in i det befintliga Integer-API:et

Buggen är värd en närmare titt även om du aldrig rör PDF-formulär. Inget krashade, inget undantag kastades, och de flesta testfiler gick igenom, eftersom de flesta verkliga masker är små. Felet visade sig bara när en hög bit och de vanliga låga flaggorna fanns tillsammans, och då tappade det inte en bit, det skrev om flera på en gång. Att lagra en bitmask i en flyttalstyp är en klassisk Delphi-fälla, och det här är hur den ser ut i produktionskod

Varför tappar en Single AcroForm-fältflaggbitar?

En Delphi Single har en 24-bitars mantissa, så den representerar varje heltal upp till 2^24 (16777216) exakt och inget därutöver utan gap. Mellan 2^31 och 2^32 ligger sammanhängande Single-värden 256 isär. En fältflaggmask med bit 32 satt bor exakt i det intervallet, så dess låga åtta bitar, och ofta fler, avrundas till närmaste multipel av 256

Diagram över avrundning av AcroForm-fältflaggor i HotPDF för Delphi: 32-bitarsmasken /Ff $8000FFFF, 2147549183, passerar en Single med 24-bitars mantissa och kommer tillbaka som $80010000 eftersom representerbara Single-värden ligger 256 isär mellan 2^31 och 2^32
Över 2^31 kan en Delphi Single bara lagra multipler av 256, så round-to-nearest skriver om lågbyten i en 32-bitars fältflaggmask utan något undantag

ISO 32000-1 §7.3.3 definierar heltals- och realobjekt som två skilda slags tal, och Appendix C listar olika implementationsgränser för vardera, så ett PDF-heltal har ingen skyldighet att passa en 32-bitars float. Fältflaggposten är definierad i tabell 221 som ett heltal, och den läses bit för bit, inte som en kvantitet. Att avrunda en kvantitet med en bråkdel är harmlöst; att avrunda en mask slår över i grannbitar. Du kan reproducera hela effekten på några rader vanlig Delphi:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, inte representerbar som Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: bit 1-16 nollställda, bit 17 satt

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: bit 1-14 nollställda, bit 15 satt
end;

Lärdomen generaliserar bortom PDF. En Single, en Double, en Variant som håller ett flyttal, eller ett JSON-tal avkodat till en float är fel behållare för ett bitfält, även när de värden du råkar testa med är små. Double skjuter det exakta intervallet ut till 2^53, vilket gömmer problemet för 32-bitarsmasker men lämnar fortfarande typen som uttrycker fel avsikt. Använd Cardinal, Integer eller Int64 och konvertera från källtexten, inte från en float som redan avrundats

Vilka fältflaggor vänder: Radio, Pushbutton, Password och Multiline

De två maskerna ovan är inte godtyckliga; de visar hur ett enda avrundningssteg ändrar fälttypen en läsare ser. PDF räknar flaggbitar från 1, så bit n har värdet 2^(n-1). ISO 32000-1 §12.7.3.1 tabell 226 definierar bit 1 till 3 för varje fält, tabell 228 definierar knappflaggorna, och tabell 230 definierar textfältflaggorna

  • $8000FFFF till $80010000: bit 16 (Radio, $8000, tabell 228) nollställs och bit 17 (Pushbutton, $10000) sätts, så en radioknapp blir en tryckknapp
  • Samma avrundning nollställer bit 1 till 3, så ReadOnly, Required och NoExport från tabell 226 försvinner tillsammans med Radio
  • $80003FFF till $80004000: bit 13 (Multiline, $1000) och bit 14 (Password, $2000) från tabell 230 nollställs båda, så en maskerad lösenordsruta blir ett vanligt textfält med en enda rad
  • Under bit 32 börjar skadan vid 2^24: 16777219 (Comb plus ReadOnly och Required) avrundas till 16777220, vilket tappar ReadOnly och Required och lägger till NoExport

Sista fallet betydde något för HotPDF bortom formulärsredigering. ExportLoadedPagesStructured bygger sin JSON- och XML-formulärmodell från den gällande masken och använder NoExport i sitt beslut om värdesundertryckning, så en avrundad mask kunde dölja ett vanligt värde eller rapportera fel readOnly- och required-medlemmar. Den strukturerade exportvägen fick samma exakta läsning i v2.989.8

Bitkarta över AcroForm-fältflaggor i HotPDF för Delphi: masken /Ff $8000FFFF med bit 32 och bit 1 till 16 satta tappar bit 1 till 16 i en enda Single-avrundning och får Pushbutton-bit 17, och blir $80010000
Flaggorna i ISO 32000-1 tabell 226, 228 och 230 rör sig som en grupp: Radio vänder till Pushbutton medan ReadOnly, Required, NoExport, Multiline och Password kan försvinna i samma avrundningssteg

Hur HotPDF läser den exakta 32-bitars heltals-token

HotPDF Delphi Component fixar lässidan utan att ändra den numeriska objektmodellen. Parsaren minns redan den ursprungliga tokentexten bredvid Single-värdet, så fältflaggläsaren parsar den tokenen som ett heltal, ackumulerar den i en avgränsad Int64, och kopierar de resulterande 32 bitarna in i det Integer som det publika API:et returnerar. Generiska numeriska API:er och den gamla icke-heltalsreservvägen blir som de var, och bara fältflaggkonsumenter tar den exakta vägen

Den exakta läsinvägen för AcroForm-fältflaggor i HotPDF för Delphi: parsaren behåller källtokenen bredvid Single-värdet, TryParseFlagMask ackumulerar den i en avgränsad Int64, och de 32 bitarna når GetLoadedFormFieldFlags och SetLoadedFormFieldFlags som en Integer
Fältflaggor parsar den ihågkomna tokentexten i stället för den avrundade Single:n, så båda stavningarna av en hög-bit-mask, 2147549183 och -2147418113, anländer med identiska bitar

Parsaren måste acceptera båda sätten verkliga filer stavar en hög-bit-mask på. Vissa skrivare sänder det oesignerade värdet 2147549183, som ligger över appendix C:s heltalsgräns 2^31 - 1 men är vanligt i praktiken; andra sänder det signerade värdet -2147418113. Båda beskriver samma bitar. En version av den avgränsade parsaren ser ut så här:

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;
  // Signerade token ner till -2^31, oesignerade upp till 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;  // reella tal tar gamla reservvägen
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // avgränsat, inget spill
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Två detaljer är medvetna. Gränsen kontrolleras före varje multiplikation, så en 400-siffrig token misslyckas rent i stället för att spilla över Int64. Och en token som 3.0 avvisas, vilket skickar anroparen tillbaka till Round(Value); den exakta vägen förbättrar heltalsmasker utan att uppfinna ny semantik för felformade

Från applikationskod ändras ingenting i signaturerna. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer returnerar den gällande masken och vandrar /Parent-kedjan på samma sätt som beskrivs i hur HotPDF slår upp ärvda AcroForm-fältvärden och återställer, och THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) skriver en komplett lokal mask. En mask med bit 32 satt är helt enkelt ett negativt Integer, så flytta bitarna i stället för att konvertera värdet:

const
  FF_RADIO      = $00008000;  // bit 16, ISO 32000-1 tabell 228
  FF_PUSHBUTTON = $00010000;  // bit 17, tabell 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));   // samma 32 bitar, ingen konvertering
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

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

Att läsa en ärvd mask lämnar barnordboken orörd; att skriva ersätter hela masken med en lokal post på det valda fältet. Genvägarna SetLoadedFormFieldReadOnly och SetLoadedFormFieldRequired utgår från den gällande ärvda masken, växlar en bit, och håller varje annan bit exakt. För att fylla värden snarare än flaggor, se att sätta formulärfältvärden i en laddad PDF med HotPDF

Varför spelar ordningen mellan Value och RememberSourceToken roll?

Ordningen spelar roll eftersom THPDFNumericObject bara litar på sin ihågkomna token medan Single-värdet är oförändrat. RememberSourceToken lagrar tokenen tillsammans med en ögonblicksbild av aktuellt Value, och gettern SourceToken returnerar en tom sträng så snart Value skiljer sig från den ögonblicksbilden. Den ogiltigförklaringsregeln är det som hindrar ett redigerat tal från att sparas med sin inaktuella ursprungstext

Regeln blir en fälla på skrivsidan. Kom ihåg tokenen först och tilldela värdet sen, och ögonblicksbilden håller det gamla värdet, gettern rapporterar ingen token, och serialiseraren faller tillbaka på att formatera den avrundade Single:n. Varje flaggskrivare inuti biblioteket, inklusive genvägarna och de nativa fältkonstruktörerna, följer samma sekvens:

// Rätt: ögonblicksbilden matchar Value, så den exakta token överlever sparningen
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Fel: ögonblicksbilden tas innan Value ändras, så SourceToken
// ogiltigförklaras och den avrundade Single:n skrivs i stället
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Eftersom SaveNumericObject skriver den ihågkomna tokenen närhelst en är giltig är det att få ordningen rätt som gör att en sparad och återöppnad fil bär det ursprungliga heltalet, inte en avrundad approximation av det

Null-tokenen som fick två fält att dela ett namn

Samma release fixade en andra parsardefekt som hittades medan dessa masker testades: ett null-värde i en ordbok läst från strömmen konsumerades aldrig. HotPDF tjuvtittar på nästa token för att klassificera den, och den kontrollen lämnar strömpositionen vid tokenens början. Grenen otNull i den strömmande ordboksläsaren skapade null-objektet men gick inte förbi de fyra bytena av null

Konsekvensen var inget hängande utan en tyst feltolkning. Nästa nyckelsökning skannade framåt från fel plats, sprang förbi slutet på nuvarande ordbok, och läste nycklar från följande indirekta objekt, som sedan skrev över aktuellt fält. I fältlistan var symptomet två olika fält som rapporterade samma namn och samma värde. Array- och span-parsarna konsumerade redan null korrekt, så fixen flyttar strömmen fyra byte framåt i just den grenen i stället för att skriva om den generella parsaren. En markör som inte rör sig är samma slags bugg som den i PDF-parsarens markörstopp som hängde HotPDF på felformad indata, med ett annat symptom

Fixen klargjorde också arvet. En lokal /Ff null, eller en referens som löser sig till null, behandlas som frånvarande och uppslagningen fortsätter till föräldern, medan en explicit 0 är en riktig mask som åsidosätter föräldern

Vad ett {$R-}-direktiv bevisar och inte bevisar

Den första hypotesen för flaggbuggen var ett intervallkontrollfel när en stor mask tilldelades till Integer. Den var fel, och skälet är en Delphi-detalj värd att känna till: ett lokalt {$R-}-direktiv placerat före ParseSimpleDictionary i HPDFDoc.pas hade ingen matchande återställning, så varje funktion efter den i uniten kompilerades med intervallkontroller avstängda. Delphi nollställer inte en switch-direktiv i slutet av en funktion, och en include-fil ärver direktivtillståndet vid punkten där den inkluderas

Så en kommandorads--$R+ eller -Cr bevisar bara unitens ursprungliga inställning. Den säger ingenting om en viss funktion längre ner. För att veta om intervallkontroll är aktiv för en given rutin, hitta det närmaste direktivet ovanför den, och bekräfta sedan beteendet med ett verkligt gränsvärde. De två äldre lokala flaggkonsumenterna kastade aldrig intervallundantaget som hypotesen förutsade, så det saknade undantaget kunde inte citeras som bevis för en fix. Den verkliga defekten var Single-avrundningen över kända flaggbitar

Den exakta token-läsningen är ärlig om sin räckvidd också. Den ändrar inte unitvids direktivtillstånd, tilldelar ingen betydelse åt reserverade bitar, och påstår inte att varje bitkombination är ett giltigt fält. Den garanterar att de 32 bitarna i filen är de 32 bitar din kod ser

FAQ

Varför inte ändra THPDFNumericObject.Value till Double eller Int64?

Att ändra typen på ett publikt fält skulle bryta varje anropare som läser eller tilldelar den, och PDF-tal är reella lika ofta som de är heltal. Att behålla Single och läsa fältflaggor från den ursprungliga tokenen fixar buggen för bitmasker utan att ändra någon befintlig deklaration eller anropskonvention

Accepterar SetLoadedFormFieldFlags masker med bit 32 satt?

Ja. Skicka masken som ett signerat Integer; $80000002 och -2147483646 är samma 32 bitar. GetLoadedFormFieldFlags returnerar samma värde efter sparande och återinläsning, inklusive Required

Vilken HotPDF-version läser exakta AcroForm-fältflaggor?

Exakta fältflaggläsningar och null-tokenfixen kom i HotPDF v2.989.5, och strukturerad JSON- och XML-export antog den exakta masken i v2.989.8. Tidigare versioner avrundar varje mask över 2^24 genom Single

Exakta AcroForm-fältflaggor, ärvd fältuppslagning och laddad formulärsredigering är del av HotPDF Component for Delphi and C++Builder, ett nativt PDF-bibliotek för att skapa, läsa in och redigera PDF-dokument