مقاله فنی

FPDF_FORMFILLINFO نسخهٔ 2 در Delphi: پیروی از ABI همان DLL

کامپوننت PDFium حالا مقدار FPDF_FORMFILLINFO.version را برای هر محیط form-fill که راه‌اندازی می‌کند روی 2 می‌گذارد، چون نسخه‌ای که یک build بومی PDFium قبول می‌کند خاصیت همان build است، نه خاصیت سندی که باز می‌شود. یک pdfium.v8.dll با پشتیبانی XFA نسخهٔ 1 را یکسره رد می‌کند، پس یک PDF سادهٔ AcroForm که از آن باز می‌شد در FPDFDOC_InitFormFillEnvironment شکست می‌خورد بدون این‌که هیچ XFAای در هیچ جا دیده شود. fix مربوط به v3.116.0 کوچک است، ولی اشتباه پشتش کلی است و ارزش نام‌گذاری دارد: یک فیلد نسخهٔ پروتکل لایهٔ حافظه‌ای را توصیف می‌کند که طرف مقابل انتظار دارد، و هرگز نباید از این استخراج شود که آیا تصادفاً به قابلیت‌هایی که آن لایه حمل می‌کند نیاز داری یا نه

چرا FPDFDOC_InitFormFillEnvironment روی یک PDF ساده با pdfium.v8.dll شکست می‌خورد؟

این محیط شکست می‌خورد چون یک build از PDFium با پشتیبانی XFA فیلد version را پیش از هر کار دیگری اعتبارسنجی می‌کند، و منطق wrapper قدیمی هر وقت سند جاری یک فرم XFA نبود به آن 1 می‌داد. نشانه‌اش در یک host مربوط به Delphi یک EPdfError است که از TPdf.InitializeFormFill با پیام Cannot initialize form fill environment بالا می‌آید، آن هم موقع باز کردن یک فاکتور یا فرم مالیاتی معمولی که چیزی جز فیلدهای متنی AcroForm ندارد. همان فایل در برابر pdfium.dll ساده بی‌اشکال باز می‌شود. همان DLL یک سند XFA واقعی را بی‌اشکال باز می‌کند. فقط ترکیب build نسخهٔ V8 با یک سند غیر-XFA می‌شکند، که دقیقاً همان ترکیبی است که یک host بعد از روشن کردن EnableV8Engine برای گرفتن JavaScript مربوط به AcroForm، یا بعد از این‌که انتخاب خودکار در LoadDocument فرایند را برای یک فایل XFAی قبلی به pdfium.v8.dll متعهد کرده باشد، در آن می‌افتد. آن تعهد در سطح کل فرایند است: EnableV8Engine پیش از اولین LoadLibrary خوانده می‌شود، و بعد از این‌که build نسخهٔ XFA بار شد هر PDF سادهٔ بعدی از همان راه‌اندازی محیط روی همان باینری می‌گذرد. host کار غلطی نکرده بود؛ wrapper موقع پر کردن رکورد سؤال غلطی پرسیده بود. اگر هنوز داری تصمیم می‌گیری کدام باینری را اصلاً عرضه کنی، یادداشت ما دربارهٔ عرضهٔ DLL مربوط به PDFium و عیب‌یابی خطاهای بارگذاری انتخاب بین ساده و V8 را پوشش می‌دهد، و این مقاله فرض می‌کند build نسخهٔ V8 از قبل داخل فرایند است

نمودار کامپوننت PDFium از چهار ترکیب pdfium.dll ساده و pdfium.v8.dll دارای XFA در برابر سندهای AcroForm و XFA: یک رکورد نسخهٔ 1 فقط build نسخهٔ 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 را پوشش می‌دهد. یک build بدون ماژول XFA هر یک از 1 و 2 را می‌پذیرد، و با 2 آن callbackهای آزمایشی اضافی را هم صدا می‌زند. یک build با ماژول XFA مقدار 2 را اجباری می‌خواهد، تمام، و هدر آن الزام را دو بار تکرار می‌کند انگار انتظار دارد مردم از دستش بدهند. هیچ‌جای این قرارداد از سند حرفی نمی‌زند. این نسخه یک اظهار است دربارهٔ رکوردی که تخصیص داده‌ای: با 2 داری وعده می‌دهی که حافظهٔ بعد از m_pJsPlatform وجود دارد و یا اشاره‌گرهای تابع معتبر در خود دارد یا NULL

ناحیهٔ نسخهٔ 2 همان جایی است که همهٔ ماشین‌آلات XFA زندگی می‌کند. با xfa_disabled شروع می‌شود، یک FPDF_BOOL که هدر توصیفش می‌کند به‌عنوان فیلدی که زیر نسخهٔ 2 نادیده گرفته می‌شود و فقط وقتی ماژول XFA کامپایل شده باشد معنا دارد، و با هفده اشاره‌گر تابع ادامه می‌یابد، از FFI_DisplayCaret تا FFI_DoURIActionWithKeyboardModifier. هر کدام از آن‌ها مستند شده‌اند که برای XFA لازم است و در غیر آن باید NULL باشند. همین عبارت کلید کل این fix است. NULL برای آن شیارها یک حالت خطا نیست؛ همان حالت مستندشده برای hostی است که XFA را نمی‌چرخاند. رکوردی که با FillChar پاک شده و بعد نسخهٔ 2 علامت خورده، قرارداد را روی یک build غیر-XFA دقیقاً به همان خوبی یک رکورد نسخهٔ 1 برآورده می‌کند، و تنها رکوردی است که یک build دارای XFA قبول می‌کند

نمودار کامپوننت PDFium از رکورد FPDF_FORMFILLINFO در Delphi: نسخهٔ 1 callbackهای FFI_Invalidate تا FFI_DoGoToAction به‌علاوهٔ m_pJsPlatform را پوشش می‌دهد، نسخهٔ 2 فیلد xfa_disabled و هفده اشاره‌گر از دوران FFI_DisplayCaret را اضافه می‌کند، FillChar هر بایت را پاک می‌کند، و شیارهای NULL حالت مستندشده برای hostی هستند که XFA را نمی‌چرخاند
رکورد Pascal همیشه چیدمان کامل نسخهٔ 2 است، پس یک build دارای XFA قبولش می‌کند و یک build ساده اصلاً شیارهای آزمایشی‌ای که NULL می‌مانند را صدا نمی‌زند

انتخاب قدیمی ABI را به سند گره زده بود

این نقص یک شرط تکی بود که به‌تنهایی معقول به نظر می‌رسید. TPdf.InitializeFormFill یک پرچم RuntimeReady را از سه واقعیت حساب می‌کند: سند از طریق TPdf.XFA یک نوع فرم XFA گزارش می‌کند، helperهای رشته‌ای XFA از طریق XfaFeaturesAvailable حل شده‌اند، و exportهای 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 که همان build دارای XFA است، PDFium فیلد را بررسی می‌کند، می‌بیند زیر 2 لازم است، و یک FPDF_FORMHANDLE تهی برمی‌گرداند، که CheckPdf آن را به همان استثنای بالا تبدیل می‌کند. قصد کد قدیمی تدافعی بود: نسخهٔ 1 را نگه دار تا یک build دارای XFA هرگز شیارهای تخصیص‌نیافتهٔ نسخهٔ 2 را نخواند. از مشکلی دفاع می‌کرد که هدر از قبل ردش کرده و مشکلی ساخت که هدر صریحاً درباره‌اش هشدار می‌دهد. کد اصلاح‌شده نسخه را یک بار، در ابتدا، از آنچه رکورد فیزیکاً هست تصمیم می‌گیرد

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinel: از درخت صفحهٔ ایستا استفاده کن
  if not FormFill then
    Exit;

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

  // رکورد کامل نسخهٔ 2 بالا تخصیص و پاک شده است. PDFium
  // نسخهٔ 2 را بدون XFA می‌پذیرد و در هر build دارای XFA
  // اجباری می‌خواهد، حتی وقتی این سند هیچ فرم XFA ندارد
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady فقط callbackهای XFA و xfa_disabled را gate می‌کند، هرگز نسخه را
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

RuntimeReady کجا هنوز جا دارد: callbackها و xfa_disabled

RuntimeReady کارش را به‌عنوان gate برای رفتار 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 درست بوده، و فقط یک بازگشت درست 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;

دو جزئیات در آن بلوک موقع نوشتن binding خودت راحت غلط می‌شوند. FXfaPageCountOverride پیش از هر کار دیگری به‌عنوان sentinel روی -1 ریست می‌شود، پس PageCount تا وقتی FFI_PageEvent یک صفحه‌بندی دوباره را گزارش کند به درخت صفحهٔ ایستا برمی‌گردد؛ یک صفر آن‌جا بی‌صدا ادعا می‌کرد که سند خالی است. و هر یک از callbackهای نسخهٔ 2 یک روتین ایستای cdecl است که TPdf مالک را از رکورد بازیابی می‌کند و هر استثنای Pascal را پیش از بازگشت به PDFium می‌بلعد، که همان نظمی است که یادداشت ما دربارهٔ مقاوم‌سازی ABI مربوط به PDFium در Delphi برای FFI_OpenFile بازش کرده. هیچ‌چیز در تغییر نسخه هیچ‌کدام از این دو قاعده را شل نمی‌کند

آیا نسخهٔ 2 وقتی DLL ماژول XFA ندارد امن است؟

بله، و دلیلش در خود رکورد است نه در یک وعده از طرف کتابخانه. روی یک build غیر-XFA هدر می‌گوید نسخهٔ 2 باعث می‌شود callbackهای آزمایشی هم صدا زده شوند، پس سؤال این است که PDFium وقتی نگاه می‌کند چه می‌یابد. TPdfFormFillInfo یک رکورد packed است که عضو Infoاش همان FPDF_FORMFILLINFO کامل با هر فیلد نسخهٔ 2 است، و InitializeFormFill پیش از دست زدن به یک بایت کل آن را با FillChar پاک می‌کند. پس روی یک pdfium.dll ساده با یک سند ساده، کتابخانه نسخهٔ 2 و xfa_disabled ست و NULL در هر شیار آزمایشی را می‌بیند، که دقیقاً همان حالتی است که هدر برای hostی که XFA را پیاده نکرده تجویز می‌کند. هیچ رکورد بریده‌ای نیست تا کتابخانه از آن رد شود بخواند، چون رکورد از اول هرگز کوتاه‌تر از نسخهٔ 2 نبود. منطق قدیمی داشت از یک ناسازگاری چیدمان دفاع می‌کرد که اعلان Pascal از قبل حذفش کرده بود

مرزی که ارزش دارد صادقانه گفته شود همانی است که رکورد نمی‌تواند پوشش بدهد. نسخهٔ 2 روی یک سند ساده JavaScript یا scripting مربوط به XFA یا هیچ‌کدام از رویدادهای hostی که پشت آن callbackها هستند را روشن نمی‌کند. m_pJsPlatform فقط وقتی وصل می‌شود که V8FeaturesAvailable درست باشد، XFA غیرفعال می‌ماند مگر این‌که RuntimeReady درست بوده باشد، و TPdf.XFA همچنان نوع فرم را از FPDF_GetFormType گزارش می‌کند مستقل از این‌که محیط سر چه چیزی چانه زده باشد. hostی که می‌خواهد بداند آیا XFA دینامیک واقعاً رندر می‌شود باید بعد از درست شدن Active همچنان XfaRuntimeAvailable را بخواند، همان‌طور که یادداشت ما دربارهٔ تشخیص فرم‌های 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;

نسخهٔ پروتکل و در دسترس بودن قابلیت دو محور متفاوت‌اند

قاعدهٔ کلی‌ای که از این fix بیرون می‌افتد این است که یک فیلد نسخه در یک ساختار callback به این سؤال جواب می‌دهد که «این رکورد چقدر بزرگ است و اجازه داری چه چیزی از آن بخوانی»، در حالی که تشخیص قابلیت به این جواب می‌دهد که «کدام یک از آن شیارها کار مفیدی می‌کنند». اولی با باینری بومی و با اعلان Pascal که در برابر آن کامپایل کرده‌ای ثابت می‌شود. دومی به ازای هر سند، هر جدول export در DLL، و هر پیکربندی host فرق می‌کند. فروکاستن این دو به یک boolean وسوسه‌کننده است چون مورد XFA تصادفاً به هر دو نیاز دارد، ولی همان لحظه که یک build حداقل نسخه را الزامی کند این فروکاستن برای هر سندی که به آن قابلیت نیاز ندارد می‌شکند. فرم‌های XFA، که ISO 32000-1 §12.7.8 آن‌ها را به‌عنوان یک payload XML توصیف می‌کند که کنار دیکشنری AcroForm زندگی می‌کند، همان قابلیت این‌جا هستند؛ چیدمان رکورد همان پروتکل است، و PDFium حق دارد پیش از آن‌که اصلاً به فایل نگاه کند روی چیدمان پافشاری کند. همین شکل هر جایی سر می‌زند که یک کتابخانهٔ C ساختارهایش را نسخه‌دار می‌کند: یک بلوک viewer-info، یک رکورد render-options، یک جدول callback پلتفرم. الگوی امن همانی است که InitializeFormFill اصلاح‌شده دنبال می‌کند. جدیدترین چیدمانی که می‌فهمی را اعلان کن، کاملاً پاکش کن، نسخه را بی‌شرط با آن چیدمان هماهنگ ست کن، و بعد بگذار بررسی‌های قابلیت تصمیم بگیرند کدام شیارها پر شوند. اگر هدر PDFium در آینده یک نسخهٔ 3 اضافه کند، تغییر در اعلان و در همان یک انتساب است، نه در یک شاخهٔ وابسته به سند که برای هر ترکیبی که کسی تست نکرده غلط خواهد بود

نمودار کامپوننت PDFium که دو محور پشت FPDF_FORMFILLINFO را جدا می‌کند: نسخهٔ پروتکل که با چیدمان رکورد و باینری بومی ثابت می‌شود، و در دسترس بودن قابلیت که در آن RuntimeReady فیلد xfa_disabled و هفده شیار نسخهٔ 2 و FPDF_LoadXFA و m_pJsPlatform را به ازای هر سند و هر host gate می‌کند
یک فیلد نسخه حافظه‌ای را توصیف می‌کند که طرف مقابل اجازه دارد بخواند، بررسی‌های قابلیت تعیین می‌کنند کدام شیارها کار مفیدی می‌کنند، و فروکاستن این دو به یک boolean آن buildی را می‌شکند که حداقل نسخه را الزامی می‌کند

راه‌اندازی اصلاح‌شدهٔ form-fill در کامپوننت PDFium برای Delphi و Lazarus و C++Builder منتشر شده، و روی Win32 و Win64 یکسان اعمال می‌شود چون هر دو بیلد همان اعلان رکورد را شریک‌اند. اگر برنامهٔ تو از قبل برای AcroFormهای JavaScript-محور pdfium.v8.dll را انتخاب می‌کند، این همان تغییری است که به آن اجازه می‌دهد بقیهٔ آرشیو PDFت را از همان باینری باز کند بدون این‌که محیط فرم را استثنا کند