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

Баг зведення чекбоксів PDF: значення поля проти віджета в 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 постачає перегенерацію вигляду, зведення й доступ до полів форми, описані тут, як звичайні властивості та методи