Bài viết kỹ thuật

Runtime biểu mẫu XFA động trong Delphi: giao dịch HotPDF

HotPDF điền biểu mẫu XFA động trong Delphi thông qua TXFAWidgetRuntime, một tầng widget trung lập với host coi mỗi lần chỉnh sửa trường là một giao dịch: snapshot, validate, calculate, reflow, rồi publish hoặc rollback toàn bộ. Nó chạy đơn luồng bên trong host VCL hoặc FMX của bạn, không cần cài Acrobat, và kiểm mọi giới hạn trước khi cấp phát bất cứ thứ gì

Tình huống này quen thuộc với bất kỳ ai từng đưa phần mềm tài liệu vào khối chính phủ hoặc bảo hiểm. Một tờ khai bồi thường hay tờ khai thuế đến dưới dạng PDF mà nội dung trang chỉ là một thông báo "Please wait... if this message is not eventually replaced", còn mọi trường thật nằm trong packet XFA mà chỉ Adobe Acrobat mới render được. Người dùng của bạn muốn điền nó ngay trong ứng dụng của bạn. Bạn cũng không thể lách bằng cách rasterise, vì biểu mẫu mọc thêm hàng khi dữ liệu được nhập, và bố cục sau hàng thứ ba không còn là bố cục đi kèm tệp ban đầu

Vì sao XFA động vẫn là bài toán đáng giải

XFA động vẫn tồn tại vì các biểu mẫu đã triển khai sống lâu hơn định dạng mang chúng. ISO 32000-1 §12.7.8 mô tả XFA là một entry /XFA trên dictionary AcroForm giữ một stream packet XDP, và ISO 32000-2 đã deprecate toàn bộ cơ chế này; việc deprecate đưa nó ra khỏi lộ trình, không phải ra khỏi thực địa, và các biểu mẫu soạn theo đặc tả XFA 3.3 vẫn được phát hành và vẫn có giá trị pháp lý. XFA tĩnh có thể rút gọn thành các widget annotation thường, và HotPDF làm điều đó khi bạn gọi ApplyXFAAsAcroForm, với các đánh đổi được trình bày trong làm phẳng biểu mẫu XFA thành trường AcroForm. XFA động là một chuyện khác: các dải occur, văn bản có thể lớn dần và script calculate khiến tập trường trở thành hàm của dữ liệu, nên không có danh sách annotation cố định nào để làm phẳng vào cho đến khi người dùng gõ xong. Đó chính là khoảng trống TXFAWidgetRuntime lấp đầy, bằng cách giữ DOM XFA luôn sống, tính lại bố cục sau mỗi lần chỉnh sửa được chấp nhận, và trao cho host một mảng phẳng các widget đã định vị để vẽ và hit-test

Runtime trao gì cho ứng dụng host?

Nó trao cho bạn hình học và trạng thái, và không gì hàm ý một bộ UI toolkit cụ thể. TXFAWidgetRuntime để lộ WidgetCountWidgets[I] dưới dạng record TXFAWidgetState mang ID, Name, Kind, PageIndex, Bounds theo điểm PDF, Value, EditValue cùng các cờ Focused, Editing, ReadOnly, Valid, còn việc vẽ, vẽ con trỏ soạn thảo và định tuyến bàn phím nằm trong code của bạn. Danh tính widget ổn định và theo thứ tự: mỗi widget nhận một ID dạng name[n], trong đó n đếm các lần xuất hiện trước của tên trường đó theo thứ tự bố cục, nên hàng thứ hai của một subform lặp là amount[1]. Danh tính đó là thứ sống sót qua mỗi lần rebuild, và cũng là thứ FocusWidget, BeginEdit, DispatchEventHitTest đều dùng để nói chuyện. Với tài liệu đang mở trong một instance THotPDF, CreateLoadedXFAWidgetRuntime trích các packet XDP, lấy page box đầu tiên làm kích thước trang bố cục, và trả về nil khi tệp hoàn toàn không có XFA

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil khi không có /XFA
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // hit test trong không gian trang, widget trên cùng thắng
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Cái gì phải là nguyên tử khi một trường được commit?

Mọi thứ mà lần chỉnh sửa chạm tới, tức nhiều hơn hẳn giá trị trường. CommitEdit gọi CaptureSnapshot trước khi ghi bất cứ gì, và snapshot đó bao gồm bốn thứ: DOM XFA đã tuần tự hoá từ TXFADocument.SaveToBytes, toàn bộ mảng record tương tác TXFAWidgetState, các bộ đếm LastCalculationPassesLastReflowPasses, cùng Warnings.Count hiện tại. Chỉ lưu giá trị node là lối tắt hấp dẫn nhưng sai, vì một script calculate hoặc một binding chưa phân giải có thể gọi EnsureValueNode và hiện thực hoá các node dữ liệu vốn không tồn tại khi lần chỉnh sửa bắt đầu; một phục hồi chỉ theo giá trị không có cách nào xoá chúng đi, nên một lần chỉnh sửa bị từ chối sẽ để lại phần dư cấu trúc vĩnh viễn trong packet datasets. Trình tự commit vốn rất nghiêm ngặt — ghi giá trị ứng viên, chạy validate cho trường vừa sửa, chạy calculate đến điểm cố định, rồi reflow đến khi bố cục ổn định — và mọi thất bại ở bất kỳ giai đoạn nào đều đi qua FailAndRestore, hàm này nạp lại các byte snapshot vào một TXFADocument mới, dựng lại danh sách widget, áp lại các trạng thái tương tác đã ghi, đặt lại bộ đếm và cắt Warnings về đúng độ dài lúc snapshot. LastDiagnostic giữ nguyên nhân khi thất bại, và giữ literal XFA transaction rollback failed trong trường hợp bệnh lý mà chính việc phục hồi lại raise

HotPDF coi một lần commit trường XFA là một giao dịch, chụp DOM đã tuần tự hoá, mọi trạng thái widget, các bộ đếm lượt và số cảnh báo trước khi validate, calculate và reflow, rồi publish hoặc khôi phục cả bốn cùng lúc
CommitEdit chụp bốn loại trạng thái trước khi ghi bất cứ gì, nên một validate, calculate hay reflow thất bại không để lại phần dư cấu trúc
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // chỉ đọc, hoặc không có widget đó
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // range sai, hoặc surrogate bị cắt đôi
    Exit;
  end;
  Result := Runtime.CommitEdit;             // tất cả hoặc không có gì
  if not Result then
    // tài liệu, widget, bộ đếm và cảnh báo đã trở về
    // trạng thái trước chỉnh sửa; widget đang focus chỉ bị đánh dấu invalid
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection xứng đáng có một đoạn riêng, vì đây là chỗ rẻ nhất để loại bỏ đầu vào dị dạng. Nó từ chối một vùng chọn cắt đôi một cặp surrogate UTF-16, từ chối văn bản thay thế chứa surrogate cao hoặc thấp không ghép cặp, và từ chối mọi kết quả dài hơn MaxValueChars. Bắt lỗi này ở tầng phím nghĩa là cỗ máy giao dịch không bao giờ phải cuộn ngược một ký tự astral-plane viết dở

Rebuild vào một danh sách riêng, publish bằng một phép swap

Một lần rebuild widget không bao giờ được phép để người quan sát thấy nửa chừng, nên RebuildWidgets dựng một TObjectList sở hữu hoàn toàn tách biệt rồi swap nó vào chỗ bằng đúng một phép gán ở cuối. Lý do không phải để đẹp: TXFALayoutEngine.ComputeLayout chạy trong lúc rebuild đang diễn ra và gọi ngược vào code host qua hàm MeasureText bạn cung cấp, và nó có thể raise EXFAWidgetRuntimeError khi chạm giới hạn widget. Nếu runtime biến đổi danh sách sống tại chỗ, cả hai đường đều có thể để host giữ một danh sách nửa cũ nửa mới, với các con trỏ DataNode trỏ vào một tài liệu sắp bị rollback. Độ hội tụ của reflow sau đó do LayoutSignature quyết định, một chuỗi dựng từ số widget cộng với mọi ID, chỉ số trang và bounding box làm tròn bốn chữ số thập phân: CommitEdit rebuild, so chữ ký, và lặp lại cho đến khi hai chữ ký liên tiếp khớp nhau hoặc ngân sách lượt cạn. Khi chữ ký chẳng hề thay đổi, LastReflowPasses giữ nguyên 0, đó là cách bạn phân biệt một lần sửa chỉ tác động giá trị với một lần thực sự làm biểu mẫu lớn lên, và trạng thái tương tác được chuyển qua mỗi lần rebuild theo widget ID, nên focus và việc đang gõ dở sống sót qua một lần chèn hàng

Runtime XFA của HotPDF dựng lại danh sách widget vào một danh sách sở hữu riêng trong lúc bố cục chạy và gọi ngược vào code đo đạc của host, rồi publish danh sách hoàn chỉnh bằng một phép gán mà host không thể thấy nửa chừng
Rebuild diễn ra trong danh sách riêng vì ComputeLayout có thể raise giữa chừng, còn LayoutSignature quyết định khi nào hai lần reflow liên tiếp đã hội tụ

Vì sao một trường có binding lại đọc nhầm bản ghi?

Vì script chạy mà không có ngữ cảnh dữ liệu. Một trường mang khai báo tường minh <bind match="dataRef" ref="$record.actual"/> và một trường được đặt tên theo đúng node dữ liệu đó là hai widget khác nhau trỏ vào một giá trị, còn một subform lặp với <occur max="2"/> sinh ra vài widget chung tên và chỉ khác nhau ở thuộc hàng dữ liệu nào; đánh giá validation và calculation từ gốc tài liệu thì tất cả chúng đều phân giải this thành node khớp đầu tiên trong toàn bộ packet datasets, nên hàng hai lặng lẽ validate hộ hàng một. HotPDF né điều đó bằng cách lưu DataNode đã phân giải trên từng entry widget khi bố cục sinh ra nó, rồi luồn node đó qua cả hai lời gọi HPDFXFAEvaluateFieldScript, cho xfskValidate lẫn xfskCalculate. Chính ngữ cảnh đó quyết định EnsureValueNode tạo node dựa vào đâu khi một phép tính nhắm vào một binding chưa tồn tại, và khi không phân giải được binding nào thì commit thất bại sạch sẽ với XFA calculation target is not bound thay vì ghi vào hàng nhầm. Ngữ nghĩa FormCalc đằng sau các script đó vang vọng điều mà tài liệu AcroForm nhận được từ các action được mô tả trong định dạng AcroForm và script calculate, nhưng luật phân giải ở đây là phạm vi XFA chứ không phải phạm vi tên trường

Ngân sách được kiểm tra trước tác dụng phụ, không phải sau

Mọi giới hạn trong runtime đều là điều kiện tiên quyết, vì một ngân sách được thi hành sau khi việc cấp phát đã xảy ra thì không còn là ngân sách. TXFAWidgetRuntimeOptions.Default giao MaxWidgets ở 10000, MaxValueChars ở 1048576, MaxCalculationPasses ở 16 và MaxReflowPasses ở 4, còn TXFAFormScriptOptions mặc định mang MaxOperations ở 100000 với MaxElapsedMilliseconds ở 500. Bên dưới, DOM XFA áp TXFADOMLimits của riêng nó: trần 128 MB cho đầu vào và đầu ra đã giải nén, nhiều nhất 1024 packet được khâu lại với nhau, 1000000 node, và độ sâu lồng nhau 256. Hai chi tiết quan trọng hơn chính các con số. Thứ nhất, ngân sách script áp cho cả giao dịch chứ không theo từng script: CommitEdit gieo một bộ đếm số thao tác còn lại duy nhất và một deadline monotonic, và mỗi lời gọi validate lẫn calculate đều rút từ cùng bộ đếm đó và chỉ nhận được số mili giây còn lại, nên một biểu mẫu với hai trăm trường tính toán không thể tiêu trọn 500 ms hai trăm lần. Thứ hai, deadline đến từ một hàm MonotonicMilliseconds có thể tiêm vào, và đó là thứ làm cho hành vi thời gian trở nên tái lập được trong bộ kiểm thử thay vì tung đồng xu trên một build agent đang bận

Các tầng ngân sách trong runtime XFA của HotPDF, từ giới hạn widget và giá trị qua giới hạn thao tác và thời gian của script xuống các trần DOM XFA, với một bộ đếm thao tác và một deadline dùng chung cho mọi lời gọi trong một giao dịch
Ngân sách script áp cho cả giao dịch chứ không theo từng script, nên hai trăm trường tính toán không thể trường nào cũng đòi một khoản 500 ms mới
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // mặc định 10000
  Options.MaxCalculationPasses := 8;                       // mặc định 16
  Options.MaxReflowPasses := 2;                            // mặc định 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // cả giao dịch
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // chỉ phát khi reflow thực sự dịch chuyển widget
      end;
    // ... điều khiển biểu mẫu ...
  finally
    Runtime.Free;
  end;
end;

Runtime dừng ở đâu, và vì sao nó nói thẳng điều đó

Runtime cố tình không phải một engine script XFA tổng quát. DispatchEvent xử lý nguyên bản các activity enterexit bằng cách chuyển focus, còn với mọi activity khác có kèm script nó từ chối bằng một diagnostic cụ thể, ổn định thay vì giả vờ: script nhắc đến addInstance, removeInstance hoặc instanceManager trả về XFA runtime does not support event-driven instance mutation, script đụng vào .presence trả về thông điệp presence tương ứng, và mọi thứ khác trả về XFA runtime does not support this event script. Một lời từ chối đoán được mà bạn có thể rẽ nhánh tốt hơn một bản mô phỏng cục bộ chạy đúng trên tệp mẫu của bạn và lệch đi trên tệp của khách hàng

Mô hình luồng cũng thẳng thắn không kém: một instance runtime thuộc về một luồng, không có khoá nội bộ, vì engine bố cục gọi ngược vào các callback đo đạc của host và một khoá quanh chỗ đó là một deadlock đang chờ repaint. Nội dung rich text bên trong trường đi theo đường lối bảo thủ giống như những chỗ khác trong thư viện, nơi các payload exData được xử lý như mô tả trong rich text và siêu liên kết exData của XFA, còn widget chữ ký và nút bấm trả về dưới dạng ReadOnly trong khi các loại UI không được hỗ trợ lộ diện dưới dạng xwkUnsupported thay vì một ô văn bản có thể sửa mà lặng lẽ mất dữ liệu

Gộp lại, đó là một câu trả lời khả dụng cho XFA động trong Delphi: giữ DOM sống, biến mỗi lần chỉnh sửa thành một giao dịch hoặc hạ cánh trọn vẹn hoặc không để lại gì, giới hạn mọi lượt, và nói rõ những gì nằm ngoài phạm vi. Nếu bạn đang cân nhắc nó cho quy trình bồi thường, thuế hay trợ cấp, runtime XFA được giao kèm trong HotPDF Delphi PDF component, cùng các đường AcroForm, làm phẳng và render mà những dự án đó thường cuối cùng cần đến cùng nhau