מאמר טכני

FPDF_FORMFILLINFO גרסה 2 בדלפי: ללכת לפי ה-ABI של ה-DLL

‏רכיב PDFium קובע כעת את FPDF_FORMFILLINFO.version ל-2 עבור כל סביבת מילוי טפסים שהוא מאתחל, כי הגרסה שבנייה ילידית של PDFium מקבלת היא תכונה של אותה בנייה, לא של המסמך שנפתח. pdfium.v8.dll עם XFA דוחה גרסה 1 outright, כך ש-PDF רגיל עם AcroForm שנפתח דרכו נהג להיכשל ב-FPDFDOC_InitFormFillEnvironment בלי שום XFA באופק. התיקון ב-v3.116.0 קטן, אבל הטעות שמאחוריו כללית וראויה לשם: שדה גרסה של פרוטוקול מתאר את פריסת הזיכרון שהצד השני מצפה לה, ואסור לגזור אותו מהשאלה אם במקרה אתם צריכים את היכולות שפריסה זו נושאת

למה FPDFDOC_InitFormFillEnvironment נכשל על PDF רגיל עם pdfium.v8.dll?

הסביבה נכשלת כי בנייה של PDFium עם XFA מאמתת את שדה ה-version לפני שהיא עושה כל דבר אחר, והלוגיקה הישנה של העטיפה העבירה לה 1 בכל פעם שהמסמך הנוכחי לא היה טופס XFA. הסימפטום במארח דלפי הוא 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 של ארבעת השילובים של pdfium.dll הרגיל ושל pdfium.v8.dll עם XFA מול מסמכי AcroForm ו-XFA: רשומת גרסה 1 שברה רק את בניית ה-V8 עם טופס רגיל, EPdfError ב-FPDFDOC_InitFormFillEnvironment, בעוד שרשומת גרסה 2 המתוקנת פותחת את כל הארבעה
תנאי אחד קשר את גרסת ה-ABI למסמך, כך שהבחירה ברמת התהליך בקובץ של ה-V8 הפכה כל PDF רגיל בהמשך לאתחול סביבה כושל

מה שדה הגרסה ב-FPDF_FORMFILLINFO באמת מבטיח?

FPDF_FORMFILLINFO.version אומר ל-PDFium אילו שדות של הרשומה מותר לו לקרוא, והכותרת הציבורית fpdf_formfill.h קושרת את הערכים המקובלים לאיך שהספרייה קומפלה ולא למסמך. בניסוח חופשי, לחוזה יש שלושה חלקים. גרסה 1 מכסה את ה-callbacks היציבים מ-FFI_Invalidate עד FFI_DoGoToAction ועוד המצביע m_pJsPlatform. בנייה בלי מודול XFA מקבלת גם 1 וגם 2, ועם 2 היא גם תקרא ל-callbacks הניסיוניים הנוספים. בנייה עם מודול 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 של רשומת FPDF_FORMFILLINFO בדלפי: גרסה 1 מכסה את ה-callbacks מ-FFI_Invalidate עד FFI_DoGoToAction ועוד m_pJsPlatform, גרסה 2 מוסיפה את xfa_disabled ושבעה עשר מצביעים מתקופת FFI_DisplayCaret, FillChar מנקה כל בית, וחריצי NULL הם המצב המתועד למארח שלא מפעיל XFA
רשומת הפסקל היא תמיד הפריסה המלאה של גרסה 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 משערת את ה-callbacks של XFA ואת xfa_disabled, אף פעם לא את הגרסה.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

איפה RuntimeReady עדיין שייכת: ה-callbacks ו-xfa_disabled

RuntimeReady שומרת על תפקידה כשער להתנהגות XFA; היא פשוט כבר לא נוגעת בפריסת הרשומה. ה-callbacks של גרסה 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 מדווח על עימוד מחדש; אפס שם היה מצהיר בשקט על מסמך ריק. וכל אחד מה-callbacks של גרסה 2 הוא שגרה סטטית מסוג cdecl שמשחזרת את ה-TPdf הבעלים מהרשומה ובולעת כל חריגת פסקל לפני החזרה ל-PDFium, וזו המשמעת שהרשומה שלנו על חיסון ה-ABI של PDFium בדלפי מפרטת עבור FFI_OpenFile. שום דבר בשינוי הגרסה לא מרפה מאף אחד מהשניים

האם גרסה 2 בטוחה כשל-DLL אין מודול XFA?

כן, והסיבה נמצאת ברשומה, לא בהבטחה מהספרייה. בבנייה בלי XFA הכותרת אומרת שגרסה 2 גורמת לקרוא גם ל-callbacks הניסיוניים, אז השאלה היא מה PDFium מוצא כשהוא מסתכל. TPdfFormFillInfo היא רשומה ארוזה שחבר ה-Info שלה הוא FPDF_FORMFILLINFO המלא כולל כל שדה של גרסה 2, ו-InitializeFormFill מנקה את כולה ב-FillChar לפני שהיא נוגעת בבית. כך שב-pdfium.dll רגיל עם מסמך רגיל הספרייה רואה גרסה 2, xfa_disabled דלוק, ו-NULL בכל חריץ ניסיוני, וזה בדיוק המצב שהכותרת קובעת למארח שלא מממש XFA. אין רשומה קטועה שהספרייה תקרוא מעבר לה, כי הרשומה אף פעם לא הייתה קצרה מגרסה 2 מלכתחילה. הלוגיקה הישנה הגנה מפני אי-התאמת פריסה שהצהרת הפסקל כבר ביטלה

הגבול שראוי להצהיר עליו בכנות הוא זה שהרשומה לא יכולה לכסות. גרסה 2 על מסמך רגיל לא מדליקה JavaScript, תסריטי XFA, או איזה מאירועי המארח שמאחורי ה-callbacks האלה. 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 עונה על השאלה "כמה גדולה הרשומה הזו ומה מותר לך לקרוא ממנה", בעוד זיהוי יכולות עונה על "אילו מהחריצים האלה יעשו משהו מועיל". הראשון קבוע על ידי הקובץ הבינארי הילידי ועל ידי הצהרת הפסקל שמולה קומפלתם. השני משתנה לפי מסמך, לפי טבלת הייצוא של ה-DLL ולפי תצורת המארח. קיפולם של השניים לדגל בוליאני אחד מפתה כי במקרה של XFA צריך את שניהם, אבל ברגע שבנייה אוכפת גרסה מינימלית הקיפול נשבר עבור כל מסמך שלא צריך את היכולת. טפסי XFA, המתוארים ב-ISO 32000-1 §12.7.8 כמטען XML שחי לצד מילון ה-AcroForm, הם היכולת כאן; פריסת הרשומה היא הפרוטוקול, ו-PDFium רשאי להתעקש על הפריסה לפני שהוא מסתכל על הקובץ בכלל. אותה צורה מופיעה בכל מקום שבו ספריית C מנהלת גרסאות של המבנים שלה: בלוק מידע למציג, רשומת אפשרויות רינדור, טבלת callbacks של פלטפורמה. הדפוס הבטוח הוא זה ש-InitializeFormFill המתוקנת הולכת לפיו. הצהירו על הפריסה החדשה ביותר שאתם מבינים, נקו אותה לגמרי, קבעו את הגרסה כך שתתאים לפריסה הזו בלי תנאי, ואז הניחו לבדיקות היכולת להחליט אילו חריצים למלא. אם כותרת PDFium עתידית תוסיף גרסה 3, השינוי הוא בהצהרה ובהשמה האחת ההיא, לא בענף תלוי-מסמך שיהיה שגוי בכל שילוב שאיש לא בדק

תרשים ברכיב PDFium שמפריד את שני הצירים שמאחורי FPDF_FORMFILLINFO: גרסת הפרוטוקול הקבועה על ידי פריסת הרשומה והקובץ הבינרי הילידי, וזמינות היכולות שבה RuntimeReady משערת את xfa_disabled, שבעה עשר חריצי גרסה 2, FPDF_LoadXFA ו-m_pJsPlatform לפי מסמך ולפי מארח
שדה גרסה מתאר את הזיכרון שהצד השני רשאי לקרוא, בדיקות היכולת מחליטות אילו חריצים עושים משהו מועיל, וקיפול השניים לדגל בוליאני אחד שובר את הבנייה שאוכפת מינימום

אתחול מילוי הטפסים המתוקן מגיע ברכיב PDFium ל-Delphi, Lazarus ו-C++Builder, והוא חל ב-Win32 וב-Win64 כאחד כי שתי הבניות חולקות אותה הצהרת רשומה. אם האפליקציה שלכם כבר בוחרת ב-pdfium.v8.dll בשביל טופסי AcroForm מונעי JavaScript, זה השינוי שמאפשר לה לפתוח את שאר ארכיון ה-PDF שלכם דרך אותו קובץ בינרי בלי לטפל בסביבת הטופס כמקרה מיוחד