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

Баг сведения чекбоксов PDF: значение поля vs виджет в Delphi

Чекбоксы и переключатели сводятся как неотмеченные, потому что состояние внешнего вида /AS никогда не синхронизировалось со значением поля /V. PDFium Component, VCL- и LCL-компонент на базе PDFium для Delphi, C++Builder и Lazarus, теперь читает это значение через FPDFAnnot_GetFormFieldValue, что разрешает родительский словарь поля вместо аннотации-виджета

Отчёт о баге, приведший сюда, — из тех, которым сначала не доверяешь. Клиент сводит подписанную форму согласия, открывает результат, и каждый чекбокс пуст. Открой исходный файл в Acrobat — галочки видимо стоят. Прочитай исходный файл обратно через тот же компонент — значения полей верны. Только сведённый вывод их теряет, и только для чекбоксов и переключателей: текстовые поля на той же странице выходят нормально

Почему чекбоксы неотмечены после сведения?

Потому что сведение никогда не смотрит на /V. FPDFPage_Flatten запекает поток внешнего вида виджета в содержимое страницы, и внешний вид, который он выбирает, — тот, что назван /AS. Если /AS всё ещё говорит /Off, пока значение поля говорит, что бокс включён, сведение добросовестно запекает выключенный внешний вид. Значение никогда не терялось; оно никогда не запрашивалось

ISO 32000-1 §12.5.5 определяет словарь внешнего вида /AP с тремя возможными записями, /N, /R и /D. Для чекбокса или переключателя запись /N — не поток, а подсловарь, чьи ключи — имена состояний внешнего вида, и §12.5.2 делает /AS обязательным селектором, когда /N — подсловарь. Так что чекбокс несёт два предпостроенных внешних вида и один указатель. Ошибитесь с указателем — и рендеринг неверен так, что никакое количество верного /V не исправит. Это также причина, почему режим сбоя отличается от текстовых полей, у которых вообще нет предпостроенного внешнего вида для выбора: единственный поток /N текстового поля должен быть перегенерирован с нуля после изменения значения, так что GenerateFormAppearances обрабатывает два случая совершенно раздельными путями кода, и только путь для кнопок был сломан

Где на самом деле живёт значение чекбокса?

В словаре поля, не в виджете. ISO 32000-1 §12.7.5.2 описывает чекбоксы и переключатели как кнопочные поля, чьё /V — объект-имя, называющий текущее состояние внешнего вида, а §12.7.3.1 помещает /V среди записей, общих для всех словарей полей. Аннотация-виджет, определённая в §12.5.6.19, вносит /AS и /AP. Ничто в спецификации не обязывает виджет нести /V

// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue не дефектна. Её контракт — ровно то, что говорит её имя: извлечь строковую запись из переданного ей словаря аннотации. Запрос у неё /V на объекте 13 ничего не возвращает, потому что у объекта 13 действительно нет /V. Дефект был в вызывающей стороне, предполагавшей плоскую объектную модель, которую ISO 32000-1 никогда не обещала

Когда поле и виджет разделяют один словарь?

Всякий раз, когда поле имеет ровно один виджет. §12.5.6.19 разрешает объединить словарь поля и его единственную аннотацию-виджет в один объект, и большинство инструментов авторства пользуются этим сокращением. В объединённом объекте /FT, /T, /V, /AS и /AP сидят бок о бок, так что чтение /V на уровне виджета удаётся, и весь баг остаётся невидимым

В момент, когда поле владеет двумя или более виджетами, слияние становится невозможным, и §12.7.3.1 требует, чтобы виджеты стали /Kids отдельного словаря поля. Каждая группа переключателей имеет такую форму по построению. Так же и чекбоксы согласия, повторённые в шапке и подвале, и любое поле, скопированное инструментом авторства на вторую страницу. Это и есть всё объяснение того, почему дефект пережил регрессионный набор: тестовый корпус был полон одновиджетных форм, а клиентские файлы — нет. Если вы обходите виджеты сами, а не полагаетесь на компонент, та же асимметрия проявляется в порядке перечисления, и заметки о навигации по полям форм PDF с PDFium Component покрывают, как обход аннотаций на уровне страницы соотносится с деревом полей на уровне документа

Чтение значения так, как задумал PDFium

FPDFAnnot_GetFormFieldValue — верный API, и он был привязан в компоненте некоторое время без того, чтобы путь чекбокса его использовал. Он принимает дескриптор формы вместе с аннотацией, что и есть значимый сигнал: с доступным окружением заполнения формы PDFium разрешает аннотацию в её элемент управления формой и читает значение из объекта поля, так что он возвращает верный ответ и для объединённых, и для раздельных разметок

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Две детали в этом фрагменте легко перепутать. Возвращаемая длина — это счёт байт для текста UTF-16, включая терминатор, так что число символов — buflen div 2 - 1, а значение 2 означает пустую строку. Защита buflen >= 4 поэтому означает как минимум один реальный символ, что и не даёт полю вовсе без /V получить свой /AS перезаписанным пустым именем

О чём на самом деле договариваются /AS и /AP /N

Они договариваются об имени, а имя выбирает тот, кто произвёл файл. §12.7.5.2 требует, чтобы выключенное состояние называлось /Off, и оставляет включённое состояние целиком на усмотрение производителя. /Yes — соглашение, не правило. Acrobat пишет /Yes, но множество генераторов пишут /On, /1, /Choice1 или локализованное слово, а группа переключателей обычно даёт каждому дочернему элементу отдельное имя включённого состояния, чтобы группа могла выразить, какая кнопка выбрана. Именно поэтому дословное копирование /V в /AS — это верная операция, а не хак: для отмеченного элемента управления PDFium сообщает имя включённого состояния, которое определяет сам файл, а для неотмеченного он сообщает Off, так что значение, которое вы записываете в /AS, гарантированно является ключом, существующим в подсловаре /AP /N этого виджета. Жёсткое кодирование /Yes сработало бы на выводе Acrobat и молча сломалось бы везде ещё

Порядок операций, и где всё ещё нужна осторожность

Последовательность фиксирована и непрощающа: включить заполнение формы, назначить значения, перегенерировать внешний вид, свести, затем сохранить. Пропустите шаг регенерации, и FPDFPage_Flatten найдёт пустые или устаревшие потоки внешнего вида и запечёт их без жалоб, что является тихой потерей данных, а не возвратом ошибки

Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Остаются два честных ограничения. Во-первых, синхронизация записывает значение поля в /AS каждого виджета этого поля, что верно для чекбоксов и приблизительно для групп переключателей, чьи дочерние элементы каждый определяет своё собственное имя включённого состояния; дочерний элемент, чей /AP /N не имеет записи, совпадающей с записанным /AS, не имеет внешнего вида для выбора согласно §12.5.5, так что невыбранная кнопка может свестись в ничто вместо пустого круга. Аудит группы переключателей с FPDFAnnot_GetFormControlIndex перед сведением стоит нескольких строк. Во-вторых, ничто из этого не применяется к XFA, где значение живёт в XML-пакете данных, а не в словарях AcroForm, — разделение, покрытое в заметках о правках полей XFA, которые не сохраняются. Общий урок стоит держать в уме и за пределами этого исправления: всякий раз, когда API принимает дескриптор формы в дополнение к аннотации, он говорит вам, что разрешит иерархию полей за вас, а всякий раз, когда он принимает только аннотацию, он прочитает ровно тот объект, что вы передали. Это различие также управляет обменом данными, поскольку экспорт и импорт данных форм XFDF работает с полностью квалифицированными именами полей, никогда — с позициями виджетов

Сведение формы — одна из тех функций, что выглядит как один вызов API, а оказывается контрактом между тремя словарями. Если вы предпочли бы работать с компонентом, уже кодирующим этот контракт, PDFium Component для Delphi и C++Builder поставляет описанные здесь регенерацию внешнего вида, сведение и доступ к полям формы как обычные свойства и методы