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