Технічна стаття

AcroForm: успадковані значення полів і скидання в Delphi

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, таблиця 220, позначає /FT, /Ff, /V і /DV як успадковувані, а таблиця 229 у §12.7.4.3 робить те саме для /MaxLen текстового поля, тож будь-який читач, що дивиться лише на локальний словник, видасть неправильний тип, неправильні прапорці та порожнє значення для цілком валідної дитини. HotPDF пропускає всі ці читання через один внутрішній резолвер, HPDFLoadedInheritedFieldObject, який шукає ключ у словнику, розв'язує непряме посилання, якщо знаходить, а інакше йде за /Parent щонайбільше 128 рівнів, бо пошкоджені файли вміють будувати цикли /Parent, які не мають нічого спільного з /Kids. Публічні гетери сидять поверх нього: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue і хелпери опцій GetLoadedFormFieldOptionCount і GetLoadedFormFieldOptions, які теж підбирають масив /Opt, що зберігається на батькові. Одне правило в резолвері легко зіпсувати: обхід зупиняється на першому словнику, що містить ключ, навіть якщо значення там — порожній рядок. Локальний /V () — це свідоме перекриття, яке маскує батька, а не прогалина, яку треба заповнити десь вище по дереву

Діаграма успадкованих атрибутів AcroForm у HotPDF: груповий вузол один раз несе /FT, /Ff і /Opt, тоді як іменовані діти group.a і group.b тримають лише /T, /Parent, /Rect і локальний /V; HPDFLoadedInheritedFieldObject обходить /Parent до 128 рівнів, де перемагає перший словник із ключем, а порожнє локальне значення маскує батька
HotPDF розв'язує /FT, /Ff, /V, /DV і /Opt через один батько-обхідний резолвер, тож іменована дитина лишається адресованою, а локальне порожнє значення свідомо перекриває все, що несе група над нею
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 і все одно є дочірнім полем, тож один субтип не може відправити його в анонімний список віджетів батька; перемагає /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 і значення та вигляд уцілілого брата — і після повного перезапису, і після інкрементального оновлення

Діаграма виживання брата в HotPDF RemoveFormField: хід видалення перевикористовує HPDFLoadedFieldHasChildFields і набір відвіданих із знаходження, вириває лише іменовану дитину group.a з AcroForm /Fields і сторінкових /Annots і тримає спільного батька, поки його масив /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');

Що пише ResetLoadedFormField, коли дефолт успадкований?

ResetLoadedFormField пише локальний /V, що є свіжою копією успадкованого /DV з тим самим PDF-типом об'єкта, і валідує весь дефолт, перш ніж торкнутися поля. Тип об'єкта важливий, бо скалярні гетери сплощують усе в текст. Дефолт чекбокса — це ім'я на кшталт /Yes, дефолт multi-select list box — масив рядків, а текстовий дефолт може бути шістнадцятковим рядком UTF-16; скопіювати будь-що з них через GetLoadedFormFieldDefaultValue — значить перетворити ім'я на рядок, масив на порожній рядок, а hex-рядок на його літеральні цифри. Тому скидання розгалужується за успадкованим типом: текстові та choice-поля отримують новий рядковий об'єкт, що зберігає прапорець IsHexadecimal, choice-поля з масивним дефолтом — новий масив нових рядків, а кнопки не-pushbutton — новий об'єкт-ім'я. Копіювати, а не вказувати на об'єкти батька, — свідомо: /V, який ділив би масив /DV батька чи його номер об'єкта, змінив би дефолт наступного разу, коли хтось відредагує значення. Дефолт неправильного типу або choice-масив, що містить щось крім рядків, піднімає виняток і лишає /V і /I рівно як були. Pushbutton-кнопки, які не мають значення (таблиця 226, біт 17), і поля підпису падають назад на старіший шлях "лише рядки"

Діаграма типізованого скидання в HotPDF: ResetLoadedFormField розгалужується за типом об'єкта успадкованого /DV, пишучи свіжий об'єкт-ім'я для чекбокса, новий масив нових рядків для multi-select choice, рядок, що зберігає IsHexadecimal, для hex-тексту, порожній рядок чи /Off, коли /DV немає, і піднімає виняток, не торкаючись /V чи /I, при невідповідності типів
Копіювання замість вказівки на об'єкти батька не дає пізнішій правці значення тихо змінити дефолт, а pushbutton-кнопки та поля підпису падають назад на старіший шлях "лише рядки"

Коли /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 list box: 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, не мутуючи його, звіряє кожне значення з export-половиною кожного запису /Opt і пише один новий відсортований /I, тож скидання до [(b) (r)] проти опцій b, g, r дає /I [0 2]. ReconcileLoadedButtonAppearanceStates тепер питає успадкований тип, тож дочірній чекбокс, чий /FT /Btn живе на батькові, нарешті отримує свій /AS. На боці запису SetFormFieldValue і SetLoadedFormFieldDefaultValue зберігають об'єкт-ім'я для успадкованої кнопки не-pushbutton, навіть коли в дитини немає локального запису, з якого можна скопіювати тип. А коли EnsureLoadedFieldAppearanceStream перебудовує вигляди кнопок, він пише /AS /Off, якщо значення не збігається зі станом увімкненості, і дає кожному потоку стану належні /Type /XObject, /Subtype /Form і /BBox; до v2.754.4 перегенерація вигляду після скидання могла знову поставити галочку ще до збереження файлу

Межі, про які варто знати, перш ніж будувати на цьому

Скалярні гетери лишаються скалярними. GetFormFieldValue і GetLoadedFormFieldDefaultValue повертають порожній рядок для масивного значення, перетворюють числа та булеві на 42 чи true, а hex-кодований рядок звітують у його шістнадцятковому написанні. Цикл /Parent закінчує обхід без винятку, тож поле, чий тип загубився в циклі, звітує lfftUnknown і нульові прапорці замість падіння. SetFormFieldValue і ResetLoadedFormField завжди пишуть у дитину, до якої ви звертаєтеся, і ніколи не промотують значення в спільного батька — це правильно для незалежних дітей, але радіогрупи треба адресувати через поле, якому належить вибір. І кожен виклик комітить одне поле самостійно; нічого тут не робить пакет скидань транзакційним

Розв'язання успадкованих атрибутів, єдина класифікація дерева полів і типізоване скидання, описані тут, — частина API завантажених форм у HotPDF Delphi Component для Delphi та C++Builder, поруч зі створенням полів, про яке йдеться в додаванні полів AcroForm до завантаженого PDF у Delphi