Счёт AcroForm с горсткой текстовых полей и безо всякого XFA открывается нормально через обычную pdfium.dll, однако тот же файл может заставить FPDFDOC_InitFormFillEnvironment вернуть NULL, стоит процессу работать на pdfium.v8.dll с поддержкой XFA. Байты документа в обоих прогонах идентичны. Различается одно целое: сборки PDFium с XFA требуют, чтобы FPDF_FORMFILLINFO.version был 2, и биндинг, пишущий 1 для документов без XFA, разворачивается ещё до того, как PDFium взглянет на первый callback
Почему FPDFDOC_InitFormFillEnvironment отвергает обычный AcroForm PDF?
Отказ приходит от бинарника, и документ тут ни при чём. Заголовок form-fill, fpdf_formfill.h в PDFium и его Pascal-перевод в FPdfFormFill.pas, утверждает, что сборка, скомпилированная с модулем XFA, требует версию 2, и говорит это дважды в одном блоке комментария. Сборка без XFA принимает 1 или 2. При биндинге, рассылающем версию 1 для не-XFA файлов, из четырёх сочетаний бинарника и документа проваливается ровно одно: запись версии 1, врученная pdfium.v8.dll вместе с обычным AcroForm-файлом. Обычная DLL принимает такую запись. V8-сборка принимает запись версии 2 для любого документа. Настоящую XFA-форму на V8-сборке биндинг, ключующий версию по документу, версией 1 никогда не наградит, так что проваливается ровно та клетка, которую никто не думает тестировать
Ручной биндинг в этой точке видит NULL от FPDFDOC_InitFormFillEnvironment и больше ничего. TPdf в PDFium Component сообщил бы об отвергнутой среде как об EPdfError во время Active := True, но он всегда пишет версию 2, так что эта клетка там не возникает. Попасть в неё легко. Хост ставит глобальный EnableV8Engine в True, потому что ему нужен AcroForm JavaScript, и с этого момента каждый PDF в процессе идёт через V8-бинарник. Или хост флаг не трогает, но первый открытый файл имеет запись /XFA в AcroForm-словаре или /NeedsRendering true в каталоге: пока библиотека PDFium ещё не загружена, TPdf сканирует файл на эти два маркера и включает EnableV8Engine перед загрузкой DLL. В любом случае выбор держится до конца процесса, и каждый обычный AcroForm-файл, открытый позже, встречает XFA-сборку. Какой бинарник поставлять в первую очередь, разобрано в руководстве по развёртыванию PDFium DLL и диагностике сбоев загрузки; всё ниже предполагает, что V8-сборка уже загружена
Что обещает поле version в FPDF_FORMFILLINFO
FPDF_FORMFILLINFO.version сообщает PDFium, сколько записи можно читать. Версия 1 покрывает стабильную часть: Release, callbacks от FFI_Invalidate до FFI_DoGoToAction и указатель m_pJsPlatform. Версия 2 добавляет экспериментальный хвост. Сборка без XFA, получившая версию 2, тоже вызовет экспериментальные callbacks, которые там найдёт; сборка с XFA меньшего не примет. Заголовок про документ не упоминает вовсе. Писать 2 — значит обещать, что память после m_pJsPlatform существует и каждый слот в ней держит либо валидную cdecl-функцию, либо NULL
Хвост начинается с xfa_disabled, FPDF_BOOL, игнорируемого ниже версии 2, за которым идут семнадцать указателей функций от FFI_DisplayCaret до FFI_DoURIActionWithKeyboardModifier. Пятнадцать из них, от FFI_DisplayCaret до FFI_PutRequestURL, документированы как "Required for XFA, otherwise set to NULL". Последние два, FFI_OnFocusChange и FFI_DoURIActionWithKeyboardModifier, необязательны для любого хоста, а заголовок добавляет, что встраивание версии 2, оставившее FFI_DoURIActionWithKeyboardModifier незаполненным, получает перенаправление кликов URI на FFI_DoURIAction. В сумме NULL — предписанное значение для хоста, не управляющего XFA. Запись, обнулённая FillChar и проштампованная версией 2, выполняет контракт на обычной сборке ровно как запись версии 1, и это к тому же единственная запись, которую примет XFA-сборка
Сторона JavaScript следует той же схеме. m_pJsPlatform не используется сборками без V8; на V8-сборке NULL-указатель останавливает JavaScript при рендеринге документа. Запись IPDF_JsPlatform, на которую он указывает, имеет собственное поле version, и заголовок говорит, что та сейчас обязана быть 2
Ловушка: пусть документ выбирает версию ABI
Ошибка — один условный переход, который сам по себе читается здраво. Ручной биндинг и так должен решать, стоят ли XFA-callbacks подключения, и соблазнительно поручить тот же тест выбору номера версии
// Соблазнительная форма для ручного биндинга: версия следует за документом
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
FormInfo.version := 2
else
FormInfo.version := 1; // каждый не-XFA документ получает 1
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// на pdfium.v8.dll с обычным AcroForm-файлом FormHandle здесь nil
Умысел той ветки оборонительный: держать версию 1, чтобы XFA-сборка никогда не читала слоты, которые хост не заполнил. Но FillChar строкой выше уже заполнил их NULL, который заголовок определяет как правильное значение для хоста без XFA. Ветка стережёт опасность, которую объявление уже устранило, и шагает прямиком в ту, о которой заголовок предупреждает. К тому же её не поймать тестированием XFA-файлов, ведь те каждый раз идут по пути версии 2
PDFium Component строит запись наоборот. Всякий раз, когда TPdf.FormFill True, открытие документа обнуляет полную запись, пишет версию 2 и xfa_disabled := 1 и только потом спрашивает, что нужно документу. Сформулируем правило для любого биндинга: ставьте версию по записи, которую вы объявили, и не позволяйте ничего её трогать. Вот такая форма для вашего собственного кода, с обёрткой THostFormFillInfo, объявленной ниже
// FormInfo — поле THostFormFillInfo вашего объекта-вьюера, поэтому оно живёт
// дольше хэндла форм; HostInvalidate, HostGetPage и остальные — ваши
// собственные cdecl-callbacks
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;
FormInfo.Info.version := 2; // соответствует объявленной раскладке, всегда
FormInfo.Info.xfa_disabled := 1; // XFA остаётся выключенной, пока не доказана пригодность
FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... остальные callbacks версии 1
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
FormInfo.Info.xfa_disabled := 0;
FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
FormInfo.Info.FFI_PageEvent := HostPageEvent;
// ... оставшиеся слоты версии 2
end;
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);
Где по-прежнему уместны RuntimeReady и V8FeaturesAvailable
Проверки возможностей решают, какие слоты заполнять, и в PDFium Component они никогда не трогают раскладку. TPdf вычисляет готовность из трёх фактов: документ сообщает XFA-тип формы через TPdf.XFA, глобальный XfaFeaturesAvailable находит XFA-строковые помощники, экспортируемые загруженной DLL, а V8FeaturesAvailable находит экспорты V8. Callbacks версии 1 подключаются для каждого документа, ведь от них зависят и AcroForm, и XFA. Все семнадцать слотов версии 2 назначаются, а xfa_disabled очищается, только когда все три факта сходятся. После появления среды FPDF_LoadXFA запускается только для этого случая, и только ненулевой возврат заставляет TPdf.XfaRuntimeAvailable докладывать True. FPDF_LoadXFA экспортируют даже сборки без движка XFA — там он просто возвращает false, почему одной проверки экспорта недостаточно
Когда документ XFA, а рантайма нет, запись остаётся версии 2 с xfa_disabled равным 1 и хвостом из NULL. Среда открывается, содержимое XFA остаётся статичным, а TPdf возбуждает OnXfaRuntimeMissing, чтобы хост мог посоветовать перезапуск на pdfium.v8.dll. m_pJsPlatform следует третьему правилу: он подключается всякий раз, когда V8FeaturesAvailable истинно, и для обычных AcroForm-файлов тоже — именно это позволяет скриптам уровня документа, вычислениям полей и app.alert работать под V8-сборкой. PageCount читает статичное дерево страниц, пока XFA не перепагинирует документ через FFI_PageEvent; с этого момента новый итог берётся из FPDF_GetPageCount и докладывается через OnXfaPageCountChanged
На стороне хоста два свойства отвечают на два разных вопроса. XFA сообщает, что лежит в файле, — из FPDF_GetFormType, независимо от бинарника. XfaRuntimeAvailable сообщает, запустился ли движок фактически для этого документа. Читайте второе после того, как Active станет True, как делает статья об обнаружении XFA-форм и извлечении XFA-пакетов, а поле версии из рассуждений уберите целиком
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// Возбуждается при открытии документа, когда он XFA, а загруженная DLL не
// может запустить движок. Среда форм всё ещё существует, с выключенной XFA
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll for 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; // обычные AcroForm-файлы тоже открываются под pdfium.v8.dll
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaNotice; // ваша собственная UI-процедура
end;
Ошибки раскладки, ломающие запись до чтения версии
Правильный номер версии помогает, только если байты за ним лежат там, куда их положил C-компилятор. Несколько ошибок биндинга дают запись, которую PDFium читает неверно при любой версии, и одна из них остаётся невидимой на Win32. Это объявление FPDF_FORMFILLINFO как packed record. На Win32 каждое поле четырёхбайтное, поэтому packed и выровненная раскладки случайно совпадают. На Win64 C-структура набивает четыре байта после version, чтобы Release попал на 8-байтовую границу, и набивает снова после xfa_disabled; packed-объявление на Pascal выбрасывает оба зазора и сдвигает каждый указатель callback на четыре байта. Объявление в FPdfFormFill.pas — обычный record именно поэтому
Вторая ошибка — обрубленная запись: перевод, остановившийся на m_pJsPlatform, но пишущий версию 2. PDFium тогда читает хвост из какой попало памяти за записью. Третья — время жизни. Заголовок говорит, что запись, переданная в FPDFDOC_InitFormFillEnvironment, обязана оставаться валидной, пока не закрыт возвращённый хэндл форм, так что запись в локальной переменной процедуры, открывающей документ, — висячий указатель в момент выхода из той процедуры. TPdf держит свою запись полем компонента, завёрнутой во внешнюю запись, которая кладёт структуру PDFium в нулевое смещение, а обратно-указатель на владеющий TPdf сразу за ней, — так каждый статический callback находит свой объект
type
// Неверно на Win64: упаковка убирает набивку, которую C-структура держит после
// version, поэтому Release стоит на смещении 4 здесь и на смещении 8 в PDFium
TBadFormFillInfo = packed record
version: Integer;
Release: Pointer;
// ... остальные поля
end;
// Ваша обёртка: сначала запись PDFium, за ней ваш обратно-указатель
THostFormFillInfo = record
Info: FPDF_FORMFILLINFO; // смещение 0, поэтому pThis приводится обратно напрямую
Owner: TObject;
end;
PHostFormFillInfo = ^THostFormFillInfo;
procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
left, top, right, bottom: Double); cdecl;
begin
try
// TMyViewer и InvalidatePdfRect олицетворяют ваш класс-вьюер
TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
page, left, top, right, bottom);
except
// Pascal-исключение не должно разматываться через C++-кадры PDFium
end;
end;
Ещё два слота заслуживают заботы. FFI_GetLocalTime возвращает 16-байтовый FPDF_SYSTEMTIME по значению, а Delphi и MSVC на Win32 расходятся в том, куда идёт скрытый указатель результата, поэтому Pascal-реализация испортила бы FPDF_FORMFILLINFO вызывающего. Актуальный PDFium помечает член как неиспользуемый, и Pascal-объявление несёт комментарий — оставить nil; PDFium Component так и делает. Каждый callback, который компонент устанавливает, ловит Pascal-исключения и возвращает документированное значение неудачи, потому что C ABI не умеет проносить исключение обратно через границу. Разбор укрепления PDFium ABI в Delphi проходит ту дисциплину на примере FFI_OpenFile
Версия протокола и доступность возможностей — две оси
Поле версии в структуре callback отвечает на вопрос, насколько велика запись и что из неё можно читать. Детекция возможностей отвечает, какие из этих слотов вообще что-то сделают. Первое зафиксировано нативным бинарником и объявлением, против которого вы компилировали; второе меняется от документа к документу, от таблицы экспортов DLL к таблице и от настройки хоста к настройке. XFA-случаю нужны оба ответа сразу, отчего их и тянет свернуть в один boolean, а этот свёрт ломается в момент, когда сборка начинает требовать минимальную версию. XFA-формы, которые ISO 32000-1 §12.7.8 описывает как XML-полезную нагрузку рядом с AcroForm-словарём, — та самая возможность из этой истории. Раскладка записи — протокол, и PDFium вправе проверить раскладку прежде, чем читать файл
Та же конструкция всплывает всюду, где C-библиотека версионировала свои структуры: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, любая таблица callback с заголовком размера или версии. Объявляйте новейшую раскладку, которую понимаете, обнуляйте её полностью, пишите соответствующую версию безусловно и заполняйте слоты по тому, что документ и DLL реально умеют. Если будущий fpdf_formfill.h введёт версию 3, работа — новое объявление и одна изменённая присваивалка. Ветку, ключующуюся на содержимом документа, пришлось бы пересматривать для каждого сочетания сборки и файла, а та, в которой она ошибётся, обычно та, что никто не открывал
Обработка записи, описанная здесь, — часть PDFium Component для Delphi, Lazarus и C++Builder, с одним и тем же объявлением для сборок Win32 и Win64. Приложение, уже поставляющее pdfium.v8.dll ради AcroForm с JavaScript, может открыть остальную часть своего архива, XFA или нет, через тот единственный бинарник без особой обработки среды форм