Анкета показывает в Acrobat два выпадающих списка, group.a и group.b. Пройдите /AcroForm /Fields кодом, который считает любой узел с собственным /FT готовым полем, и счёт вернётся как 1: единственное поле group с двумя виджетами. Запросите group.b по имени — поиск провалится. Откройте словарь ребёнка: в нём вообще нет /FT, ни /Ff, ни списка опций. Ничего не сломано. Тип, флаги и опции сидят уровнем выше на родителе, и спецификация PDF прямо позволяет им там жить
Раскладка легальна и экономна. Инструмент автора пишет узел группы с /FT /Ch, /Ff 131078 и массивом /Opt один раз, затем подвешивает под ним двух именованных детей, каждый — словарь слитого поля-плюс-виджета, держащий только /T, /Parent, /Rect и собственный /V. Правильно прочитать такую форму — значит сделать три вещи: идти по /Parent для каждого наследуемого ключа, решить, где дерево полей на самом деле кончается, и убедиться, что сброс записывает значение правильного PDF-типа. Эта статья подхватывает там, где останавливается материал об установке значений полей формы в загруженном PDF из Delphi
Какие записи AcroForm поле может наследовать от родителя?
}}ISO 32000-1 §12.7.3.1 (Table 220) помечает /FT, /Ff, /V и /DV как наследуемые, и Table 229 в §12.7.4.3 делает то же для /MaxLen текстового поля. Массивы опций здесь получают то же обращение, потому что производители, делящие список выбора между детьми, хранят /Opt на группе. Читатель, проверяющий только локальный словарь, отчитает неверный тип, флаги 0 и пустое значение у совершенно валидного ребёнка
HotPDF Delphi Component отвечает на каждый из этих запросов одинаково. Он ищет ключ в собственном словаре поля и, если ключ там, разыменовывает косвенную ссылку и останавливается. Иначе идёт по /Parent и пробует снова, максимум 128 уровней. Потолок — не украшение. Указатели /Parent в повреждённых файлах могут образовать цикл, никак не связанный с /Kids, и читатель, доверяющий им безусловно, зависнет на первом же таком файле
Одно правило в этой прогулке легко понять наоборот: побеждает первый словарь, содержащий ключ, даже если хранимое там значение — пустая строка. Локальный /V () на group.b — это автор говорит «этот ребёнок пуст», и прогулка останавливается там, вместо того лезть дальше к значению, которое может держать родитель. Считать пустое «отсутствующим» значило бы заставить очищенного ребёнка показывать выбор родителя — противоположность тому, что написал автор
Все публичные геттеры сидят на этом поиске: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue и помощники опций GetLoadedFormFieldOptionCount и GetLoadedFormFieldOptions. Поэтому чтение ребёнка анкеты не требует особого кода:
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;}}
GetFormField возвращает новую обёртку THPDFLoadedFormField каждый раз и поднимает исключение при неизвестном имени: держите вызов вне блока try обёртки и освобождайте обёртку сами
Поиск конца дерева полей
Локальный /FT — неверный тест на терминальное поле, потому что родитель может поставлять тип и всё ещё владеть именованными дочерними полями. Соблазнительный обход прекращает спуск, как только узел имеет собственный /FT или не имеет /Kids. В анкете group имеет и /FT /Ch, и /Kids, поэтому такой обход регистрирует его как одно поле с двумя аннотациями виджетов. Имена group.a и group.b исчезают. Счёт полей — 1. Поиск по имени ребёнка проваливается, и запись значения может попасть только в общего родителя
HotPDF смотрит на детей. Ребёнок считается дочерним полем, если у него есть собственный /T, собственный /Kids или он не словарь /Subtype /Widget. Только когда ни один ребёнок не проходит проверку, узел терминален, и его дети считаются его виджетами. Оба краевых случая за этим правилом вырастают из слитых словарей, которые §12.7.3.1 разрешает, когда у поля ровно один виджет:
- Именованный слитый словарь несёт
/Subtype /Widgetи всё ещё является дочерним полем. Один подтип не может затолкать его в анонимный список виджетов родителя; решает/T - Некоторые производители повторяют родительский
/FTна каждом анонимном виджете. Так что/FTна виджете тоже нельзя читать как «здесь начинается новое поле»
Та же классификация ведёт индекс полей, FormFieldExists и RemoveFormField. Каждая из этих прогулок тоже записывает уже посещённые словари и сдаётся после 128 уровней. Пусть группа — объект 5 и дважды перечисляет себя среди собственных детей: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Она всё равно отчитает ровно два поля, детей 6 и 7. Никакой бесконечной рекурсии и никакого фантомного третьего поля из самоссылки
Как RemoveFormField избегает удаления соседних полей?
}}RemoveFormField удаляет только названного ребёнка, потому что обнаружение и удаление делят одно определение терминального поля. Перегрузка по имени разрешает индекс поля из списка полей, затем идёт по /AcroForm /Fields второй раз, считая терминальные поля, чтобы найти словарь для удаления. Если бы вторая прогулка применяла соблазнительное правило /FT, а первая — правило по детям, индексы разошлись бы: прогулка удаления видела бы group полем 0, и удаление group.a унесло бы родителя, сиблинга и оба виджета
Поэтому прогулка удаления применяет тот же тест дочернего поля и тот же набор посещённых, что и обнаружение. Она собирает аннотации виджетов, принадлежащие удаляемому ребёнку, вычищает только их из /Annots каждой страницы и удаляет родителя, лишь когда его массив /Kids опустел. Пока group.b в списке, родитель остаётся, и тип, флаги и опции, которые наследует group.b, остаются при нём
// Удалить одного именованного ребёнка; сиблинг и общий родитель выживают
Pdf.RemoveFormField('group.a');
// Проверки на вменяемость, которые вы добавите сами, не часть библиотеки
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Тип, флаги и опции по-прежнему разрешаются через родителя
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
Проверяя удаление на своих формах, смотрите в три места, потому что каждое ловит свою ошибку: /Kids родителя, /Annots страницы и значение с внешним видом выжившего сиблинга. Сделайте это один раз после SaveLoadedDocument и один раз после SaveIncrementalUpdate, поскольку инкрементальное сохранение дописывает только изменённые объекты, и /Kids родителя обязаны быть среди них
Что ResetLoadedFormField копирует из наследуемого /DV
ResetLoadedFormField пишет локальный /V — свежую копию наследуемого /DV того же PDF-типа объекта, — и валидирует дефолт целиком, прежде чем тронуть поле. Дефолт чекбокса — имя вроде /Yes. Дефолт мультиселект-листбокса — массив строк. Текстовый дефолт может быть hex-строкой UTF-16 вроде <FEFF...>. Прогоните любой из них через GetLoadedFormFieldDefaultValue и SetFormFieldValue, и вы получите строку там, где должно быть имя, пустую строку там, где был массив, или литеральные hex-цифры
Поэтому сброс ветвится по тому, чем на самом деле является наследуемый /DV:
- Текстовые и choice-поля со строковым дефолтом получают новый строковый объект, сохраняющий hex-флаг оригинала
- Choice-поля с массивным дефолтом получают новый массив новых строк; массив, содержащий что-то кроме строк, поднимает исключение
- Чекбоксы и радиокнопки получают новый объект-имя
- Дефолт любого другого типа поднимает исключение, и
/Vи/Iостаются ровно как были
Копирование вместо ссылки — намеренное и единственно безопасное. Ребёнок /V, указывавший на массив /DV родителя, делил бы один объект между «текущим значением» и «дефолтным»; следующий SetFormFieldValue на этом ребёнке тихо переписал бы дефолт каждому сиблингу. Пара лишних байтов на сбрасываемое поле — дешёвая цена за их разделение. Кнопки-отправители, у которых нет значения (Table 226, бит 17), и поля подписи остаются на простом пути, записывающем строковую копию их собственного /DV
Когда /DV нет нигде по цепочке, метод пишет локальную пустую строку или /Off для чекбокса или радиополя. Удалить /V ребёнка выглядело бы опрятнее и дало бы неверный ответ. Если родитель держит текущее значение, удаление локального оверрайда ребёнка возвращает его напрямую, и «сброшенное» поле показывает то, чего пользователь никогда в него не вводил
var
Field: THPDFLoadedFormField;
begin
// Родитель 'palette' — мультиселект-листбокс с
// /Opt [(b) (g) (r)] и /DV [(b) (r)]; ребёнок держит только /T и /V
Field := Pdf.GetFormField('palette.primary');
try
try
Pdf.ResetLoadedFormField(Field.Index);
// Теперь у ребёнка собственные /V [(b) (r)] и /I [0 2];
// массив /DV родителя не тронут
except
on E: Exception do
// Уродливый дефолт (неверный тип, не-строковый элемент массива):
// /V и /I остались как были
Writeln('Reset skipped: ', E.Message);
end;
// Скалярный геттер не может представить массивный дефолт
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // пусто
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
Сброс одного поля — ещё и не то же самое, что действие ResetForm из §12.7.5.3, которое просмотрщик прогоняет по набору полей, когда пользователь нажимает кнопку; это действие разобрано в материале о построении полей и действий AcroForm с HotPDF. ResetLoadedFormField правит одно загруженное поле, следует собственному правилу для случая без дефолта и помечает поле изменённым, чтобы инкрементальный пересчёт его подхватил
Держим /V, /I и /AS в согласии
}}Сброс корректен, только если индекс выбора и состояние внешнего вида следуют за значением, и ResetLoadedFormField заканчивает теми же двумя последующими шагами, что и SetFormFieldValue. Для choice-полей он удаляет локальный /I ребёнка, сверяет каждое значение с экспортной половиной каждой записи /Opt и пишет новый отсортированный /I. Сброс в [(b) (r)] против опций b, g, r даёт /I [0 2]. Наследуемый /I родителя, если есть, остаётся не тронутым
Кнопки работают от наследуемого типа. Дочерний чекбокс, чей /FT /Btn живёт на родителе, всё равно получает обновление /AS. На стороне записи SetFormFieldValue и SetLoadedFormFieldDefaultValue хранят объект-имя для наследуемой кнопки, не являющейся pushbutton, даже когда у ребёнка нет локальной записи, откуда скопировать тип. И когда EnsureLoadedFieldAppearanceStream пересобирает внешние виды кнопок, он пишет /AS /Off, если значение не совпадает со включённым состоянием, и даёт каждому потоку состояния /Type /XObject, /Subtype /Form и /BBox. Без проверки на /Off регенерация внешних видов после сброса показала бы бокс снова отмеченным ещё до сохранения файла
Скалярные геттеры и прочие грани, которые надо планировать заранее
Строковые геттеры остаются скалярными, и код, сравнивающий их вывод, должен знать, что они сплющивают. GetFormFieldValue и GetLoadedFormFieldDefaultValue возвращают пустую строку для массивного значения, превращают числа и булевы в 42 или true и отчитывают hex-кодированную строку в её hex-написании. Если надо знать, держит ли дефолт листбокса два выбора или ни одного, дефолтный геттер не скажет: оба приходят пустой строкой
Цикл /Parent завершает поиск без исключения, поэтому поле, чей тип потерялся внутри цикла, отчитывает lfftUnknown и флаги 0. Считайте lfftUnknown на именованном поле признаком повреждённого файла, достойного лога. SetFormFieldValue и ResetLoadedFormField всегда пишут ребёнка, к которому вы обратились, и никогда не повышают значение до общего родителя. Это верно для независимых детей вроде двух анкетных выпадающих списков. Радиогруппы другие: выбор принадлежит полю, владеющему группой, поэтому адресуйте то поле, а не одного из его детей
Каждый вызов также коммитит одно поле самостоятельно. Цикл из двадцати сбросов — это двадцать независимых правок; если пятый поднимет исключение на уродливом дефолте, первые четыре уже применены. Для пакетной работы сперва прочитайте тип и дефолт каждой цели, решите, каких полей коснётесь, и только потом начинайте писать
Поиск наследуемых атрибутов, классификация дерева полей по детям и типизированный сброс принадлежат загруженно-форменному API HotPDF Delphi Component, который покрывает и проекты на Delphi и C++Builder, которым нужно добавлять новые поля в существующий документ, как показано в материале о добавлении полей AcroForm в загруженный PDF из Delphi