บทความเทคนิค

FPDF_FORMFILLINFO เวอร์ชัน 2 ใน Delphi: ทำตาม ABI ของ DLL

ตอนนี้ PDFium Component ตั้ง FPDF_FORMFILLINFO.version เป็น 2 ให้ทุก form-fill environment ที่มัน initialize เพราะเวอร์ชันที่บิลด์ PDFium แบบเนทีฟยอมรับเป็นคุณสมบัติของบิลด์นั้น ไม่ใช่ของคุณสมบัติของเอกสารที่กำลังเปิด pdfium.v8.dll ที่เปิด XFA ปฏิเสธเวอร์ชัน 1 ตรง ๆ PDF ที่เป็น AcroForm ธรรมดาที่เปิดผ่านมันจึงเคยล้มเหลวใน FPDFDOC_InitFormFillEnvironment ทั้งที่ไม่มี XFA อยู่ตรงไหนเลย การแก้ใน v3.116.0 นั้นเล็ก แต่ความผิดพลาดที่อยู่ข้างหลังมันเป็นเรื่องทั่วไปและคุ้มที่จะตั้งชื่อให้: field ของเวอร์ชันโปรโตคอลบรรยาย layout หน่วยความจำที่อีกฝั่งคาดหวัง และมันต้องไม่ถูกอนุมานจากการที่คุณบังเอิญต้องการฟีเจอร์ที่ layout นั้นพกมา

ทำไม FPDFDOC_InitFormFillEnvironment ถึงล้มเหลวบน PDF ธรรมดาเมื่อใช้ pdfium.v8.dll

environment ล้มเหลวเพราะบิลด์ PDFium ที่เปิด XFA จะตรวจ field version ก่อนทำอะไรอื่น และตรรกะของ wrapper ตัวเก่าก็ส่งค่า 1 ให้ทุกครั้งที่เอกสารปัจจุบันไม่ใช่ฟอร์ม XFA อาการในโฮสต์ Delphi คือ EPdfError ที่ถูก raise จาก TPdf.InitializeFormFill พร้อมข้อความ Cannot initialize form fill environment โดยโยนขึ้นตอนเปิดใบแจ้งหนี้หรือแบบฟอร์มภาษีธรรมดาที่มีแต่ text field ของ AcroForm ไฟล์เดียวกันเปิดกับ pdfium.dll ธรรมดาได้ไม่มีปัญหา DLL ตัวเดียวกันก็เปิดเอกสาร XFA จริงได้ไม่มีปัญหา มีแค่การรวมกันของบิลด์ V8 กับเอกสารที่ไม่ใช่ XFA เท่านั้นที่พัง ซึ่งก็คือการรวมกันที่โฮสต์ไปลงเอยพอดีหลังจากเปิด EnableV8Engine เพื่อเอา JavaScript ของ AcroForm หรือหลังจากที่การเลือกอัตโนมัติใน LoadDocument ผูกโปรเซสเข้ากับ pdfium.v8.dll ไปแล้วเพราะไฟล์ XFA ก่อนหน้า การผูกนั้นเป็นระดับโปรเซส: EnableV8Engine ถูกอ่านก่อน LoadLibrary ครั้งแรก และเมื่อบิลด์ XFA ถูกโหลดแล้ว ทุก PDF ธรรมดาที่ตามมาก็ผ่านการตั้งค่า environment ชุดเดียวกันกับไบนารีตัวเดียวกัน โฮสต์ไม่ได้ทำอะไรผิด; wrapper ต่างหากที่ถามคำถามผิดตอนกรอก record ถ้าคุณยังตัดสินใจอยู่เลยว่าจะส่งไบนารีตัวไหนออกไป บันทึกของเราเรื่องการ deploy PDFium DLL และการวินิจฉัยความล้มเหลวในการโหลดครอบคลุมการเลือกระหว่างตัวธรรมดากับ V8 และบทความนี้สมมติว่าบิลด์ V8 อยู่ในโปรเซสแล้ว

แผนภาพของ PDFium Component ที่แสดงการรวมกันสี่แบบของ pdfium.dll ธรรมดากับ pdfium.v8.dll ที่เปิด XFA เมื่อเจอกับเอกสาร AcroForm และ XFA: record เวอร์ชัน 1 พังเฉพาะบิลด์ V8 กับฟอร์มธรรมดา โดยเกิด EPdfError ใน FPDFDOC_InitFormFillEnvironment ขณะที่ record เวอร์ชัน 2 ที่แก้แล้วเปิดได้ทั้งสี่แบบ
เงื่อนไขเดียวผูกเวอร์ชัน ABI เข้ากับเอกสาร การเลือกไบนารี V8 ในระดับโปรเซสจึงเปลี่ยนทุก PDF ธรรมดาที่ตามมาให้กลายเป็นการ initialize environment ที่ล้มเหลว

field version ใน FPDF_FORMFILLINFO สัญญาอะไรจริง ๆ

FPDF_FORMFILLINFO.version บอก PDFium ว่ามันได้รับอนุญาตให้อ่าน field ไหนของ record และ header สาธารณะ fpdf_formfill.h ผูกค่าที่ยอมรับได้เข้ากับวิธีการ compile ไลบรารี ไม่ใช่เข้ากับเอกสาร ถ้าถอดความ สัญญานี้มีสามส่วน เวอร์ชัน 1 ครอบ callback ที่เสถียรตั้งแต่ FFI_Invalidate ถึง FFI_DoGoToAction บวก pointer m_pJsPlatform บิลด์ที่ไม่มีโมดูล XFA รับได้ทั้ง 1 และ 2 และถ้าเป็น 2 มันจะเรียก callback ทดลองเพิ่มเติมด้วย บิลด์ที่มีโมดูล XFA ต้องเป็น 2 เท่านั้น จบ และ header ก็พูดย้ำข้อกำหนดนั้นถึงสองครั้งราวกับคาดว่าคนจะพลาด แล้วไม่มีที่ไหนในสัญญานี้ที่พูดถึงเอกสารเลย เวอร์ชันคือคำแถลงเกี่ยวกับ record ที่คุณจัดสรร: ถ้าเป็น 2 คุณกำลังสัญญาว่าหน่วยความจำหลัง m_pJsPlatform มีอยู่และบรรจุ function pointer ที่ใช้ได้หรือไม่ก็ NULL

โซนของเวอร์ชัน 2 คือที่ที่เครื่องจักร XFA ทั้งหมดอาศัยอยู่ มันเริ่มด้วย xfa_disabled ซึ่งเป็น FPDF_BOOL ที่ header บรรยายว่าถูกเพิกเฉยเมื่อต่ำกว่าเวอร์ชัน 2 และมีความหมายเฉพาะเมื่อ compile โมดูล XFA เข้ามาด้วย แล้วต่อด้วย function pointer สิบเจ็ดตัว ตั้งแต่ FFI_DisplayCaret ถึง FFI_DoURIActionWithKeyboardModifier แต่ละตัวมีเอกสารกำกับว่าจำเป็นสำหรับ XFA และถ้าไม่ใช่อย่างนั้นให้ตั้งเป็น NULL คำพูดนั้นคือกุญแจของการแก้ทั้งหมด NULL ไม่ใช่สถานะ error สำหรับช่องเหล่านั้น แต่มันคือสถานะที่มีเอกสารระบุไว้สำหรับโฮสต์ที่ไม่ได้ขับ XFA record ที่ถูกล้างด้วย FillChar แล้วทำเครื่องหมายว่าเป็นเวอร์ชัน 2 ก็ทำตามสัญญาบนบิลด์ที่ไม่ใช่ XFA ได้ดีเท่ากับ record เวอร์ชัน 1 เป๊ะ และมันเป็น record เดียวที่บิลด์ XFA จะยอมรับ

แผนภาพของ PDFium Component ที่แสดง record FPDF_FORMFILLINFO ใน Delphi: เวอร์ชัน 1 ครอบ callback ตั้งแต่ FFI_Invalidate ถึง FFI_DoGoToAction บวก m_pJsPlatform, เวอร์ชัน 2 เพิ่ม xfa_disabled กับ pointer ยุค FFI_DisplayCaret อีกสิบเจ็ดตัว, FillChar ล้างทุก byte และช่องที่เป็น NULL คือสถานะที่มีเอกสารระบุสำหรับโฮสต์ที่ไม่ได้ขับ XFA
record ใน Pascal เป็น layout เวอร์ชัน 2 แบบเต็มเสมอ บิลด์ที่เปิด XFA จึงยอมรับมัน และบิลด์ธรรมดาก็แค่ไม่เคยเรียกช่องทดลองที่ยังเป็น NULL

การเลือกแบบเก่าผูก ABI เข้ากับเอกสาร

ข้อบกพร่องคือเงื่อนไขเดียวที่ดูสมเหตุสมผลเมื่อมองเดี่ยว ๆ TPdf.InitializeFormFill คำนวณ flag RuntimeReady จากข้อเท็จจริงสามข้อ: เอกสารรายงานชนิดฟอร์ม XFA ผ่าน TPdf.XFA, helper สตริงของ XFA resolve ได้ผ่าน XfaFeaturesAvailable และ export ของ V8 resolve ได้ผ่าน V8FeaturesAvailable ก่อน v3.116.0 flag เดียวกันนั้นยังเลือกเวอร์ชันด้วย

// 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 เป็นเท็จสำหรับเอกสาร AcroForm ธรรมดาทุกฉบับ เอกสารธรรมดาทุกฉบับจึงประกาศเวอร์ชัน 1 บน pdfium.dll นั่นไม่เป็นไร บน pdfium.v8.dll ซึ่งเป็นบิลด์ที่เปิด XFA PDFium ตรวจ field นั้น เจอว่าต่ำกว่า 2 ที่ต้องการ แล้วคืน FPDF_FORMHANDLE เป็น null ซึ่ง CheckPdf ก็เปลี่ยนเป็น exception ข้างต้น เจตนาของโค้ดเก่าคือป้องกันตัว: ให้คงเวอร์ชัน 1 ไว้เพื่อที่บิลด์ XFA จะไม่ไปอ่านช่องเวอร์ชัน 2 ที่ยังไม่ได้กำหนด มันป้องกันปัญหาที่ header ตัดออกไปอยู่แล้ว และสร้างปัญหาที่ header เตือนไว้อย่างชัดเจนขึ้นมาแทน โค้ดที่แก้แล้วตัดสินเวอร์ชันครั้งเดียวตั้งแต่ต้น จากสิ่งที่ record เป็นอยู่จริง ๆ

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinel: ใช้ static page tree
  if not FormFill then
    Exit;

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

  // record เวอร์ชัน 2 แบบเต็มถูกจัดสรรและล้างไว้ข้างบนแล้ว PDFium
  // รับเวอร์ชัน 2 ได้โดยไม่มี XFA และบังคับให้ใช้ในทุกบิลด์ที่เปิด
  // XFA รวมถึงเมื่อเอกสารนี้ไม่มีฟอร์ม XFA
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady กันเฉพาะ callback ของ XFA กับ xfa_disabled ไม่เคยกันเวอร์ชัน
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

RuntimeReady ยังมีที่อยู่ตรงไหน: callback และ xfa_disabled

RuntimeReady ยังคงทำหน้าที่เป็นประตูสำหรับพฤติกรรม XFA; แค่มันไม่แตะ layout ของ record อีกต่อไป callback เวอร์ชัน 1 ได้แก่ FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction และที่เหลือในบล็อกนั้น ถูกต่อไว้โดยไม่มีเงื่อนไขเพราะทั้ง AcroForm และ XFA ต่างพึ่งมัน pointer เวอร์ชัน 2 สิบเจ็ดตัวถูก assign เฉพาะภายในสาขา RuntimeReady พร้อมกับ xfa_disabled := 0 เมื่อเอกสารเป็น XFA แต่ runtime ไม่มี record ก็ยังอยู่ที่เวอร์ชัน 2 โดย xfa_disabled เป็น 1 และช่องเวอร์ชัน 2 ปล่อยเป็น NULL และ wrapper ก็ raise OnXfaRuntimeMissing เพื่อให้โฮสต์แนะนำให้รีสตาร์ทบน pdfium.v8.dll ได้ หลังจาก environment มีอยู่แล้ว 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;

มีสองรายละเอียดในบล็อกนั้นที่พลาดได้ง่ายเวลาคุณเขียน binding เอง FXfaPageCountOverride ถูกตั้งกลับเป็น -1 เป็น sentinel ก่อนอะไรอื่นจะเกิดขึ้น PageCount จึง fallback ไปที่ static page tree จนกว่า FFI_PageEvent จะรายงานการแบ่งหน้าใหม่; ค่าเป็นศูนย์ตรงนั้นจะอ้างอย่างเงียบ ๆ ว่าเอกสารว่างเปล่า และ callback เวอร์ชัน 2 แต่ละตัวเป็นรูทีน cdecl แบบ static ที่ดึง TPdf เจ้าของกลับมาจาก record แล้วกลืน exception ของ Pascal ทุกตัวก่อนคืนค่าให้ PDFium ซึ่งเป็นวินัยที่บันทึกของเราเรื่องการเสริมความแข็งแกร่งให้ ABI ของ PDFium ใน Delphiระบุไว้อย่างชัดเจนสำหรับ FFI_OpenFile ไม่มีอะไรในเรื่องการเปลี่ยนเวอร์ชันที่ผ่อนกฎสองข้อนั้น

เวอร์ชัน 2 ปลอดภัยไหมเมื่อ DLL ไม่มีโมดูล XFA

ปลอดภัย และเหตุผลอยู่ใน record ไม่ได้อยู่ในคำสัญญาจากไลบรารี บนบิลด์ที่ไม่ใช่ XFA header บอกว่าเวอร์ชัน 2 จะทำให้ callback ทดลองถูกเรียกด้วย คำถามจึงกลายเป็นว่า PDFium เจออะไรเมื่อมันมอง TPdfFormFillInfo เป็น packed record ที่สมาชิก Info เป็น FPDF_FORMFILLINFO ครบถ้วนรวมทุก field ของเวอร์ชัน 2 และ InitializeFormFill ล้างทั้งก้อนด้วย FillChar ก่อนแตะ byte แรก ดังนั้นบน pdfium.dll ธรรมดากับเอกสารธรรมดา ไลบรารีจะเห็นเวอร์ชัน 2, xfa_disabled ถูกตั้ง และ NULL ในทุกช่องทดลอง ซึ่งก็คือสถานะที่ header กำหนดไว้เป๊ะ ๆ สำหรับโฮสต์ที่ไม่ได้ implement XFA ไม่มี record ที่ถูกตัดขาดให้ไลบรารีอ่านเลยขอบ เพราะ record ไม่เคยสั้นกว่าเวอร์ชัน 2 มาตั้งแต่แรก ตรรกะเก่ากำลังป้องกัน layout ที่ไม่ตรงกันซึ่งการประกาศใน Pascal ตัดมันออกไปแล้ว

ขอบเขตที่คุ้มจะพูดตรง ๆ คือขอบเขตที่ record ครอบไม่ได้ เวอร์ชัน 2 บนเอกสารธรรมดาไม่ได้เปิด JavaScript, XFA scripting หรือ event ใด ๆ ของโฮสต์ที่อยู่หลัง callback เหล่านั้น m_pJsPlatform ถูกต่อเข้าเฉพาะเมื่อ V8FeaturesAvailable เป็นจริง XFA ยังปิดอยู่เว้นแต่ RuntimeReady เป็นจริง และ TPdf.XFA ก็ยังรายงานชนิดฟอร์มจาก FPDF_GetFormType ต่อไปไม่ว่า environment จะเจรจาอะไรไว้ โฮสต์ที่อยากรู้ว่า dynamic XFA จะ render จริงไหมควรอ่าน XfaRuntimeAvailable ต่อไปหลังจาก Active กลายเป็นจริง อย่างที่บันทึกของเราเรื่องการตรวจจับฟอร์ม XFA และการดึง XFA packetแนะนำ แทนที่จะอนุมานอะไรจาก field เวอร์ชัน

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // ถูกเรียกจาก InitializeFormFill เมื่อเอกสารเป็น XFA แต่ pdfium.dll
  // ที่โหลดมาขับเคลื่อนเอนจินไม่ได้ form environment ก็ยังเปิดได้
  // เพราะส่งเวอร์ชัน 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;   // ไม่โยน exception อีกต่อไปบน PDF ธรรมดาใต้ pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

เวอร์ชันโปรโตคอลกับความพร้อมของฟีเจอร์เป็นสองแกนที่ต่างกัน

กฎทั่วไปที่ตกออกมาจากการแก้นี้คือ field เวอร์ชันในโครงสร้าง callback ตอบคำถามว่า "record นี้ใหญ่แค่ไหนและคุณอ่านอะไรจากมันได้" ขณะที่การตรวจจับฟีเจอร์ตอบว่า "ช่องไหนในนั้นจะทำอะไรที่มีประโยชน์" อย่างแรกถูกตรึงโดยไบนารีเนทีฟและโดยการประกาศใน Pascal ที่คุณ compile ด้วย อย่างที่สองแปรผันตามเอกสาร ตามตาราง export ของ DLL และตามการตั้งค่าโฮสต์ การยุบสองอย่างนี้เป็นบูลีนตัวเดียวนั้นน่าดึงดูดเพราะเคส XFA บังเอิญต้องใช้ทั้งคู่ แต่ทันทีที่บิลด์บังคับเวอร์ชันขั้นต่ำ การยุบนั้นก็พังสำหรับทุกเอกสารที่ไม่ต้องการฟีเจอร์นั้น ฟอร์ม XFA ซึ่ง ISO 32000-1 §12.7.8 บรรยายว่าเป็น XML payload ที่อยู่เคียงกับดิกชันนารี AcroForm คือฟีเจอร์ในที่นี้; layout ของ record คือโปรโตคอล และ PDFium มีสิทธิ์ยืนกรานเรื่อง layout ก่อนที่มันจะได้มองไฟล์ด้วยซ้ำ รูปร่างเดียวกันนี้โผล่ที่ไหนก็ตามที่ไลบรารี C ทำเวอร์ชันให้โครงสร้างของตัวเอง: บล็อก viewer-info, record render-options, ตาราง callback ของแพลตฟอร์ม แพตเทิร์นที่ปลอดภัยคือแบบที่ InitializeFormFill ที่แก้แล้วทำตาม ประกาศ layout ใหม่สุดที่คุณเข้าใจ ล้างมันให้หมด ตั้งเวอร์ชันให้ตรงกับ layout นั้นโดยไม่มีเงื่อนไข แล้วปล่อยให้การตรวจ capability เป็นตัวตัดสินว่าจะเติมช่องไหน ถ้า header ของ PDFium ในอนาคตเพิ่มเวอร์ชัน 3 การแก้ก็คือที่การประกาศกับที่การ assign อันเดียวนั้น ไม่ใช่ที่สาขาที่ขึ้นกับเอกสารซึ่งจะผิดสำหรับการรวมกันที่ไม่มีใครได้ทดสอบ

แผนภาพของ PDFium Component ที่แยกสองแกนเบื้องหลัง FPDF_FORMFILLINFO: เวอร์ชันโปรโตคอลที่ถูกตรึงโดย layout ของ record และไบนารีเนทีฟ กับความพร้อมของฟีเจอร์ที่ RuntimeReady เป็นประตูให้ xfa_disabled, ช่องเวอร์ชัน 2 สิบเจ็ดช่อง, FPDF_LoadXFA และ m_pJsPlatform เป็นรายเอกสารและรายโฮสต์
field เวอร์ชันบรรยายหน่วยความจำที่อีกฝั่งอ่านได้ การตรวจ capability เป็นตัวตัดสินว่าช่องไหนทำอะไรที่มีประโยชน์ และการยุบสองอย่างเป็นบูลีนตัวเดียวจะพังบิลด์ที่บังคับเวอร์ชันขั้นต่ำ

การ initialize form fill ที่แก้แล้วอยู่ในPDFium Component สำหรับ Delphi, Lazarus และ C++Builder และมันใช้ได้ทั้งบน Win32 และ Win64 เพราะทั้งสองบิลด์แชร์การประกาศ record ชุดเดียวกัน ถ้าแอปพลิเคชันของคุณเลือก pdfium.v8.dll อยู่แล้วสำหรับ AcroForm ที่ขับด้วย JavaScript นี่คือการเปลี่ยนแปลงที่ทำให้มันเปิดส่วนที่เหลือของคลัง PDF ผ่านไบนารีตัวเดียวกันได้โดยไม่ต้องทำกรณีพิเศษให้ form environment