Bài viết kỹ thuật

FPDF_FORMFILLINFO bản 2 trong Delphi: theo đúng ABI của DLL

PDFium Component giờ đặt FPDF_FORMFILLINFO.version thành 2 cho mọi form-fill environment mà nó khởi tạo, vì phiên bản mà một bản dựng PDFium native chấp nhận là thuộc tính của chính bản dựng đó, không phải của tài liệu đang được mở. Một pdfium.v8.dll bật XFA từ chối thẳng phiên bản 1, nên một PDF AcroForm thường mở qua nó trước đây fail ngay trong FPDFDOC_InitFormFillEnvironment mà chẳng thấy XFA đâu cả. Bản vá v3.116.0 thì nhỏ, nhưng sai lầm đằng sau nó là một sai lầm phổ quát và đáng gọi tên: một trường phiên bản giao thức mô tả bố cục bộ nhớ mà phía bên kia trông đợi, và nó không bao giờ được suy ra từ việc bạn có tình cờ cần những tính năng mà bố cục đó mang theo hay không

Vì sao FPDFDOC_InitFormFillEnvironment fail trên một PDF thường với pdfium.v8.dll?

Environment fail vì một bản dựng PDFium bật XFA kiểm tra trường version trước khi làm bất cứ việc gì khác, còn logic wrapper cũ lại đưa cho nó số 1 mỗi khi tài liệu hiện tại không phải form XFA. Triệu chứng trong một host Delphi là một EPdfError được ném ra từ TPdf.InitializeFormFill với thông báo Cannot initialize form fill environment, ném ngay khi đang mở một hóa đơn hay tờ khai thuế bình thường chỉ có các text field AcroForm. Cùng tệp đó mở ngon với pdfium.dll thường. Cùng DLL đó mở một tài liệu XFA thật cũng ngon. Chỉ có tổ hợp bản dựng V8 với một tài liệu không phải XFA là hỏng, và đó đúng là tổ hợp mà một host rơi vào sau khi nó bật EnableV8Engine để có JavaScript của AcroForm, hoặc sau khi cơ chế tự chọn trong LoadDocument đã cam kết cả tiến trình dùng pdfium.v8.dll vì một tệp XFA trước đó. Cam kết đó là toàn tiến trình: EnableV8Engine được đọc trước lần LoadLibrary đầu tiên, và một khi bản dựng XFA đã nạp thì mọi PDF thường về sau đều đi qua cùng bước thiết lập environment trên cùng một binary. Host không làm gì sai; wrapper đã hỏi sai câu hỏi khi nó điền vào record. Nếu bạn còn đang cân nhắc nên phát hành binary nào, bài về triển khai PDFium DLL và chẩn đoán lỗi nạp trình bày việc chọn bản thường hay bản V8, còn bài này giả định bản V8 đã nằm trong tiến trình

Sơ đồ PDFium Component về bốn tổ hợp giữa pdfium.dll thường và pdfium.v8.dll bật XFA với tài liệu AcroForm và XFA: một record phiên bản 1 chỉ phá bản dựng V8 khi gặp form thường, gây EPdfError trong FPDFDOC_InitFormFillEnvironment, còn record phiên bản 2 đã sửa mở được cả bốn
Một câu điều kiện đã buộc phiên bản ABI vào tài liệu, nên việc chọn binary V8 ở mức tiến trình biến mọi PDF thường về sau thành một lần khởi tạo environment thất bại

Trường version trong FPDF_FORMFILLINFO thật ra hứa điều gì?

FPDF_FORMFILLINFO.version cho PDFium biết nó được phép đọc những trường nào của record, và header công khai fpdf_formfill.h gắn các giá trị được chấp nhận với cách thư viện được biên dịch chứ không với tài liệu. Nói lại, giao kèo có ba phần. Phiên bản 1 bao gồm các callback ổn định từ FFI_Invalidate tới FFI_DoGoToAction cùng con trỏ m_pJsPlatform. Một bản dựng không có module XFA chấp nhận cả 1 lẫn 2, và với 2 thì nó cũng sẽ gọi thêm các callback thử nghiệm. Một bản dựng có module XFA đòi 2, hết, và header lặp lại yêu cầu đó hai lần như thể nó biết trước người ta sẽ bỏ sót. Không chỗ nào trong giao kèo nhắc tới tài liệu. Phiên bản là một phát biểu về cái record bạn đã cấp phát: với số 2, bạn cam kết rằng vùng bộ nhớ sau m_pJsPlatform tồn tại và chứa hoặc các con trỏ hàm hợp lệ hoặc NULL

Vùng phiên bản 2 là nơi toàn bộ cỗ máy XFA trú ngụ. Nó bắt đầu bằng xfa_disabled, một FPDF_BOOL mà header mô tả là bị bỏ qua dưới phiên bản 2 và chỉ có nghĩa khi module XFA được biên dịch vào, rồi tiếp tục với mười bảy con trỏ hàm, từ FFI_DisplayCaret tới FFI_DoURIActionWithKeyboardModifier. Mỗi cái trong số đó được ghi là bắt buộc với XFA và nếu không thì phải đặt NULL. Cách diễn đạt đó chính là chìa khóa của toàn bộ bản vá. NULL không phải trạng thái lỗi với những slot đó; nó là trạng thái được ghi rõ cho một host không điều khiển XFA. Một record đã được xóa bằng FillChar rồi đánh dấu là phiên bản 2 thỏa giao kèo trên bản dựng không có XFA đúng như một record phiên bản 1, và nó là record duy nhất mà bản dựng XFA chịu nhận

Sơ đồ PDFium Component về record FPDF_FORMFILLINFO trong Delphi: phiên bản 1 gồm các callback từ FFI_Invalidate tới FFI_DoGoToAction cộng m_pJsPlatform, phiên bản 2 thêm xfa_disabled và mười bảy con trỏ thời FFI_DisplayCaret, FillChar xóa mọi byte, và các slot NULL là trạng thái được ghi rõ cho host không điều khiển XFA
Record Pascal luôn là bố cục phiên bản 2 đầy đủ, nên bản dựng bật XFA chấp nhận nó còn bản dựng thường đơn giản là không bao giờ gọi những slot thử nghiệm vẫn để NULL

Cách chọn cũ đã buộc ABI vào tài liệu

Lỗi chỉ là một câu điều kiện trông có vẻ hợp lý khi đứng riêng. TPdf.InitializeFormFill tính một cờ RuntimeReady từ ba dữ kiện: tài liệu báo form type là XFA qua TPdf.XFA, các helper chuỗi XFA phân giải được qua XfaFeaturesAvailable, và các export V8 phân giải được qua V8FeaturesAvailable. Trước v3.116.0, chính cờ đó cũng chọn luôn phiên bản

// v3.115.0 và trước đó: phiên bản ABI đi theo tài liệu
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

if RuntimeReady then
  FFormFillInfo.Info.version := 2
else
  FFormFillInfo.Info.version := 1;

// ... và nhánh thiếu runtime ghim nó lần nữa
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Đọc nó với header trong tay thì lỗi hiện ra rõ ràng. RuntimeReady sai với mọi tài liệu AcroForm thường, nên mọi tài liệu thường đều khai phiên bản 1. Trên pdfium.dll thì không sao. Trên pdfium.v8.dll, tức bản dựng bật XFA, PDFium kiểm tra trường đó, thấy nó dưới mức 2 bắt buộc, và trả về một FPDF_FORMHANDLE null, thứ mà CheckPdf biến thành exception ở trên. Ý định của code cũ là phòng thủ: giữ phiên bản 1 để bản dựng XFA không bao giờ đọc các slot phiên bản 2 chưa được gán. Nó phòng một vấn đề mà header đã loại trừ sẵn, rồi tạo ra một vấn đề mà header cảnh báo tường minh. Code đã sửa quyết định phiên bản một lần, ngay từ đầu, dựa trên việc record thật sự là gì

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinel: dùng cây trang tĩnh
  if not FormFill then
    Exit;

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

  // Record phiên bản 2 đầy đủ được cấp phát và xóa sạch ở trên. PDFium
  // chấp nhận phiên bản 2 khi không có XFA và đòi nó ở mọi bản dựng
  // bật XFA, kể cả khi tài liệu này không chứa form XFA.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady gác các callback XFA và xfa_disabled, không bao giờ gác version.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

RuntimeReady vẫn thuộc về đâu: các callback và xfa_disabled

RuntimeReady giữ nguyên vai trò cổng gác cho hành vi XFA; nó chỉ không còn đụng tới bố cục record nữa. Các callback phiên bản 1, FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction cùng phần còn lại của khối đó, được nối vô điều kiện vì cả AcroForm lẫn XFA đều phụ thuộc vào chúng. Mười bảy con trỏ phiên bản 2 chỉ được gán bên trong nhánh RuntimeReady, cùng với xfa_disabled := 0. Khi tài liệu là XFA nhưng runtime không có mặt, record vẫn ở phiên bản 2 với xfa_disabled bằng 1 và các slot phiên bản 2 để NULL, còn wrapper kích OnXfaRuntimeMissing để host có thể gợi ý khởi động lại trên pdfium.v8.dll. Sau khi environment đã tồn tại, FPDF_LoadXFA chỉ được gọi khi RuntimeReady từng đúng, và chỉ một giá trị trả về true mới đặt FXfaRuntimeUsable, tức thứ mà TPdf.XfaRuntimeAvailable báo cáo

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = bật 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 tới FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime không có: giữ phiên bản 2, để XFA tắt, báo cho 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;

Hai chi tiết trong khối đó dễ làm sai khi bạn tự viết binding. FXfaPageCountOverride được đặt lại về -1 như một sentinel trước khi mọi thứ khác xảy ra, nên PageCount rơi về cây trang tĩnh cho tới khi FFI_PageEvent báo có phân trang lại; một số 0 ở đó sẽ âm thầm khai rằng tài liệu rỗng. Và mỗi callback phiên bản 2 là một routine cdecl tĩnh, nó lấy lại TPdf sở hữu từ record rồi nuốt mọi exception Pascal trước khi trả về cho PDFium, đúng kỷ luật mà bài về gia cố ABI PDFium trong Delphi nói rõ cho FFI_OpenFile. Không có gì trong thay đổi phiên bản này nới lỏng cả hai quy tắc đó

Phiên bản 2 có an toàn khi DLL không có module XFA không?

Có, và lý do nằm trong chính record chứ không nằm ở một lời hứa nào từ thư viện. Trên bản dựng không có XFA, header nói phiên bản 2 khiến các callback thử nghiệm cũng được gọi, nên câu hỏi là PDFium thấy gì khi nó nhìn vào. TPdfFormFillInfo là một packed record có member Info là FPDF_FORMFILLINFO đầy đủ, gồm mọi trường phiên bản 2, và InitializeFormFill xóa sạch nó bằng FillChar trước khi chạm vào một byte nào. Nên trên một pdfium.dll thường với một tài liệu thường, thư viện thấy phiên bản 2, xfa_disabled được đặt, và NULL ở mọi slot thử nghiệm, đúng cái trạng thái mà header quy định cho một host không cài đặt XFA. Không có record bị cắt ngắn nào để thư viện đọc lố, vì record vốn chưa bao giờ ngắn hơn phiên bản 2. Logic cũ đang phòng một sự lệch bố cục mà chính khai báo Pascal đã loại bỏ từ trước

Ranh giới đáng nói thật thà là ranh giới mà record không thể che. Phiên bản 2 trên một tài liệu thường không bật JavaScript, không bật scripting XFA, cũng không bật bất kỳ host event nào đứng sau các callback đó. m_pJsPlatform chỉ được gắn khi V8FeaturesAvailable đúng, XFA vẫn tắt trừ khi RuntimeReady từng đúng, và TPdf.XFA vẫn báo form type từ FPDF_GetFormType bất kể environment đã thương lượng được gì. Một host muốn biết XFA động có thật sự render hay không thì nên tiếp tục đọc XfaRuntimeAvailable sau khi Active thành true, như bài về phát hiện form XFA và trích xuất gói XFA khuyến nghị, thay vì suy diễn bất cứ điều gì từ trường version

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Được kích từ InitializeFormFill khi tài liệu là XFA nhưng
  // pdfium.dll đã nạp không chạy được engine. Form environment vẫn mở,
  // vì dù sao phiên bản 2 cũng đã được truyền; chỉ runtime XFA là tắt.
  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;   // không còn ném lỗi với PDF thường dưới pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Phiên bản giao thức và khả năng tính năng là hai trục khác nhau

Quy tắc chung rút ra từ bản vá này là một trường phiên bản trong cấu trúc callback trả lời câu hỏi “record này lớn bao nhiêu và bạn được đọc gì từ nó”, còn phát hiện khả năng tính năng trả lời câu hỏi “slot nào trong đó sẽ làm được việc gì có ích”. Cái thứ nhất do binary native và do khai báo Pascal mà bạn biên dịch cùng quyết định. Cái thứ hai thay đổi theo từng tài liệu, theo từng bảng export của DLL và theo từng cấu hình host. Gộp hai thứ vào một boolean thì rất cám dỗ vì ca XFA tình cờ cần cả hai, nhưng ngay khi một bản dựng cưỡng chế phiên bản tối thiểu thì việc gộp đó vỡ với mọi tài liệu không cần tính năng ấy. Form XFA, được ISO 32000-1 §12.7.8 mô tả là một payload XML nằm cạnh dictionary AcroForm, chính là tính năng ở đây; bố cục record là giao thức, và PDFium có quyền khăng khăng đòi đúng bố cục trước khi nó nhìn vào tệp. Hình dạng đó cũng xuất hiện ở mọi nơi một thư viện C đánh phiên bản cho cấu trúc của nó: một khối viewer-info, một record render-options, một bảng callback platform. Mẫu an toàn chính là mẫu mà InitializeFormFill đã sửa tuân theo. Khai báo bố cục mới nhất mà bạn hiểu, xóa sạch nó hoàn toàn, đặt phiên bản khớp với bố cục đó một cách vô điều kiện, rồi để các phép kiểm tra khả năng quyết định slot nào sẽ được điền. Nếu một header PDFium tương lai thêm phiên bản 3, thay đổi nằm ở khai báo và ở đúng một phép gán đó, chứ không nằm ở một nhánh phụ thuộc tài liệu — thứ sẽ sai với bất kỳ tổ hợp nào không ai test

Sơ đồ PDFium Component tách hai trục đằng sau FPDF_FORMFILLINFO: phiên bản giao thức do bố cục record và binary native quyết định, còn khả năng tính năng thì RuntimeReady gác xfa_disabled, mười bảy slot phiên bản 2, FPDF_LoadXFA và m_pJsPlatform theo từng tài liệu và từng host
Một trường phiên bản mô tả vùng bộ nhớ mà phía bên kia được đọc, các phép kiểm tra khả năng quyết định slot nào làm được việc có ích, và gộp hai thứ vào một boolean sẽ phá bản dựng đang cưỡng chế một mức tối thiểu

Phần khởi tạo form-fill đã sửa có trong PDFium Component cho Delphi, Lazarus và C++Builder, và nó áp dụng như nhau trên Win32 lẫn Win64 vì cả hai bản dựng dùng chung một khai báo record. Nếu ứng dụng của bạn đã chọn pdfium.v8.dll cho các AcroForm chạy bằng JavaScript, đây chính là thay đổi giúp nó mở phần còn lại của kho PDF qua cùng một binary mà không phải đối xử đặc biệt với form environment