Техническая статья

Почему Single портит флаги полей AcroForm в Delphi

HotPDF Delphi Component хранит каждое число PDF в THPDFNumericObject.Value, который объявлен как Single, а Single не вмещает любое 32-битное целое. Флаги полей AcroForm (/Ff) — 32-битная маска, поэтому любая маска со взведённым старшим битом округлялась при загрузке: $8000FFFF возвращался как $80010000. С v2.989.5 чтение флагов полей разбирает исходный целочисленный токен точно и копирует его биты в существующий API на Integer

Баг стоит внимательного разбора, даже если вы никогда не трогаете PDF-формы. Ничего не падало, исключение не поднималось, и большинство тестовых файлов проходили, потому что большинство реальных масок маленькие. Сбой проявлялся, только когда старший бит и младшие ходовые флаги оказывались вместе, и тогда он не терял один бит — он переписывал несколько сразу. Хранение битовой маски в типе с плавающей точкой — классические грабли Delphi, и вот как они выглядят в боевом коде

Почему Single теряет биты флагов полей AcroForm?

Delphi-Single имеет 24-битную мантиссу, поэтому представляет точно каждое целое до 2^24 (16777216) и ничего сверх без дыр. Между 2^31 и 2^32 последовательные значения Single отстоят на 256. Маска флагов поля со взведённым битом 32 живёт ровно в этом диапазоне, так что её младшие восемь бит, а часто и больше, округляются до ближайшего кратного 256

Диаграмма округления флагов полей AcroForm в HotPDF для Delphi: 32-битная маска /Ff $8000FFFF, 2147549183, проходит через Single с 24-битной мантиссой и возвращается как $80010000, потому что представимые значения Single отстоят на 256 между 2^31 и 2^32
Выше 2^31 Delphi Single способен хранить только кратные 256, поэтому round-to-nearest переписывает младший байт 32-битной маски флагов поля без всякого исключения

ISO 32000-1 §7.3.3 определяет целые и вещественные объекты как отдельные виды чисел, а Приложение C приводит разные пределы реализации для каждого, так что PDF-целое не обязано влезать в 32-битный float. Запись флагов поля определена в Table 221 как целое, и читается она побитно, а не как количество. Округление количества на крошечную долю безвредно; округление маски переносится в соседние биты. Весь эффект можно воспроизвести несколькими строками обычного Delphi:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, не представимо как Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: биты 1-16 очищены, бит 17 взведён

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: биты 1-14 очищены, бит 15 взведён
end;

Урок обобщается за пределы PDF. Single, Double, Variant с float внутри или JSON-число, декодированное во float, — неправильный контейнер для битового поля, даже когда значения, на которых вы случайно тестировали, маленькие. Double отодвигает точный диапазон до 2^53, что прячет проблему для 32-битных масок, но всё равно оставляет тип, выражающий неверное намерение. Берите Cardinal, Integer или Int64 и конвертируйте из исходного текста, а не из уже округлённого float

Какие флаги полей переворачиваются: Radio, Pushbutton, Password и Multiline

Две маски выше не случайны; они показывают, как один шаг округления меняет тип поля, который видит ридер. PDF нумерует битовые флаги с единицы, поэтому бит n имеет значение 2^(n-1). ISO 32000-1 §12.7.3.1 Table 226 определяет биты с 1 по 3 для любого поля, Table 228 определяет флаги кнопок, а Table 230 — флаги текстовых полей

  • $8000FFFF в $80010000: бит 16 (Radio, $8000, Table 228) очищен, а бит 17 (Pushbutton, $10000) взведён, так что радиокнопка превращается в простую кнопку
  • То же округление очищает биты с 1 по 3, поэтому ReadOnly, Required и NoExport из Table 226 исчезают вместе с Radio
  • $80003FFF в $80004000: бит 13 (Multiline, $1000) и бит 14 (Password, $2000) из Table 230 очищаются оба, так что замаскированное поле пароля становится однострочным обычным текстовым полем
  • Ниже бита 32 ущерб начинается с 2^24: 16777219 (Comb плюс ReadOnly и Required) округляется до 16777220, что роняет ReadOnly и Required и добавляет NoExport

Последний случай был важен для HotPDF и вне правки форм. ExportLoadedPagesStructured строит свою JSON- и XML-модель форм из эффективной маски и использует NoExport в решении о подавлении значения, так что округлённая маска могла спрятать обычное значение или сообщить неверные члены readOnly и required. Путь структурного экспорта получил то же точное чтение в v2.989.8

Битовая карта флагов полей AcroForm в HotPDF для Delphi: маска /Ff $8000FFFF с битом 32 и битами с 1 по 16 теряет биты с 1 по 16 за одно округление Single и получает бит 17 Pushbutton, становясь $80010000
Флаги из Tables 226, 228 и 230 ISO 32000-1 двигаются группой: Radio переворачивается в Pushbutton, тогда как ReadOnly, Required, NoExport, Multiline и Password могут исчезнуть за тот же шаг округления

Как HotPDF читает точный 32-битный целочисленный токен

HotPDF Delphi Component чинит сторону чтения, не меняя числовую объектную модель. Парсер уже помнит исходный текст токена рядом со значением Single, поэтому читатель флагов полей разбирает этот токен как целое, накапливает его в ограниченном Int64 и копирует получившиеся 32 бита в Integer, который возвращает публичный API. Универсальные числовые API и старый нецелый fallback остаются как были, и только потребители флагов полей идут точным путём

Точный путь чтения флагов полей AcroForm в HotPDF для Delphi: парсер хранит исходный токен рядом со значением Single, TryParseFlagMask накапливает его в ограниченном Int64, и 32 бита доходят до GetLoadedFormFieldFlags и SetLoadedFormFieldFlags как Integer
Флаги полей разбирают запомненный текст токена вместо округлённого Single, поэтому обе записи маски со старшим битом, 2147549183 и -2147418113, приходят с идентичными битами

Парсер обязан принимать оба способа, какими реальные файлы записывают маску со старшим битом. Одни писатели выпускают беззнаковое значение 2147549183, что выше целочисленного предела 2^31 - 1 из Приложения C, но ходово на практике; другие выпускают знаковое значение -2147418113. Оба описывают одни и те же биты. Версия ограниченного парсера выглядит так:

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;
  // Знаковые токены до -2^31, беззнаковые до 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;  // вещественные идут старым fallback
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // ограничено, без переполнения
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Две детали намеренные. Граница проверяется перед каждым умножением, поэтому 400-значный токен чисто отказывает вместо переполнения Int64. И токен вроде 3.0 отвергается, что отправляет вызывающего обратно к Round(Value); точный путь улучшает целые маски, не изобретая новой семантики для кривых

Из кода приложения в сигнатурах ничего не меняется. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer возвращает эффективную маску, поднимаясь по цепочке /Parent так же, как описано в статье о том, как HotPDF разрешает наследуемые значения полей AcroForm и сбрасывает их, а THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) пишет полную локальную маску. Маска со взведённым битом 32 — это просто отрицательный Integer, так что переносите биты, а не конвертируйте значение:

const
  FF_RADIO      = $00008000;  // бит 16, ISO 32000-1 Table 228
  FF_PUSHBUTTON = $00010000;  // бит 17, Table 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));   // те же 32 бита, без конверсии
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // старший бит плюс Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

Чтение наследуемой маски дочерний словарь не трогает; запись заменяет всю маску локальной записью на выбранном поле. Шорткаты SetLoadedFormFieldReadOnly и SetLoadedFormFieldRequired стартуют от эффективной наследуемой маски, переключают один бит и держат все остальные биты точными. Для заполнения значений, а не флагов, см. запись значений полей форм в загруженном PDF с HotPDF

Почему порядок Value и RememberSourceToken важен?

Порядок важен, потому что THPDFNumericObject доверяет запомненному токену, только пока значение Single не менялось. RememberSourceToken сохраняет токен вместе со снимком текущего Value, а геттер SourceToken возвращает пустую строку, как только Value разошёлся со снимком. Именно это правило инвалидации не даёт отредактированному числу сохраниться со своим устаревшим исходным текстом

На стороне записи правило превращается в ловушку. Запомните токен первым, а значение присвойте вторым — и снимок держит старое значение, геттер рапортует об отсутствии токена, а сериализатор откатывается к форматированию округлённого Single. Каждый писатель флагов внутри библиотеки, включая шорткаты и нативные конструкторы полей, следует одной последовательности:

// Правильно: снимок совпадает с Value, поэтому точный токен переживает сохранение
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Неправильно: снимок снят до смены Value, поэтому SourceToken
// инвалидируется, и вместо него записывается округлённый Single
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Поскольку SaveNumericObject пишет запомненный токен всякий раз, когда тот валиден, правильный порядок — то, что заставляет сохранённый и заново открытый файл нести исходное целое, а не его округлённую аппроксимацию

null-токен, из-за которого два поля делили одно имя

Тот же релиз починил второй дефект парсера, найденный при тестировании этих масок: значение null в словаре, читаемом из потока, никогда не поглощалось. HotPDF подсматривает следующий токен, чтобы классифицировать его, и эта проверка оставляет позицию потока на начале токена. Ветка otNull потокового читателя словарей создавала null-объект, но не продвигалась за четыре байта null

Последствием был не подвисание, а молчаливое неверное чтение. Следующий поиск ключа сканировал вперёд с неправильного места, убегал за конец текущего словаря и читал ключи из следующего косвенного объекта, который затем затирал текущее поле. В списке полей симптомом были два разных поля с одним именем и одним значением. Парсеры массивов и спанов уже поглощали null правильно, поэтому фикс продвигает поток на четыре байта в той единственной ветке вместо переписывания общего парсера. Неподвижный курсор — из той же семьи багов, что и стопоры курсора PDF-парсера, подвешивавшие HotPDF на искажённом входе, только с другим симптомом

Фикс заодно прояснил наследование. Локальный /Ff null, как и ссылка, разрешающаяся в null, трактуется как отсутствие, и поиск продолжается к родителю, тогда как явный 0 — настоящая маска, перекрывающая родителя

Что доказывает и чего не доказывает директива {$R-}

Первой гипотезой бага флагов была ошибка проверки диапазона при присвоении большой маски в Integer. Она оказалась неверной, и причина — деталь Delphi, которую полезно знать: локальная директива {$R-}, поставленная перед ParseSimpleDictionary в HPDFDoc.pas, не имела парного восстановления, так что каждая функция после неё в модуле компилировалась с выключенными проверками диапазона. Delphi не сбрасывает переключательную директиву в конце функции, а include-файл наследует состояние директивы в точке своего включения

Поэтому командная строка -$R+ или -Cr доказывает только начальную настройку модуля. О конкретной функции ниже по тексту она не говорит ничего. Чтобы узнать, активна ли проверка диапазона для данной подпрограммы, найдите ближайшую директиву выше неё, затем подтвердите поведение реальным граничным значением. Два старых локальных потребителя флагов так и не подняли то исключение диапазона, что предсказывала гипотеза, поэтому отсутствие исключения нельзя было цитировать как доказательство фикса. Настоящим дефектом было округление Single по известным битам флагов

Точное чтение токена честно и насчёт своего охвата. Оно не меняет состояние директив на уровне модуля, не назначает смысла зарезервированным битам и не утверждает, что каждая комбинация бит — валидное поле. Оно гарантирует, что 32 бита в файле — это те 32 бита, которые видит ваш код

FAQ

Почему бы не сменить THPDFNumericObject.Value на Double или Int64?

Смена типа публичного поля сломала бы каждого вызывающего, который его читает или присваивает, а числа PDF бывают вещественными не реже, чем целыми. Сохранение Single и чтение флагов полей из исходного токена чинит баг для битовых масок, не меняя ни одного существующего объявления или конвенции вызова

Принимает ли SetLoadedFormFieldFlags маски со взведённым битом 32?

Да. Передавайте маску как знаковый Integer; $80000002 и -2147483646 — одни и те же 32 бита. GetLoadedFormFieldFlags возвращает то же значение после сохранения и перезагрузки, включая Required

Какая версия HotPDF читает точные флаги полей AcroForm?

Точные чтения флагов полей и фикс null-токена вышли в HotPDF v2.989.5, а структурный экспорт JSON и XML взял точную маску в v2.989.8. Более ранние версии округляют любую маску выше 2^24 через Single

Точные флаги полей AcroForm, наследуемый поиск полей и правка загруженных форм входят в HotPDF Component for Delphi and C++Builder — нативную библиотеку PDF для создания, загрузки и редактирования PDF-документов