PDFium Component теперь ставит FPDF_FORMFILLINFO.version в 2 для каждой инициализируемой им среды заполнения форм, потому что версия, которую принимает нативная сборка PDFium, — свойство этой сборки, а не открываемого документа. pdfium.v8.dll с поддержкой XFA отвергает версию 1 с ходу, поэтому обычный AcroForm PDF, открытый через него, падал в 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 уже загружена в процесс
Что на самом деле обещает поле version в FPDF_FORMFILLINFO?
FPDF_FORMFILLINFO.version говорит PDFium, какие поля записи ему позволено читать, и публичный заголовок fpdf_formfill.h привязывает допустимые значения к тому, как собрана библиотека, а не к документу. В пересказе контракт состоит из трёх частей. Версия 1 покрывает стабильные обратные вызовы от FFI_Invalidate до FFI_DoGoToAction плюс указатель m_pJsPlatform. Сборка без модуля XFA принимает и 1, и 2, а с 2 она ещё и вызовет дополнительные экспериментальные обратные вызовы. Сборка с модулем 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
Старый выбор привязывал 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;
// ... и ветка отсутствующей среды прибивала её снова
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 управляет XFA-колбэками и xfa_disabled, но не версией.
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
...
Где RuntimeReady всё ещё уместен: обратные вызовы и xfa_disabled
RuntimeReady сохраняет свою работу как ворота для поведения XFA; он просто больше не трогает раскладку записи. Обратные вызовы версии 1 — FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction и остальной этот блок — подключаются безусловно, потому что от них зависят и AcroForm, и XFA. Семнадцать указателей версии 2 присваиваются только внутри ветки RuntimeReady вместе с xfa_disabled := 0. Когда документ XFA, а среды нет, запись остаётся версией 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
// Среда недоступна: держим версию 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 не сообщит о переразбиении; ноль здесь молча объявил бы документ пустым. И каждый обратный вызов версии 2 — это статическая процедура cdecl, которая достаёт владеющий TPdf из записи и глотает любое исключение Pascal, прежде чем вернуться в PDFium, — та дисциплина, которую наша заметка про укрепление ABI PDFium в Delphi расписывает для FFI_OpenFile. Ничто в смене версии не ослабляет ни одного из этих правил
Безопасна ли версия 2, когда в DLL нет модуля XFA?
Да, и причина этому в записи, а не в обещании библиотеки. На сборке без XFA заголовок говорит, что версия 2 приводит к вызову и экспериментальных обратных вызовов, так что вопрос в том, что PDFium найдёт, когда посмотрит. TPdfFormFillInfo — упакованная запись, чей член Info и есть полная FPDF_FORMFILLINFO со всеми полями версии 2, а InitializeFormFill обнуляет всё это через FillChar, прежде чем тронуть хоть байт. Так что на обычной pdfium.dll с обычным документом библиотека видит версию 2, выставленный xfa_disabled и NULL в каждом экспериментальном слоте — ровно то состояние, которое заголовок предписывает для хоста, не реализующего XFA. Усечённой записи, за которую библиотека могла бы прочитать лишнее, нет, потому что запись изначально никогда не была короче версии 2. Старая логика защищалась от несовпадения раскладки, которое объявление на Pascal уже устранило
Граница, которую стоит честно назвать, — та, которую запись покрыть не может. Версия 2 на обычном документе не включает ни JavaScript, ни скриптинг XFA, ни одно из хост-событий за этими обратными вызовами. 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 передана в любом случае; выключена только среда 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;
Версия протокола и доступность возможностей — две разные оси
Общее правило, вытекающее из этого фикса, таково: поле версии в структуре обратных вызовов отвечает на вопрос «какого размера эта запись и что из неё можно читать», а определение возможностей отвечает на вопрос «какие из этих слотов будут что-то делать полезное». Первое зафиксировано нативным бинарником и объявлением на Pascal, под которое вы компилировались. Второе меняется от документа к документу, от таблицы экспортов DLL и от конфигурации хоста. Сваливать их в один булев флаг соблазнительно, потому что случай XFA случайно нуждается в обоих, но в тот момент, когда сборка требует минимальную версию, это сваливание ломается на каждом документе, которому возможность не нужна. XFA-формы, описанные в ISO 32000-1 §12.7.8 как XML-нагрузка, живущая рядом со словарём AcroForm, — это здесь возможность; раскладка записи — протокол, и PDFium вправе настаивать на раскладке прежде, чем вообще посмотрит на файл. Та же форма встречается везде, где C-библиотека версионирует свои структуры: блок сведений о просмотрщике, запись параметров рендеринга, таблица платформенных обратных вызовов. Безопасный шаблон — тот, которому следует исправленный InitializeFormFill. Объявите самую новую раскладку, которую понимаете, полностью её обнулите, выставьте версию в соответствии с этой раскладкой безусловно и затем позвольте проверкам возможностей решить, какие слоты заполнять. Если будущий заголовок PDFium добавит версию 3, менять придётся объявление и это единственное присваивание, а не зависящую от документа ветку, которая окажется неверной для того сочетания, которое никто не тестировал
Исправленная инициализация среды заполнения форм поставляется в PDFium Component для Delphi, Lazarus и C++Builder и действует одинаково на Win32 и Win64, поскольку обе сборки делят одно объявление записи. Если ваше приложение уже выбирает pdfium.v8.dll ради AcroForm на JavaScript, это то изменение, которое позволяет ему открывать через тот же бинарник и остальной ваш архив PDF, не выделяя среду форм в особый случай