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

Наследуемые значения полей AcroForm и сбросы в Delphi

Анкета показывает в 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 — это автор говорит «этот ребёнок пуст», и прогулка останавливается там, вместо того лезть дальше к значению, которое может держать родитель. Считать пустое «отсутствующим» значило бы заставить очищенного ребёнка показывать выбор родителя — противоположность тому, что написал автор

Диаграмма наследуемых атрибутов AcroForm в HotPDF: узел группы несёт /FT, /Ff и /Opt один раз, тогда как именованные дети group.a и group.b держат только /T, /Parent, /Rect и локальный /V, показывая, как поиск наследуемых атрибутов идёт по /Parent до 128 уровней, где первый словарь с ключом побеждает, а пустое локальное значение заслоняет родителя
HotPDF разрешает /FT, /Ff, /V, /DV и /Opt через один резолвер, идущий по родителям, поэтому именованный ребёнок остаётся адресуемым, а локальное пустое значение намеренно перекрывает всё, что несёт группа над ним

Все публичные геттеры сидят на этом поиске: 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, остаются при нём

Диаграмма выживания сиблинга в HotPDF RemoveFormField: прогулка удаления использует тот же тест дочернего поля и набор посещённых, что и обнаружение, вычищает из AcroForm /Fields и страниц /Annots только названного ребёнка group.a и сохраняет общего родителя, пока его массив /Kids ещё держит выжившего 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 остаются ровно как были
Диаграмма типизированного сброса HotPDF: ResetLoadedFormField ветвится по типу объекта наследуемого /DV, записывая свежий объект-имя для чекбокса, новый массив новых строк для мультиселект-choice, строку, сохраняющую IsHexadecimal, для hex-текста, пустую строку или /Off при отсутствии /DV, и поднимая исключение без касания /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