HotPDF Delphi Component считает /FT, /Ff, /V и /DV на загруженном поле AcroForm наследуемыми атрибутами, которые разрешаются обходом цепочки /Parent. Начиная с v2.754.3 и v2.754.4 именованный ребёнок, чей тип приходит от родителя, остаётся адресуемым по отдельности, RemoveFormField не трогает его братьев, а ResetLoadedFormField копирует унаследованный дефолт с его исходным PDF-типом объекта. До этого изрядное количество совершенно обычных форм читалось неправильно
Форма, на которой всё это всплывает, ничем не экзотична. Инструмент авторинга строит групповой узел group, который один раз несёт /FT /Ch, флаги поля и список опций, и подвешивает под него двух именованных детей a и b, каждый — слитый словарь «поле плюс виджет» без ничего, кроме /T, /Parent, /Rect и собственного /V. Это совершенно легальный способ делить атрибуты, и это ровно тот случай, который раздел Limits статьи о задании значений полей формы в загруженном PDF из Delphi пометила как необрабатываемый: сверка кнопок смотрела только на локальный /FT. Эта статья подхватывает там, где та остановилась: как классифицируется дерево полей, как читаются унаследованные значения и что сбросу одиночного поля разрешено писать
Какие записи AcroForm поле может наследовать от родителя?
ISO 32000-1 §12.7.3.1, Table 220, помечает /FT, /Ff, /V и /DV как наследуемые, а Table 229 в §12.7.4.3 делает то же для /MaxLen текстового поля, так что любой читатель, смотрящий только в локальный словарь, выдаст неверный тип, неверные флаги и пустое значение для совершенно валидного ребёнка. HotPDF прогоняет все эти чтения через один внутренний резолвер HPDFLoadedInheritedFieldObject, который проверяет словарь на ключ, разрешает косвенную ссылку, если находит её, и иначе идёт по /Parent максимум 128 уровней, потому что битые файлы умеют строить циклы /Parent, не имеющие ничего общего с /Kids. Публичные геттеры сидят сверху: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue и опционные хелперы GetLoadedFormFieldOptionCount и GetLoadedFormFieldOptions, которые подхватывают и массив /Opt, лежащий на родителе. Одно правило резолвера легко понять неправильно: обход останавливается на первом словаре, содержащем ключ, даже если значение там — пустая строка. Локальный /V () — намеренное перекрытие, маскирующее родителя, а не дыра, которую надо заполнить откуда-то выше
var
Pdf: THotPDF;
Field: THPDFLoadedFormField;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
// 'group' несёт /FT /Ch, /Ff 131078 и /Opt; ребёнок
// 'group.b' держит только /T, /Parent, /Rect и свой /V
Field := Pdf.GetFormField('group.b');
try
if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
begin
// 131078 = Combo (бит 18) + NoExport (бит 3) + Required (бит 2)
Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
Writeln(Pdf.IsFormFieldRequired(Field.Index)); // TRUE
Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
Writeln(Pdf.GetFormFieldValue(Field.Index)); // локальный /V
end;
finally
Field.Free;
end;
finally
Pdf.Free;
end;
end;
Почему локальный /FT — неверный тест на терминальное поле?
Потому что родитель может поставлять тип и при этом владеть именованными дочерними полями, так что наличие /FT ничего не говорит о том, где кончается дерево полей. Старый обход объявлял узел терминальным, если у него был собственный /FT или не было /Kids. В форме выше у group есть и /FT /Ch, и /Kids, поэтому он регистрировался как одно поле group с двумя виджетами, а полные имена group.a и group.b просто исчезали. GetFormFieldCount возвращал 1, поиск по имени ребёнка проваливался, а SetFormFieldValue мог писать только в общего родителя. Заменяющий тест HPDFLoadedFieldHasChildFields смотрит на детей вместо родителя: ребёнок — дочернее поле, если у него есть собственный /T, собственные /Kids или он вообще не словарь /Subtype /Widget. Только когда ни один ребёнок не проходит, узел терминален, а его дети считаются его виджет-аннотациями
Оба краевых случая, сформировавших это правило, выходят из слитых словарей, которые §12.7.3.1 разрешает, когда у поля один виджет. Именованный слитый словарь носит /Subtype /Widget и всё равно остаётся дочерним полем, так что один subtype не может отправить его в анонимный список виджетов родителя; побеждает /T. Бывает и наоборот: некоторые производители повторяют /FT родителя на каждом анонимном виджете, так что /FT нельзя использовать и как улику, что виджет начинает новое поле. Классификация общая для кэша отношений, FormFieldExists и RemoveFormField, и каждый из этих обходов теперь запоминает уже посещённые словари и останавливается за 128 уровнями. Регрессионный файл, чья группа перечисляет себя дважды, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], по-прежнему сообщает ровно два поля вместо вечной рекурсии или двойного счёта одного узла
Как RemoveFormField избегает удаления соседних полей?
RemoveFormField теперь удаляет только названного вами ребёнка, потому что обнаружение и удаление наконец согласились в том, что такое терминальное поле. Это согласие важнее, чем кажется. Оверлоуд по имени разрешает индекс через кэш отношений, а затем считает терминальные поля вторым обходом по /AcroForm /Fields. Когда кэш уже научился видеть group.a и group.b, неисправленный обход удаления всё ещё считал бы group единственным терминальным полем, и индекс 0 снёс бы родителя вместе со всеми братьями и всеми их виджетами. Обход удаления теперь использует тот же тест HPDFLoadedFieldHasChildFields и тот же набор посещённых, собирает виджет-аннотации только удаляемого ребёнка, вычищает их из /Annots каждой страницы и удаляет родителя, только когда его массив /Kids опустел. Регресс проверяет все три места, где ошибка вылезла бы: /Kids родителя, /Annots страницы, а также значение и внешний вид выжившего брата — и после полной перезаписи, и после инкрементального обновления
// Удалить одного именованного ребёнка; брат и общий родитель выживают
Pdf.RemoveFormField('group.a');
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Тип, флаги и опции по-прежнему разрешаются через родителя
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
Что ResetLoadedFormField пишет, когда дефолт унаследован?
ResetLoadedFormField пишет локальный /V — свежую копию унаследованного /DV с тем же PDF-типом объекта, — и валидирует весь дефолт, прежде чем трогать поле. Тип объекта важен, потому что скалярные геттеры сплющивают всё в текст. Дефолт чекбокса — имя вроде /Yes, дефолт мультиселект-листбокса — массив строк, а текстовый дефолт может быть шестнадцатеричной UTF-16-строкой; скопировать любой из них через GetLoadedFormFieldDefaultValue значило бы превратить имя в строку, массив — в пустую строку, а hex-строку — в её литеральные цифры. Поэтому сброс ветвится по унаследованному типу: текстовые и choice-поля получают новый строковый объект, сохраняющий флаг IsHexadecimal, choice-поля с массивным дефолтом — новый массив новых строк, а непуш-кнопки — новый объект-имя. Копирование, а не указание на объекты родителя, намеренно: /V, разделяющий с родителем массив /DV или номер объекта, менял бы дефолт при следующей правке значения. Дефолт неверного типа или choice-массив, содержащий что-либо кроме строк, поднимает исключение и оставляет /V и /I ровно как были. Пуш-кнопки, у которых значения нет (Table 226, бит 17), и поля подписи откатываются к старому строковому пути
Когда /DV нет нигде по цепочке вверх, метод держит свой контракт очистки, записывая локальную пустую строку, или /Off для чекбокса или радио. Удалить локальный /V выглядело бы опрятнее и было бы ошибкой: родитель может держать текущее значение, и снятие перекрытия ребёнка молча вернуло бы его. Поэтому же сброс одиночного поля — это не действие ResetForm из §12.7.5.3, которое вьювер гоняет по набору полей, когда пользователь нажимает кнопку, — о нём в статье о построении полей и действий AcroForm с HotPDF. ResetLoadedFormField — операция редактирования одного загруженного поля со своим правилом для случая без дефолта, и она помечает поле через NoteLoadedFormFieldDirty, чтобы инкрементальный пересчёт увидел изменение
var
Field: THPDFLoadedFormField;
begin
Field := Pdf.GetFormField('group.a');
try
// Родитель держит /DV [(b) (r)] на MultiSelect-листбоксе: group.a получает
// свой /V [(b) (r)] и свежий /I [0 2]; родителя не трогаем
Pdf.ResetLoadedFormField(Field.Index);
// Скалярные геттеры не могут представить массивный дефолт
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // пусто
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
Держим /V, /I и /AS в согласии
Сброс верен, только если индекс выбора и состояние внешнего вида следуют за значением, поэтому ResetLoadedFormField финиширует теми же двумя сверщиками, что и SetFormFieldValue. HPDFReconcileChoiceSelection теперь принимает массивное значение: он удаляет локальный /I без мутации, сверяет каждое значение с экспортной половиной каждой записи /Opt и пишет один новый сортированный /I, так что сброс к [(b) (r)] против опций b, g, r даёт /I [0 2]. ReconcileLoadedButtonAppearanceStates теперь запрашивает унаследованный тип, так что дочерний чекбокс, чей /FT /Btn живёт на родителе, наконец получает свой /AS. На стороне записи SetFormFieldValue и SetLoadedFormFieldDefaultValue кладут объект-имя для унаследованной непуш-кнопки, даже когда у ребёнка нет локальной записи, откуда скопировать тип. А когда EnsureLoadedFieldAppearanceStream перестраивает внешность кнопок, он пишет /AS /Off, если значение не совпало с включённым состоянием, и выдаёт каждому потоку состояний положенные /Type /XObject, /Subtype /Form и /BBox; до v2.754.4 регенерация внешности после сброса могла заново отметить галочку ещё до сохранения файла
Пределы, о которых стоит знать, прежде чем строить на этом
Скалярные геттеры остаются скалярными. GetFormFieldValue и GetLoadedFormFieldDefaultValue возвращают пустую строку для массивного значения, строкуют числа и булевы как 42 или true, а hex-закодированную строку выдают в её шестнадцатеричной записи. Цикл /Parent заканчивает обход без исключения, так что поле, чей тип потерялся в цикле, сообщает lfftUnknown и флаги 0, а не падает. SetFormFieldValue и ResetLoadedFormField всегда пишут в ребёнка, которого вы адресуете, и никогда не проталкивают значение в общего родителя — для независимых детей это правильно, но радио-группы следует адресовать через поле, владеющее выбором. И каждый вызов коммитит одно поле сам по себе; ничто здесь не делает пакет сбросов транзакционным
Разрешение наследуемых атрибутов, единая классификация дерева полей и типизированный сброс, описанные здесь, — часть API загруженных форм в HotPDF Delphi Component для Delphi и C++Builder, рядом с созданием полей, разобранным в статье о добавлении полей AcroForm в загруженный PDF из Delphi