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

FormFillInfo версії 2 і ABI XFA у PDFium Component

PDFium Component тепер виставляє FPDF_FORMFILLINFO.version у 2 для кожного середовища заповнення форм, яке він ініціалізує, бо версію, яку приймає нативна збірка PDFium, визначає ця збірка, а не документ, який відкривають. pdfium.v8.dll із підтримкою XFA відкидає версію 1 геть, тож звичайний PDF з AcroForm, відкритий через нього, раніше падав у FPDFDOC_InitFormFillEnvironment, хоч XFA там і близько не було. Виправлення у v3.116.0 маленьке, але помилка за ним — загальна, і її варто назвати: поле версії протоколу описує розкладку пам'яті, якої очікує інша сторона, і його ніколи не можна виводити з того, чи вам випадково потрібні функції, які ця розкладка несе

Чому FPDFDOC_InitFormFillEnvironment падає на звичайному PDF із pdfium.v8.dll?

Середовище падає, бо збірка PDFium із підтримкою XFA перевіряє поле version перш ніж робити будь-що інше, а стара логіка обгортки віддавала їй 1 щоразу, коли поточний документ не був формою XFA. Симптом у хості Delphi — це EPdfError, піднятий із TPdf.InitializeFormFill з повідомленням Cannot initialize form fill environment, кинутий під час відкриття звичайного рахунку чи податкової форми, де немає нічого, крім текстових полів AcroForm. Той самий файл чудово відкривається проти звичайного pdfium.dll. Той самий DLL чудово відкриває справжній документ XFA. Ламається лише комбінація збірки V8 і документа без XFA — саме та комбінація, у якій хост опиняється після того, як увімкне EnableV8Engine, щоб отримати JavaScript для AcroForm, або після того, як автовибір у LoadDocument уже закріпив процес за pdfium.v8.dll через раніший файл XFA. Це закріплення діє на весь процес: EnableV8Engine читається до першого LoadLibrary, і щойно збірку XFA завантажено, кожен наступний звичайний PDF проходить те саме налаштування середовища проти того самого бінарника. Хост не зробив нічого поганого; обгортка поставила не те питання, коли заповнювала структуру. Якщо ви все ще вирішуєте, який бінарник взагалі постачати, наша замітка про розгортання DLL PDFium і діагностику збоїв завантаження покриває вибір між звичайною та V8-збіркою, а ця стаття виходить із того, що V8-збірка вже в процесі

Схема PDFium Component із чотирма комбінаціями звичайного pdfium.dll і pdfium.v8.dll із підтримкою XFA проти документів AcroForm і XFA: структура версії 1 ламала лише збірку V8 зі звичайною формою — EPdfError у FPDFDOC_InitFormFillEnvironment, — тоді як виправлена структура версії 2 відкриває всі чотири
Один умовний оператор прив'язав версію ABI до документа, тож вибір V8-бінарника на весь процес перетворював кожен наступний звичайний PDF на невдалу ініціалізацію середовища

Що насправді обіцяє поле version у FPDF_FORMFILLINFO?

FPDF_FORMFILLINFO.version каже PDFium, які поля структури йому дозволено читати, і публічний заголовок fpdf_formfill.h прив'язує прийнятні значення до того, як збірку скомпільовано, а не до документа. Переказуючи, контракт має три частини. Версія 1 охоплює стабільні callback-и від FFI_Invalidate до FFI_DoGoToAction плюс вказівник m_pJsPlatform. Збірка без модуля XFA приймає 1 або 2, і з 2 вона додатково викликатиме експериментальні callback-и. Збірка з модулем XFA вимагає 2 без жодних варіантів, і заголовок повторює цю вимогу двічі, ніби очікує, що люди її проґавлять. Ніде контракт не згадує документ. Версія — це твердження про структуру, яку ви виділили: з 2 ви обіцяєте, що пам'ять після m_pJsPlatform існує й тримає або чинні вказівники на функції, або NULL

Область версії 2 — це те, де живе вся машинерія XFA. Вона починається з xfa_disabled, FPDF_BOOL, який заголовок описує як ігнорований нижче версії 2 і значущий лише тоді, коли модуль XFA скомпільовано, і продовжується сімнадцятьма вказівниками на функції, від FFI_DisplayCaret до FFI_DoURIActionWithKeyboardModifier. Кожен із них задокументовано як обов'язковий для XFA, а інакше — як такий, що має бути NULL. Це формулювання і є ключем до всього виправлення. NULL — не стан помилки для цих слотів; це задокументований стан для хоста, який не керує XFA. Структура, очищена через FillChar і потім позначена як версія 2, задовольняє контракт на збірці без XFA рівно так само, як структура версії 1, і тільки її прийме збірка з XFA

Схема PDFium Component зі структурою FPDF_FORMFILLINFO у Delphi: версія 1 охоплює callback-и від FFI_Invalidate до FFI_DoGoToAction плюс m_pJsPlatform, версія 2 додає xfa_disabled і сімнадцять вказівників епохи FFI_DisplayCaret, FillChar очищає кожен байт, а NULL-слоти — це задокументований стан для хоста, який не керує XFA
Структура Pascal завжди має повну розкладку версії 2, тож збірка з XFA її приймає, а звичайна збірка просто ніколи не викликає експериментальні слоти, які лишаються NULL

Старий вибір прив'язував ABI до документа

Дефект був одним умовним оператором, який окремо виглядав розумно. TPdf.InitializeFormFill обчислює прапорець RuntimeReady з трьох фактів: документ повідомляє тип форми XFA через TPdf.XFA, рядкові хелпери XFA розв'язалися через XfaFeaturesAvailable, а V8-експорти розв'язалися через V8FeaturesAvailable. До v3.116.0 той самий прапорець ще й вибирав версію

// v3.115.0 і раніше: версія ABI йшла за документом
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

if RuntimeReady then
  FFormFillInfo.Info.version := 2
else
  FFormFillInfo.Info.version := 1;

// ... а гілка «немає runtime» фіксувала її знову
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Прочитайте це з заголовком у руках — і збій очевидний. RuntimeReady хибний для кожного звичайного документа AcroForm, тож кожен звичайний документ оголошував версію 1. На pdfium.dll це гаразд. На pdfium.v8.dll, тобто збірці з підтримкою XFA, PDFium перевіряє поле, бачить, що воно нижче за потрібну 2, і повертає нульовий FPDF_FORMHANDLE, який CheckPdf перетворює на виняток вище. Намір старого коду був захисний: тримати версію 1, щоб збірка з XFA ніколи не читала непризначені слоти версії 2. Він захищався від проблеми, яку заголовок уже виключає, і створив ту, про яку заголовок явно попереджає. Виправлений код вирішує версію один раз, наперед, із того, чим структура фізично є

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // сторожове значення: використовувати статичне дерево сторінок
  if not FormFill then
    Exit;

  FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
  FFormFillInfo.Pdf := Self;

  // Повну структуру версії 2 виділено й очищено вище. PDFium
  // приймає версію 2 без XFA і вимагає її в кожній збірці з
  // підтримкою XFA, навіть коли цей документ не містить форми XFA.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady керує callback-ами XFA та xfa_disabled, але ніколи версією.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Де RuntimeReady все ще на місці: callback-и й xfa_disabled

RuntimeReady зберігає свою роль воріт для поведінки XFA; він просто більше не торкається розкладки структури. Callback-и версії 1 — FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction і решта того блоку — під'єднані безумовно, бо і AcroForm, і XFA залежать від них. Сімнадцять вказівників версії 2 присвоюються лише всередині гілки RuntimeReady, разом із xfa_disabled := 0. Коли документ — XFA, а runtime відсутній, структура лишається на версії 2 з xfa_disabled у 1 і слотами версії 2, що лишилися NULL, а обгортка піднімає OnXfaRuntimeMissing, щоб хост міг порадити перезапуск на pdfium.v8.dll. Після того, як середовище існує, FPDF_LoadXFA викликається лише тоді, коли RuntimeReady був істинним, і тільки істинне повернення виставляє FXfaRuntimeUsable, який і повідомляє TPdf.XfaRuntimeAvailable

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA увімкнено
    FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
    FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
    FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
    FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
    FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
    FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
    FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
    FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
    FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
    // ... від FFI_UploadTo до FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime недоступний: лишити версію 2, XFA лишити вимкненим, повідомити хост.
    if Assigned(FOnXfaRuntimeMissing) then
      FOnXfaRuntimeMissing(Self);
  end;

  FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
  CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
  if RuntimeReady then
    FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;

Дві деталі в цьому блоці легко зробити неправильно, коли пишете власну прив'язку. FXfaPageCountOverride скидається в -1 як сторожове значення перед усім іншим, тож PageCount відкочується до статичного дерева сторінок, доки FFI_PageEvent не повідомить про перепагінацію; нуль там мовчки заявив би порожній документ. І кожен із callback-ів версії 2 — це статична рутина cdecl, яка дістає свій TPdf зі структури й ковтає будь-який виняток Pascal перед поверненням у PDFium, і це та дисципліна, яку наша замітка про зміцнення ABI PDFium у Delphi виписує для FFI_OpenFile. Ніщо в зміні версії не послаблює жодного з цих двох правил

Чи безпечна версія 2, коли в DLL немає модуля XFA?

Так, і причина в структурі, а не в обіцянці бібліотеки. На збірці без XFA заголовок каже, що версія 2 змушує викликати й експериментальні callback-и, тож питання в тому, що PDFium знаходить, коли заглядає. TPdfFormFillInfo — це packed-структура, чий член Info і є повний FPDF_FORMFILLINFO з усіма полями версії 2, і InitializeFormFill очищає все це через FillChar, перш ніж торкнутися хоч байта. Тож на звичайному pdfium.dll із звичайним документом бібліотека бачить версію 2, виставлений xfa_disabled і NULL у кожному експериментальному слоті — саме той стан, який заголовок приписує хосту, що не реалізує XFA. Жодної обрізаної структури, за яку бібліотека могла б заглянути, немає, бо структура ніколи й не була коротшою за версію 2. Стара логіка захищалася від розбіжності в розкладці, яку сама ж декларація Pascal уже усунула

Межа, яку варто назвати чесно, — це та, якої структура покрити не може. Версія 2 на звичайному документі не вмикає ні JavaScript, ні скрипти XFA, ні будь-які з хостових подій за тими callback-ами. m_pJsPlatform приєднується лише тоді, коли V8FeaturesAvailable істинний, XFA лишається вимкненим, якщо RuntimeReady не був істинним, а TPdf.XFA і далі повідомляє тип форми з FPDF_GetFormType незалежно від того, про що домовилося середовище. Хост, який хоче знати, чи динамічний XFA справді відрендериться, має й далі читати XfaRuntimeAvailable після того, як Active стане істинним, як радить наша замітка про виявлення форм XFA й вилучення пакетів XFA, а не виводити щось із поля версії

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Спрацьовує з InitializeFormFill, коли документ — XFA, але завантажений
  // pdfium.dll не може запустити рушій. Середовище форм усе одно відкривається,
  // бо версію 2 передано в обох випадках; вимкнений лише runtime XFA.
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll to enable dynamic rendering';
end;

procedure TMainForm.OpenDocument(const FileName: string);
begin
  Pdf.Active := False;
  Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  Pdf.FormFill := True;
  Pdf.FileName := FileName;
  Pdf.Active := True;   // більше не кидає виняток на звичайному PDF під pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Версія протоколу й доступність функцій — це дві різні осі

Загальне правило, яке випливає з цього виправлення, таке: поле версії в структурі callback-ів відповідає на питання «який розмір цієї структури і що з неї можна читати», тоді як виявлення функцій відповідає на питання «які з цих слотів зроблять щось корисне». Перше визначається нативним бінарником і тією декларацією Pascal, під яку ви скомпілювалися. Друге змінюється від документа, від таблиці експортів DLL і від конфігурації хоста. Згорнути ці дві речі в один булевий прапорець спокусливо, бо випадок XFA випадково потребує обох, але тієї ж миті, коли збірка починає вимагати мінімальну версію, згортання ламається для кожного документа, якому ця функція не потрібна. Форми XFA, описані в ISO 32000-1 §12.7.8 як XML-навантаження, що живе поряд зі словником AcroForm, — це тут функція; розкладка структури — це протокол, і PDFium має право наполягати на розкладці ще до того, як загляне у файл. Та сама форма трапляється всюди, де C-бібліотека версіонує свої структури: блок viewer-info, структура опцій рендерингу, таблиця callback-ів платформи. Безпечний патерн — той, якого дотримується виправлений InitializeFormFill. Оголосіть найновішу розкладку, яку розумієте, очистіть її повністю, виставте версію відповідно до цієї розкладки безумовно, а вже потім дозвольте перевіркам можливостей вирішити, які слоти заповнювати. Якщо майбутній заголовок PDFium додасть версію 3, зміна буде в декларації й у тому одному присвоєнні, а не в залежній від документа гілці, яка буде хибною для тієї комбінації, якої ніхто не тестував

Схема PDFium Component, яка розділяє дві осі за FPDF_FORMFILLINFO: версію протоколу, визначену розкладкою структури й нативним бінарником, і доступність функцій, де RuntimeReady керує xfa_disabled, сімнадцятьма слотами версії 2, FPDF_LoadXFA та m_pJsPlatform — для кожного документа й кожного хоста
Поле версії описує пам'ять, яку інша сторона може читати, перевірки можливостей вирішують, які слоти роблять щось корисне, а згортання цих двох в один булевий прапорець ламає ту збірку, яка вимагає мінімум

Виправлена ініціалізація заповнення форм постачається в PDFium Component для Delphi, Lazarus і C++Builder, і вона діє однаково на Win32 і Win64, бо обидві збірки ділять ту саму декларацію структури. Якщо ваш застосунок уже вибирає pdfium.v8.dll заради керованих JavaScript AcroForm, це та зміна, яка дозволяє йому відкривати решту вашого PDF-архіву тим самим бінарником, без окремих випадків для середовища форм