Technický článek

Proč typ Single poškozuje příznaky polí AcroForm v Delphi

HotPDF Delphi Component ukládá každé PDF číslo v THPDFNumericObject.Value, které je deklarované jako Single, a Single nedokáže pojmout každý 32bitový integer. Příznaky AcroForm polí (/Ff) jsou 32bitová maska, takže každá maska s nastaveným vysokým bitem se při načtení zaokrouhlila: $8000FFFF se vrátilo jako $80010000. Od v2.989.5 čtení field flags parsují původní integer token přesně a kopírují jeho bity do existujícího Integer API

Bug stojí za pozorné prohlédnutí, i když se PDF formulářů nikdy nedotýkáte. Nic nespadlo, žádná výjimka se nevyhodila a většina testovacích souborů prošla, protože většina reálných masek je malá. Selhání se ukázalo jen tehdy, když spolu byly přítomny vysoký bit a nízké obvyklé příznaky, a pak neztratil jeden bit, ale přepsal jich několik najednou. Ukládání bitové masky do typu s plovoucí řádovou čárkou je klasická Delphi past a takhle vypadá v produkčním kódu

Proč Single ztrácí bity příznaků AcroForm polí?

Delphi Single má 24bitovou mantisu, takže reprezentuje každý integer do 2^24 (16777216) přesně a nad tím už nic bez mezer. Mezi 2^31 a 2^32 jsou sousední hodnoty Single vzdálené 256. Maska field flags s nastaveným bitem 32 leží přesně v tom rozsahu, takže jejích dolních osm bitů, a často víc, se zaokrouhlí na nejbližší násobek 256

Diagram zaokrouhlování AcroForm field flags v HotPDF pro Delphi: 32bitová maska /Ff $8000FFFF, 2147549183, projde Single s 24bitovou mantisou a vrátí se jako $80010000, protože reprezentovatelné hodnoty Single leží mezi 2^31 a 2^32 po 256
Nad 2^31 umí Delphi Single uchovat jen násobky 256, takže zaokrouhlení k nejbližšímu přepíše dolní bajt 32bitové masky field flags bez jediné výjimky

ISO 32000-1 §7.3.3 definuje objekty integer a real jako odlišné druhy čísel a příloha C uvádí pro každý odlišné implementační limity, takže PDF integer nemá povinnost se vměstnat do 32bitového floatu. Položka field flags je definovaná v tabulce 221 jako integer a čte se bit po bitu, ne jako kvantita. Zaokrouhlení kvantity o nepatrný zlomek je neškodné; zaokrouhlení masky se přenáší do sousedních bitů. Celý efekt si můžete reprodukovat v pár řádcích čistého Delphi:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, nereprezentovatelné jako Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: bity 1-16 vymazány, bit 17 nastaven

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: bity 1-14 vymazány, bit 15 nastaven
end;

Lekce se zobecňuje nad PDF. Single, Double, Variant držící float nebo JSON číslo dekódované do floatu je špatný kontejner pro bitové pole, i když hodnoty, se kterými zrovna testujete, jsou malé. Double odsouvá přesný rozsah až k 2^53, což problém u 32bitových masek schová, ale pořád nechává typ vyjadřovat špatný záměr. Použijte Cardinal, Integer nebo Int64 a převádějte ze zdrojového textu, ne z floatu, který už byl zaokrouhlen

Které příznaky se překlápí: Radio, Pushbutton, Password a Multiline

Ty dvě masky výše nejsou náhodné; ukazují, jak jediný zaokrouhlovací krok změní typ pole, který čtečka vidí. PDF čísluje flag bity od 1, takže bit n má hodnotu 2^(n-1). ISO 32000-1 §12.7.3.1 tabulka 226 definuje bity 1 až 3 pro každé pole, tabulka 228 definuje button flags a tabulka 230 definuje flags textových polí

  • $8000FFFF na $80010000: bit 16 (Radio, $8000, tabulka 228) se vymaže a bit 17 (Pushbutton, $10000) se nastaví, takže radio button se změní na push button
  • Stejné zaokrouhlení vymaže bity 1 až 3, takže ReadOnly, Required a NoExport z tabulky 226 zmizí spolu s Radio
  • $80003FFF na $80004000: bit 13 (Multiline, $1000) i bit 14 (Password, $2000) z tabulky 230 se vymažou, takže maskované pole hesla se stane jednorádkovým obyčejným textovým polem
  • Pod bitem 32 škoda začíná na 2^24: 16777219 (Comb plus ReadOnly a Required) se zaokrouhlí na 16777220, což zahodí ReadOnly a Required a přidá NoExport

Poslední případ byl pro HotPDF důležitý i nad rámec editace formulářů. ExportLoadedPagesStructured staví svůj JSON a XML model formulářů z efektivní masky a používá NoExport v rozhodování o potlačení hodnot, takže zaokrouhlená maska mohla schovat obyčejnou hodnotu nebo nahlásit špatné členy readOnly a required. Strukturovaná exportní cesta dostala totéž přesné čtení ve v2.989.8

Bitová mapa AcroForm field flags v HotPDF pro Delphi: maska /Ff $8000FFFF s nastaveným bitem 32 a bity 1 až 16 ztratí bity 1 až 16 jediným zaokrouhlením Single a získá Pushbutton bit 17, čímž se stane $80010000
Flags z tabulek 226, 228 a 230 ISO 32000-1 se hýbou jako skupina: Radio se překlopí na Pushbutton, zatímco ReadOnly, Required, NoExport, Multiline a Password mohou zmizet v témže zaokrouhlovacím kroku

Jak HotPDF čte přesný 32bitový integer token

HotPDF Delphi Component opravuje čtecí stranu beze změny numerického objektového modelu. Parser si už pamatuje původní text tokenu vedle hodnoty Single, takže čtečka field flags parsuje ten token jako integer, akumuluje ho v ohraničeném Int64 a výsledných 32 bitů zkopíruje do Integer, který vrací veřejné API. Generická numerická API a starý non-integer fallback zůstávají, jak byly, a jen konzumenti field flags berou přesnou cestu

Přesná čtecí cesta AcroForm field flags v HotPDF pro Delphi: parser drží zdrojový token vedle hodnoty Single, TryParseFlagMask ho akumuluje v ohraničeném Int64 a 32 bitů dorazí do GetLoadedFormFieldFlags a SetLoadedFormFieldFlags jako Integer
Field flags parsují zapamatovaný text tokenu místo zaokrouhleného Single, takže oba zápisy masky s vysokým bitem, 2147549183 a -2147418113, dorazí s identickými bity

Parser musí přijmout oba způsoby, jakými reálné soubory píší masku s vysokým bitem. Někteří zapisovači vysílají beznaménkovou hodnotu 2147549183, nad integer limitem přílohy C 2^31 - 1, ale v praxi běžnou; jiní vysílají znaménkovou hodnotu -2147418113. Obojí popisuje tytéž bity. Verze ohraničeného parseru vypadá takhle:

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;
  // Znaménkové tokeny až do -2^31, beznaménkové tokeny až do 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;  // reály jdou starou fallback cestou
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // ohraničené, bez přetečení
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Dva detaily jsou záměrné. Hranice se kontroluje před každým násobením, takže 400místný token selže čistě místo přetečení Int64. A token jako 3.0 se odmítne, což pošle volajícího zpět k Round(Value); přesná cesta vylepší integer masky, aniž by vymýšlela novou sémantiku pro poškozené

Z aplikačního kódu se na signaturách nic nemění. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer vrací efektivní masku a stoupá po řetězu /Parent stejným způsobem, jaký popisuje jak HotPDF řeší dědičné hodnoty AcroForm polí a resety, a THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) zapisuje kompletní lokální masku. Maska s nastaveným bitem 32 je prostě záporný Integer, takže přenášejte bity místo konverze hodnoty:

const
  FF_RADIO      = $00008000;  // bit 16, tabulka 228 ISO 32000-1
  FF_PUSHBUTTON = $00010000;  // bit 17, tabulka 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));   // stejných 32 bitů, bez konverze
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

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

Čtení děděné masky nechá dětský slovník netknutý; zápis nahradí celou masku lokální položkou na vybraném poli. Zkratky SetLoadedFormFieldReadOnly a SetLoadedFormFieldRequired vycházejí z efektivní děděné masky, přepnou jeden bit a každý ostatní bit udrží přesný. Pro vyplňování hodnot místo příznaků viz nastavování hodnot formulářových polí v načteném PDF s HotPDF

Proč záleží na pořadí Value a RememberSourceToken?

Pořadí záleží, protože THPDFNumericObject věří svému zapamatovanému tokenu, jen dokud se hodnota Single nemění. RememberSourceToken ukládá token spolu se snapshotem aktuálního Value a getter SourceToken vrací prázdný řetězec, jakmile se Value od snapshotu odliší. Právě to pravidlo o zneplatnění drží upravené číslo dál od uložení se svým zastaralým původním textem

Na zapisovací straně se z pravidla stane past. Zapamatujte si token nejdřív a hodnotu přiřaďte druhé, a snapshot drží starou hodnotu, getter nehlásí žádný token a serializátor sáhne po formátování zaokrouhleného Single. Každý flag zapisovač uvnitř knihovny, včetně zkratek a nativních konstruktorů polí, dodržuje tutéž sekvenci:

// Správně: snapshot odpovídá Value, takže přesný token přežije uložení
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Špatně: snapshot vznikne dřív, než se změní Value, takže SourceToken
// se zneplatní a místo něj se zapíše zaokrouhlený Single
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Protože SaveNumericObject zapisuje zapamatovaný token, kdykoli je nějaký platný, správné pořadí je to, co udělá, že uložený a znovu otevřený soubor nese původní integer, ne jeho zaokrouhlenou aproximaci

Null token, který dával dvěma polím jedno jméno

Tutéž verze opravila druhý defekt parseru nalezený při testování těchto masek: hodnota null ve slovníku čteném ze streamu se nikdy nespotřebovala. HotPDF nahlíží na další token, aby ho klasifikoval, a ta kontrola nechá pozici streamu na začátku tokenu. Větev otNull streamující čtečky slovníků vytvořila null objekt, ale nepostoupila přes čtyři bajty null

Důsledkem nebyl zasek, ale tiché špatné čtení. Další hledání klíče skenovalo dopředu ze špatného místa, přeběhlo přes konec aktuálního slovníku a četlo klíče z následujícího indirect objektu, který pak přepsal aktuální pole. V seznamu polí byl symptomem dvě různá pole hlásící stejné jméno a stejnou hodnotu. Parsery polí a spanů už spotřebovávaly null správně, takže oprava posune stream o čtyři bajty v této jedné větvi místo přepisování obecného parseru. Kurzor, který se nehne, je stejná rodina bugů jako ta v zasecích kurzoru PDF parseru, které zasekly HotPDF na poškozeném vstupu, jen s jiným symptomem

Oprava také ujasnila dědičnost. Lokální /Ff null, nebo reference, která se vyřeší na null, se bere jako neexistující a hledání pokračuje k rodiči, zatímco explicitní 0 je skutečná maska, která rodiče přebije

Co direktiva {$R-} dokazuje a nedokazuje

První hypotézou flag bugu byla chyba range check při přiřazení velké masky do Integer. Byla špatná a důvodem je Delphi detail, který stojí za znalost: lokální direktiva {$R-} umístěná před ParseSimpleDictionary v HPDFDoc.pas neměla odpovídající obnovení, takže každá funkce za ní v unitu se kompilovala s vypnutými range checky. Delphi neresetuje switch direktivu na konci funkce a include soubor dědí stav direktivy v místě, kde je vkládán

Takže -$R+ nebo -Cr na příkazové řádce dokazuje jen výchozí nastavení unitu. O konkrétní funkci níže neřekne nic. Aby bylo jasné, zda je range checking aktivní pro danou rutinu, najděte nejbližší direktivu nad ní a pak potvrďte chování skutečnou hraniční hodnotou. Dva starší lokální konzumenti flags nikdy nevyhodili výjimku range, kterou hypotéza předpovídala, takže ta chybějící výjimka se nemohla citovat jako důkaz opravy. Skutečným defektem bylo zaokrouhlování Single napříč známými flag bity

Přesné čtení tokenu je poctivé i ke svému dosahu. Nemění stav direktivy na úrovni unitu, nepřisuzuje význam rezervovaným bitům a netvrdí, že každá bitová kombinace je platné pole. Garantuje, že 32 bitů v souboru je těch 32 bitů, které vidí váš kód

FAQ

Proč nezměnit THPDFNumericObject.Value na Double nebo Int64?

Změna typu veřejného pole by rozbila každého volajícího, který ho čte nebo do něj přiřazuje, a PDF čísla jsou real stejně často jako integer. Ponechání Single a čtení field flags z původního tokenu opraví bug u bitových masek beze změny jakékoli existující deklarace či konvence volání

Přijímá SetLoadedFormFieldFlags masky s nastaveným bitem 32?

Ano. Předávejte masku jako znaménkový Integer; $80000002 a -2147483646 jsou tytéž 32 bitů. GetLoadedFormFieldFlags vrací tutéž hodnotu po uložení a znovunačtení, včetně Required

Která verze HotPDF čte přesné AcroForm field flags?

Přesná čtení field flags a oprava null tokenu přibyly v HotPDF v2.989.5 a strukturovaný JSON a XML export převzal přesnou masku ve v2.989.8. Starší verze zaokrouhlují každou masku nad 2^24 přes Single

Přesné AcroForm field flags, hledání děděných polí i editace načtených formulářů jsou součástí HotPDF Component for Delphi and C++Builder, nativní PDF knihovny pro vytváření, načítání a editaci PDF dokumentů