Artykuł techniczny

Dlaczego pojedynczy float psuje flagi pól AcroForm w Delphi

HotPDF Delphi Component przechowuje każdą liczbę PDF w THPDFNumericObject.Value, które jest zadeklarowane jako Single, a Single nie pomieści każdej 32-bitowej liczby całkowitej. Flagi pól AcroForm (/Ff) to 32-bitowa maska, więc każda maska z ustawionym wysokim bitem była zaokrąglana przy wczytaniu: $8000FFFF wracał jako $80010000. Od v2.989.5 odczyty flag pól parsują pierwotny token całkowity dokładnie i kopiują jego bity do istniejącego API Integer

Ten błąd jest warty przyjrzenia się z bliska, nawet jeśli nigdy nie dotykasz formularzy PDF. Nic się nie wywaliło, żaden wyjątek nie wyleciał, a większość plików testowych przechodziła, bo większość realnych masek jest mała. Porażka wychodziła tylko, gdy wysoki bit i niskie zwykłe flagi były obecne razem, i wtedy nie gubiła jednego bitu, tylko przepisywała kilka naraz. Trzymanie maski bitowej w typie zmiennoprzecinkowym to klasyczna pułapka Delphi, i tak to wygląda w kodzie produkcyjnym

Dlaczego Single gubi bity flag pól AcroForm?

Delphi Single ma 24-bitową mantysę, więc reprezentuje dokładnie każdą liczbę całkowitą do 2^24 (16777216) i nic ponad to bez dziur. Między 2^31 a 2^32 kolejne wartości Single odstają o 256. Maska flag pól z ustawionym bitem 32 mieszka dokładnie w tym zakresie, więc jej osiem niskich bitów, a często więcej, jest zaokrąglanych do najbliższej wielokrotności 256

Diagram zaokrąglania flag pól AcroForm HotPDF dla Delphi: 32-bitowa maska /Ff $8000FFFF, czyli 2147549183, przechodzi przez Single z 24-bitową mantysą i wraca jako $80010000, bo reprezentowalne wartości Single odstają o 256 między 2^31 a 2^32
Powyżej 2^31 Delphi Single potrafi przechować tylko wielokrotności 256, więc zaokrąglanie do najbliższej przepisuje niski bajt 32-bitowej maski flag pól bez żadnego wyjątku

ISO 32000-1 §7.3.3 definiuje obiekty całkowite i rzeczywiste jako osobne rodzaje liczb, a aneks C wypisuje różne limity implementacyjne dla każdego, więc liczba całkowita PDF nie ma obowiązku mieścić się w 32-bitowym floacie. Wpis flag pól jest zdefiniowany w tabeli 221 jako liczba całkowita i jest czytany bit po bicie, nie jako wielkość. Zaokrąglenie wielkości o ułamek jest nieszkodliwe; zaokrąglenie maski przenosi się na sąsiednie bity. Cały efekt odtworzysz w kilku linijkach zwykłego Delphi:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, niereprezentowalne jako Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: bity 1-16 wyzerowane, bit 17 ustawiony

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

Wniosek uogólnia się poza PDF. Single, Double, Variant trzymający floata albo liczba JSON zdekodowana do floata to niewłaściwy pojemnik na pole bitowe, nawet gdy wartości, którymi akurat testujesz, są małe. Double odsuwa dokładny zakres do 2^53, co ukrywa problem dla masek 32-bitowych, ale wciąż zostawia typ wyrażający zły zamiar. Używaj Cardinal, Integer albo Int64 i konwertuj z tekstu źródłowego, nie z floata, który już został zaokrąglony

Które flagi pól się przewracają: Radio, Pushbutton, Password i Multiline

Te dwie maski wyżej nie są przypadkowe; pokazują, jak jeden krok zaokrąglania zmienia typ pola, jaki widzi czytnik. PDF numeruje bity flag od 1, więc bit n ma wartość 2^(n-1). ISO 32000-1 §12.7.3.1 tabela 226 definiuje bity 1-3 dla każdego pola, tabela 228 definiuje flagi przycisków, a tabela 230 definiuje flagi pól tekstowych

  • $8000FFFF do $80010000: bit 16 (Radio, $8000, tabela 228) jest wyzerowany, a bit 17 (Pushbutton, $10000) ustawiony, więc przycisk radiowy zamienia się w przycisk zwykły
  • To samo zaokrąglenie zeruje bity 1-3, więc ReadOnly, Required i NoExport z tabeli 226 znikają razem z Radio
  • $80003FFF do $80004000: bit 13 (Multiline, $1000) i bit 14 (Password, $2000) z tabeli 230 są oba zerowane, więc maskowane pole hasła staje się jednoliniowym zwykłym polem tekstowym
  • Poniżej bitu 32 szkoda zaczyna się od 2^24: 16777219 (Comb plus ReadOnly i Required) zaokrągla się do 16777220, co gubi ReadOnly i Required, a dodaje NoExport

Ostatni przypadek miał znaczenie dla HotPDF poza edycją formularzy. ExportLoadedPagesStructured buduje swój model formularzy JSON i XML z efektywnej maski i używa NoExport w decyzji o wyciszeniu wartości, więc zaokrąglona maska mogła ukryć zwykłą wartość albo zgłosić złe składowe readOnly i required. Ścieżka eksportu strukturalnego dostała ten sam dokładny odczyt w v2.989.8

Mapa bitów flag pól AcroForm HotPDF dla Delphi: maska /Ff $8000FFFF z bitem 32 i bitami 1-16 ustawionymi gubi bity 1-16 w jednym zaokrągleniu Single i zyskuje bit 17 Pushbutton, stając się $80010000
Flagi z tabel 226, 228 i 230 ISO 32000-1 ruszają się grupowo: Radio przewraca się w Pushbutton, a ReadOnly, Required, NoExport, Multiline i Password mogą zniknąć w tym samym kroku zaokrąglania

Jak HotPDF czyta dokładny token 32-bitowej liczby całkowitej

HotPDF Delphi Component naprawia stronę odczytu bez zmiany modelu obiektów liczbowych. Parser już zapamiętuje pierwotny tekst tokena obok wartości Single, więc czytnik flag pól parsuje ten token jako liczbę całkowitą, akumuluje go w ograniczonym Int64 i kopiuje powstałe 32 bity do Integer, które zwraca publiczne API. Generyczne API liczbowe i stary fallback dla nie-całkowitych zostają jak były, a dokładną ścieżką idą tylko konsumenci flag pól

Dokładna ścieżka odczytu flag pól AcroForm w HotPDF dla Delphi: parser trzyma token źródłowy obok wartości Single, TryParseFlagMask akumuluje go w ograniczonym Int64, a 32 bity docierają do GetLoadedFormFieldFlags i SetLoadedFormFieldFlags jako Integer
Flagi pól parsują zapamiętany tekst tokena zamiast zaokrąglonego Single, więc oba zapisy maski z wysokim bitem, 2147549183 i -2147418113, przychodzą z identycznymi bitami

Parser musi przyjmować oba sposoby, jakimi prawdziwe pliki zapisują maskę z wysokim bitem. Niektórzy producenci emitują wartość bez znaku 2147549183, powyżej limitu liczby całkowitej z aneksu C, czyli 2^31 - 1, ale częstą w praktyce; inni emitują wartość ze znakiem -2147418113. Oba opisują te same bity. Wersja ograniczonego parsera wygląda tak:

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;
  // Napisy ze znakiem do -2^31, bez znaku 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;  // rzeczywiste idą na stary fallback
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // ograniczone, bez przepełnienia
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Dwa detale są celowe. Ograniczenie jest sprawdzane przed każdym mnożeniem, więc 400-cyfrowy token zawodzi czysto zamiast przepełniać Int64. A token taki jak 3.0 jest odrzucany, co odsyła wołającego do Round(Value); dokładna ścieżka poprawia maski całkowite, nie wymyślając nowej semantyki dla zniekształconych

Z kodu aplikacji nic się nie zmienia w sygnaturach. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer zwraca efektywną maskę, przechodząc łańcuch /Parent tak samo, jak opisuje jak HotPDF rozwiązuje odziedziczone wartości pól AcroForm i je resetuje, a THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) zapisuje kompletną lokalną maskę. Maska z ustawionym bitem 32 to po prostu ujemny Integer, więc przenoś bity zamiast konwertować wartość:

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));   // te same 32 bity, bez konwersji
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

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

Odczyt odziedziczonej maski zostawia słownik potomka nietknięty; zapis wymienia całą maskę na lokalny wpis na wybranym polu. Skróty SetLoadedFormFieldReadOnly i SetLoadedFormFieldRequired startują z efektywnej odziedziczonej maski, przełączają jeden bit i trzymają każdy inny bit dokładnie. Do wypełniania wartości zamiast flag służy ustawianie wartości pól formularza we wczytanym PDF z HotPDF

Dlaczego kolejność Value i RememberSourceToken ma znaczenie?

Kolejność ma znaczenie, bo THPDFNumericObject ufa swojemu zapamiętanemu tokenowi tylko dopóki wartość Single jest niezmieniona. RememberSourceToken przechowuje token razem z migawką bieżącego Value, a getter SourceToken zwraca pusty napis, gdy tylko Value odbiega od tej migawki. Ta reguła unieważniania pilnuje, żeby edytowana liczba nie została zapisana ze swoim nieaktualnym pierwotnym tekstem

Na stronie zapisu reguła zamienia się w pułapkę. Zapamiętaj najpierw token, a przypisz wartość potem, i migawka trzyma starą wartość, getter zgłasza brak tokena, a serializator schodzi do formatowania zaokrąglonego Single. Każdy zapisujący flagi wewnątrz biblioteki, łącznie ze skrótami i natywnymi konstruktorami pól, trzyma się tej samej sekwencji:

// Dobrze: migawka zgadza się z Value, więc dokładny token przeżywa zapis
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Źle: migawka jest brana przed zmianą Value, więc SourceToken
// jest unieważniany i zamiast niego jest zapisywany zaokrąglony Single
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Ponieważ SaveNumericObject zapisuje zapamiętany token, ile razy jest ważny, trafienie w kolejność robi z zapisanego i ponownie otwartego pliku nosiciela pierwotnej liczby całkowitej, a nie jej zaokrąglonego przybliżenia

Token null, przez który dwa pola dzieliły jedną nazwę

To samo wydanie naprawiło drugą wadę parsera, znalezioną przy testowaniu tych masek: wartość null w słowniku czytanym ze strumienia nigdy nie była konsumowana. HotPDF podgląda następny token, żeby go sklasyfikować, i to sprawdzenie zostawia pozycję strumienia na starcie tokena. Gałąź otNull strumieniowego czytnika słowników tworzyła obiekt null, ale nie przeszła dalej ponad cztery bajty null

Konsekwencją nie było zawieszenie, tylko ciche błędne odczytanie. Następne wyszukiwanie klucza skanowało w przód od złego miejsca, przebiegło koniec bieżącego słownika i czytało klucze z następnego obiektu pośredniego, który potem nadpisywał bieżące pole. Na liście pól objawem były dwa różne pola zgłaszające tę samą nazwę i tę samą wartość. Parsery tablic i spanów konsumowały null już poprawnie, więc poprawka przesuwa strumień o cztery bajty w tej jednej gałęzi, zamiast przepisywać parser ogólny. Kursor, który się nie rusza, to ta sama rodzina błędu co w zastanięciach kursora parsera PDF, które zawieszały HotPDF na zniekształconym wejściu, z innym objawem

Poprawka doprecyzowała też dziedziczenie. Lokalne /Ff null, albo referencja rozwiązująca się do null, jest traktowane jako brak i wyszukiwanie idzie dalej do rodzica, podczas gdy jawne 0 to prawdziwa maska nadpisująca rodzica

Co dyrektywa {$R-} dowodzi, a czego nie

Pierwszą hipotezą dla błędu flag był błąd sprawdzenia zakresu przy przypisaniu dużej maski do Integer. Była zła, a powód to szczegół Delphi warty znajomości: lokalna dyrektywa {$R-} wstawiona przed ParseSimpleDictionary w HPDFDoc.pas nie miała pasującego przywrócenia, więc każda funkcja po niej w module kompilowała się z wyłączonymi kontrolami zakresu. Delphi nie resetuje dyrektywy przełączającej na końcu funkcji, a plik dołączany dziedziczy stan dyrektywy w punkcie, gdzie jest dołączany

Więc -$R+ albo -Cr z linii poleceń dowodzi tylko początkowego ustawienia modułu. O konkretnej funkcji niżej nie mówi nic. Żeby wiedzieć, czy sprawdzenie zakresu jest aktywne dla danej procedury, znajdź najbliższą dyrektywę powyżej niej, a potem potwierdź zachowanie prawdziwą wartością brzegową. Dwa starsze lokalne konsumenci flag nigdy nie podniosły wyjątku zakresu, którego hipoteza się spodziewała, więc tego brakującego wyjątku nie dało się cytować jako dowodu naprawy. Prawdziwą wadą było zaokrąglenie Single po znanych bitach flag

Odczyt dokładnego tokena jest też uczciwy co do swojego zasięgu. Nie zmienia stanu dyrektyw modułu, nie nadaje znaczenia zarezerwowanym bitom i nie twierdzi, że każda kombinacja bitów to ważne pole. Gwarantuje, że 32 bity w pliku to 32 bity, które widzi twój kod

FAQ

Dlaczego nie zmienić THPDFNumericObject.Value na Double albo Int64?

Zmiana typu pola publicznego złamałaby każdego wołającego, który je czyta albo przypisuje, a liczby PDF są rzeczywiste równie często, jak całkowite. Trzymanie Single i odczyt flag pól z pierwotnego tokena naprawia błąd dla masek bitowych bez zmiany jakiejkolwiek istniejącej deklaracji albo konwencji wywołania

Czy SetLoadedFormFieldFlags przyjmuje maski z ustawionym bitem 32?

Tak. Przekaż maskę jako Integer ze znakiem; $80000002 i -2147483646 to te same 32 bity. GetLoadedFormFieldFlags zwraca tę samą wartość po zapisie i ponownym wczytaniu, łącznie z Required

Która wersja HotPDF czyta dokładne flagi pól AcroForm?

Dokładne odczyty flag pól i poprawka tokena null trafiły do HotPDF v2.989.5, a strukturalny eksport JSON i XML przyjął dokładną maskę w v2.989.8. Wcześniejsze wersje zaokrąglają każdą maskę powyżej 2^24 przez Single

Dokładne flagi pól AcroForm, wyszukiwanie pól odziedziczonych i edycja wczytanych formularzy są częścią HotPDF Component for Delphi and C++Builder, natywnej biblioteki PDF do tworzenia, wczytywania i edycji dokumentów PDF