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

FPDF_FORMFILLINFO версия 2 в Delphi: следвайте DLL ABI

PDFium Component вече задава FPDF_FORMFILLINFO.version на 2 за всяка form-fill среда, която инициализира, защото версията, която нативен PDFium build приема, е свойство на този build, не на документа, който се отваря. XFA-включен pdfium.v8.dll отказва версия 1 изцяло, така че обикновен AcroForm PDF, отварян през него, се проваливаше в FPDFDOC_InitFormFillEnvironment без никакво XFA на видимо място. Поправката във v3.116.0 е малка, но грешката зад нея е обща и заслужава да бъде назована: полето за протоколна версия описва memory оформлението, което другата страна очаква, и никога не бива да се извежда от това дали случайно се нуждаете от функционалностите, които това оформление носи

Защо FPDFDOC_InitFormFillEnvironment се проваля на обикновен PDF с pdfium.v8.dll?

Средата се проваля, защото XFA-включен PDFium build валидира полето version преди да направи каквото и да е друго, а старата wrapper логика му подаваше 1 винаги, когато текущият документ не е XFA форма. Симптомът в Delphi host е EPdfError, вдигнат от TPdf.InitializeFormFill със съобщение Cannot initialize form fill environment, хвърлен при отваряне на обикновена фактура или данъчна форма, в която няма нищо освен AcroForm текстови полета. Същият файл се отваря добре срещу обикновения pdfium.dll. Същият DLL отваря добре истински XFA документ. Само комбинацията от V8 build и не-XFA документ се чупи — точно комбинацията, в която host каца, след като включи EnableV8Engine, за да получи AcroForm JavaScript, или след като auto-селекцията в LoadDocument вече е ангажирала процеса с pdfium.v8.dll за по-ранен XFA файл. Този ангажимент е процес-обхватен: EnableV8Engine се чете преди първия LoadLibrary, и щом XFA build-ът е зареден, всеки следващ обикновен PDF минава през същата setup на средата срещу същия бинарен файл. Host-ът не е сгрешил нищо; wrapper-ът зададе грешния въпрос, когато попълваше записа. Ако все още решавате кой бинарен файл изобщо да доставяте, бележката ни за разполагане на PDFium DLL и диагностика на load провали покрива избора между обикновен и V8, а тази статия предполага, че V8 build-ът вече е в процеса

Диаграма на PDFium Component за четирите комбинации от обикновен pdfium.dll и XFA-включен pdfium.v8.dll срещу AcroForm и XFA документи: запис версия 1 счупи само V8 build-а с обикновена форма, EPdfError в FPDFDOC_InitFormFillEnvironment, докато коригираният запис версия 2 отваря и четирите
Един условен израз завърза ABI версията за документа, така че процес-обхватният избор на V8 бинарен файл превърна всеки следващ обикновен PDF в провалила се инициализация на средата

Какво обещава действително полето version в FPDF_FORMFILLINFO?

FPDF_FORMFILLINFO.version казва на PDFium кои полета на записа може да чете, а публичният header fpdf_formfill.h завързва приемливите стойности с начина, по който библиотеката е компилирана, а не с документа. Парафразирано, договорът има три части. Версия 1 покрива стабилните callback-и от FFI_Invalidate до FFI_DoGoToAction плюс указателя m_pJsPlatform. Build без XFA модула приема както 1, така и 2, а с 2 вика и допълнителните експериментални callback-и. Build с XFA модула изисква 2, точка, а header-ът повтаря това изискване два пъти, сякаш е очаквал хората да го пропуснат. Никъде договорът не споменава документа. Версията е изказване за записа, който сте алокирали: с 2 вие обещавате, че паметта след m_pJsPlatform съществува и държи или валидни указатели към функции, или NULL

Регионът версия 2 е мястото, където живее цялата XFA механика. Той започва с xfa_disabled — FPDF_BOOL, който header-ът описва като игнориран под версия 2 и смислен само когато XFA модулът е компилиран — и продължава със седемнайсет указателя към функции, FFI_DisplayCaret до FFI_DoURIActionWithKeyboardModifier. Всеки от тях е документиран като изискван за XFA и иначе да се зададе NULL. Тази формулировка е ключът към цялата поправка. NULL не е състояние на грешка за тези слотове; той е документираното състояние за host, който не управлява XFA. Запис, изчистен с FillChar и после маркиран като версия 2, удовлетворява договора на не-XFA build точно толкова добре, колкото запис версия 1, и е единственият запис, който XFA build ще приеме

Диаграма на PDFium Component за записа FPDF_FORMFILLINFO в Delphi: версия 1 покрива callback-ите FFI_Invalidate до FFI_DoGoToAction плюс m_pJsPlatform, версия 2 добавя xfa_disabled и седемнайсет указатели от ерата FFI_DisplayCaret, FillChar изчиства всеки байт, а NULL слотовете са документираното състояние за host, който не управлява XFA
Pascal записът е винаги пълното оформление на версия 2, така че XFA-включен build го приема, а обикновен build просто никога не вика експерименталните слотове, които остават NULL

Старата селекция завърза ABI-то за документа

Дефектът беше един-единствен условен израз, който изглеждаше разумен в изолация. TPdf.InitializeFormFill изчислява флаг RuntimeReady от три факта: документът докладва XFA формов тип чрез TPdf.XFA, XFA string помощниците са разрешени чрез XfaFeaturesAvailable, а V8 export-ите са разрешени чрез 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;

Прочетете го с header-а в ръка и провалът е очевиден. RuntimeReady е false за всеки обикновен AcroForm документ, така че всеки обикновен документ съобщаваше версия 1. На pdfium.dll това е наред. На pdfium.v8.dll, който е XFA-включеният build, PDFium проверява полето, намира го под изискваните 2, и връща нулев FPDF_FORMHANDLE, което CheckPdf превръща в горния exception. Намерението на стария код беше дефанзивно: пази версия 1, така че XFA build никога да не прочете незададените слотове версия 2. Той се защитаваше от проблем, който header-ът вече изключва, и създаде един, за който header-ът изрично предупреждава. Коригираният код решава версията веднъж, отпред, по това какъв е записът физически

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // страж: ползвай статичното page дърво
  if not FormFill then
    Exit;

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

  // Пълният запис версия 2 е алокиран и изчистен горе. PDFium
  // приема версия 2 без XFA и я изисква във всеки XFA-включен
  // build, включително когато този документ не съдържа XFA форма.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady порта XFA callback-ите и 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, а wrapper-ът вдига OnXfaRuntimeMissing, така че host-ът може да предложи рестарт върху pdfium.v8.dll. След като средата съществува, FPDF_LoadXFA се вика само когато RuntimeReady е бил true, и само истинско връщане задава 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 изключено, кажи на host-а.
    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 пада обратно към статичното page дърво, докато FFI_PageEvent докладва репагинация; нула там би заявявала тихо празен документ. И всеки от callback-ите версия 2 е статична cdecl рутина, която възстановява притежаващия TPdf от записа и гълта всяко Pascal exception, преди да се върне на PDFium — дисциплината, която бележката ни за втвърдяване на PDFium ABI в Delphi изписва за FFI_OpenFile. Нищо в промяната на версията не облекчава нито едно от двете правила

Безопасна ли е версия 2, когато DLL-ът няма XFA модул?

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

Границата, която си заслужава да се признае честно, е онази, която записът не може да покрие. Версия 2 на обикновен документ не включва JavaScript, XFA скриптове или някое от host събитията зад тези callback-и. m_pJsPlatform се прикачва само когато V8FeaturesAvailable е true, XFA остава изключено освен ако RuntimeReady не е бил true, а TPdf.XFA продължава да докладва формовия тип от FPDF_GetFormType независимо от това какво средата е договорила. Host, който иска да знае дали динамично XFA наистина ще се рендерира, трябва да продължи да чете XfaRuntimeAvailable след като Active стане true — както препоръчва бележката ни за откриване на XFA форми и извличане на XFA пакети — вместо да извежда каквото и да е от полето версия

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Светва от InitializeFormFill, когато документът е XFA, а зареденият
  // pdfium.dll не може да пусне engine-а. Формовата среда все още се отваря,
  // защото версия 2 е подадена и в двата случая; само XFA runtime-ът е изключен.
  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 export таблица и на host конфигурация. Сливането на двете в един boolean е изкушаващо, защото XFA случай случайно се нуждае и от двете, но в момента, в който build налага минимална версия, сливането се чупи за всеки документ, който не се нуждае от функционалността. XFA формите, описани в ISO 32000-1 §12.7.8 като XML payload, живеещ до AcroForm речника, са функционалността тук; оформлението на записа е протоколът, а PDFium има правото да настоява за оформлението, преди изобщо да погледне файла. Същата форма се появява навсякъде, където C библиотека верионира своите структури: блок viewer-info, запис render-options, таблица с platform callback-и. Безопасният модел е онзи, който коригираният InitializeFormFill следва. Декларирайте най-новото оформление, което разбирате, изчистете го напълно, задайте версията да съответства на това оформление безусловно, и после нека проверките за възможности решат кои слотове да се попълнят. Ако бъдещ PDFium header добави версия 3, промяната е в декларацията и в това едно задаване, не в документо-зависим клон, който ще бъде грешен за онази комбинация, която никой не е тествал

Диаграма на PDFium Component, разделяща двете оси зад FPDF_FORMFILLINFO: протоколната версия, фиксирана от оформлението на записа и нативния бинарен файл, и наличността на функционалности, където RuntimeReady порта xfa_disabled, седемнайсет слота версия 2, FPDF_LoadXFA и m_pJsPlatform на документ и на host
Поле за версия описва паметта, която другата страна може да чете, проверките за възможности решават кои слотове правят нещо полезно, а сливането на двете в един boolean чупи build-а, който налага минимум

Коригираната form-fill инициализация се доставя в PDFium Component за Delphi, Lazarus и C++Builder, и се прилага еднакво на Win32 и Win64, тъй като и двата build-а споделят същата декларация на записа. Ако вашето приложение вече избира pdfium.v8.dll за JavaScript-задвижвани AcroForm-и, това е промяната, която му позволява да отвори останалата част от PDF архива ви през същия бинарен файл, без специални случаи за формовата среда