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

รันไทม์ฟอร์ม XFA แบบไดนามิกใน Delphi: ธุรกรรมของ HotPDF

HotPDF เติมฟอร์ม XFA แบบไดนามิกใน Delphi ผ่าน TXFAWidgetRuntime ซึ่งเป็นเลเยอร์ widget ที่เป็นกลางต่อโฮสต์และถือว่าการแก้ฟิลด์ทุกครั้งเป็นหนึ่งธุรกรรม: สแนปช็อต ตรวจสอบ คำนวณ จัดวางใหม่ แล้วจึงเผยแพร่หรือย้อนกลับทั้งก้อน มันทำงานแบบ single-threaded ภายในโฮสต์ VCL หรือ FMX ของคุณ ไม่ต้องติดตั้ง Acrobat และบังคับใช้งบประมาณทุกตัวก่อนจะจองอะไรก็ตาม

สถานการณ์นี้คุ้นเคยกับใครก็ตามที่ส่งมอบซอฟต์แวร์เอกสารเข้างานภาครัฐหรือประกันภัย แบบฟอร์มเคลมหรือแบบแสดงรายการภาษีที่ส่งมาเป็น PDF ซึ่งเนื้อหาหน้ากระดาษมีเพียงข้อความ "Please wait... if this message is not eventually replaced" ใบเดียว ส่วนฟิลด์จริงทุกตัวอาศัยอยู่ในแพ็กเกต XFA ที่ Adobe Acrobat เท่านั้นที่เรนเดอร์ได้ ผู้ใช้ของคุณอยากเติมมันในแอปพลิเคชันของคุณเอง และคุณหนีไปด้วยการแปลงเป็นภาพนิ่งก็ไม่ได้ เพราะฟอร์มเพิ่มแถวตามที่ข้อมูลถูกกรอก และเลย์เอาต์หลังแถวที่สามไม่ใช่เลย์เอาต์ที่มาพร้อมไฟล์ตั้งแต่แรก

ทำไม dynamic XFA จึงยังเป็นปัญหาที่คุ้มค่าจะแก้

Dynamic XFA ยังอยู่รอดเพราะฟอร์มที่ใช้งานจริงมีอายุยาวกว่ารูปแบบไฟล์ที่แบกมันมา ISO 32000-1 §12.7.8 อธิบาย XFA ว่าเป็นรายการ /XFA บน AcroForm dictionary ที่ถือ XDP packet stream ไว้ และ ISO 32000-2 ประกาศ deprecate กลไกทั้งหมด การ deprecate ทำให้มันหลุดจากแผนงาน ไม่ได้ทำให้หลุดจากสนามจริง ฟอร์มที่เขียนขึ้นตามสเปก XFA 3.3 ยังถูกออกใช้อยู่และยังมีผลผูกพันตามกฎหมาย Static XFA ลดลงเหลือ widget annotation ธรรมดาได้ และ HotPDF ทำแบบนั้นเมื่อคุณเรียก ApplyXFAAsAcroForm โดย trade-off ต่าง ๆ อยู่ในบทความ การแปลงฟอร์ม XFA ให้เป็นฟิลด์ AcroForm ส่วน dynamic XFA เป็นสัตว์อีกพันธุ์หนึ่ง เพราะช่วง occur ของมัน ข้อความที่โตได้ และสคริปต์ calculate ทำให้เซ็ตของฟิลด์กลายเป็นฟังก์ชันของข้อมูล จึงไม่มีรายการ annotation คงที่ไว้ flatten จนกว่าผู้ใช้จะพิมพ์เสร็จ ช่องว่างนี้เองคือสิ่งที่ TXFAWidgetRuntime เติม ด้วยการรักษา XFA DOM ให้มีชีวิต คำนวณเลย์เอาต์ใหม่หลังการแก้แต่ละครั้งที่ยอมรับ แล้วส่งอาร์เรย์แบนของ widget ที่มีตำแหน่งพร้อมวาดและ hit-test ให้โฮสต์ของคุณ

รันไทม์ส่งอะไรให้แอปพลิเคชันโฮสต์บ้าง

มันส่งให้คุณแค่เรขาคณิตกับสถานะ และไม่มีอะไรที่ตั้งสมมติฐานว่าคุณใช้ UI toolkit ตัวไหน TXFAWidgetRuntime เปิดเผย WidgetCount กับ Widgets[I] ในรูปของเรกคอร์ด TXFAWidgetState ที่แบก ID, Name, Kind, PageIndex, Bounds หน่วย PDF point, Value, EditValue และฟล็อก Focused, Editing, ReadOnly, Valid มาด้วย ส่วนการวาด การลาก caret และการรับคีย์บอร์ดยังอยู่ในมือคุณ ตัวตนของ widget เสถียรและเป็นลำดับเชิงเลข: widget แต่ละตัวได้ ID รูปแบบ name[n] โดย n นับจำนวนครั้งที่ชื่อฟิลด์นั้นปรากฏมาก่อนหน้าตามลำดับเลย์เอาต์ แถวที่สองของ subform แบบทำซ้ำจึงเป็น amount[1] ตัวตนแบบนี้คือสิ่งที่รอดผ่านการ rebuild และเป็นภาษากลางที่ FocusWidget, BeginEdit, DispatchEvent และ HitTest ใช้สื่อสารร่วมกัน สำหรับเอกสารที่เปิดอยู่ในอินสแตนซ์ THotPDF อยู่แล้ว CreateLoadedXFAWidgetRuntime จะดึงแพ็กเกต XDP ออกมา เอา page box หน้าแรกเป็นขนาดหน้าเลย์เอาต์ และคืน nil เมื่อไฟล์ไม่มี 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 เมื่อไม่มี /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 ในปริภูมิหน้า ตัวที่อยู่บนสุดชนะ
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

อะไรต้องเป็นอะตอมมิกเมื่อ commit ฟิลด์หนึ่งลงตัว

ทุกอย่างที่การแก้สามารถแตะต้องได้ ซึ่งมีมากกว่าค่าของฟิลด์เสียอีก CommitEdit เรียก CaptureSnapshot ก่อนจะเขียนอะไรลงไป และสแนปช็อตนั้นครอบคลุมสี่อย่าง: XFA DOM ที่ถูกซีเรียลไลซ์จาก TXFADocument.SaveToBytes, อาร์เรย์เต็มของเรกคอร์ด interaction TXFAWidgetState, ตัวนับ LastCalculationPasses กับ LastReflowPasses และ Warnings.Count ณ ปัจจุบัน การบันทึกแค่ค่าของโหนดเป็นทางลัดที่ชวนให้หลงใหลและมันผิด เพราะสคริปต์ calculate หรือ binding ที่ยังไม่ถูก resolve สามารถเรียก EnsureValueNode แล้วทำให้ data node ที่ไม่เคยมีตอนเริ่มแก้กลายเป็นรูปธรรมขึ้นมา การ restore เฉพาะค่าไม่มีทางเอาพวกนั้นออกได้ การแก้ที่ถูกปฏิเสธจึงทิ้งเศษโครงสร้างถาวรไว้ในแพ็กเกต datasets ลำดับ commit เองเข้มงวด — เขียนค่าที่เสนอ รัน validate สำหรับฟิลด์ที่ถูกแก้ รัน calculate จนถึงจุดคงตัว แล้วจึง reflow จนเลย์เอาต์นิ่ง — และความล้มเหลวในขั้นใด ๆ จะวิ่งเข้า FailAndRestore ซึ่งโหลดไบต์สแนปช็อตกลับเข้า TXFADocument ตัวใหม่ สร้างรายการ widget ใหม่ คืนสถานะ interaction ที่บันทึกไว้ รีเซ็ตตัวนับ และตัด Warnings กลับไปยาวเท่าตอนสแนปช็อต LastDiagnostic เก็บเหตุผลเมื่อล้มเหลว และเก็บข้อความตรงตัว XFA transaction rollback failed ในกรณีพิกลพิลที่ตัว restore เองก็ raise

HotPDF ถือว่าการ commit ฟิลด์ XFA เป็นหนึ่งธุรกรรม โดยจับ DOM ที่ซีเรียลไลซ์แล้ว สถานะ widget ทุกตัว ตัวนับรอบ และจำนวนคำเตือนไว้ก่อน validate, calculate และ reflow แล้วจึงเผยแพร่หรือ restore ทั้งสี่พร้อมกัน
CommitEdit สแนปช็อตสถานะสี่ชนิดก่อนเขียนอะไรลงไป ความล้มเหลวของ validate, calculate หรือ reflow จึงไม่ทิ้งเศษโครงสร้างเอาไว้
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // read-only หรือไม่มี widget นี้
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // ช่วงผิด หรือ surrogate ที่ถูกตัดครึ่ง
    Exit;
  end;
  Result := Runtime.CommitEdit;             // สำเร็จทั้งหมดหรือไม่ทำเลย
  if not Result then
    // เอกสาร, widget, ตัวนับ และคำเตือนกลับสู่สถานะก่อนแก้แล้วทั้งหมด
    // widget ที่โฟกัสอยู่ถูกทำเครื่องหมายว่า invalid เท่านั้น
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection สมควรได้หมายเหตุของตัวเอง เพราะนี่คือจุดที่การปฏิเสธอินพุตที่ผิดรูปแพงที่สุดเพียงไม่กี่ไซเกิล มันปฏิเสธ selection ที่ตัดครึ่ง surrogate pair ของ UTF-16 ปฏิเสธข้อความแทนที่ที่มี high หรือ low surrogate ไร้คู่ และปฏิเสธผลลัพธ์ที่ยาวเกิน MaxValueChars การจับปัญหาตรงชั้น keystroke แปลว่าเครื่องจักรธุรกรรมไม่ต้องไล่ถอนอักขระ astral plane ที่เขียนครึ่ง ๆ กลางคันเลย

Rebuild ลงลิสต์ส่วนตัว แล้วเผยแพร่ด้วยการสลับครั้งเดียว

การ rebuild รายการ widget ต้องไม่ถูกมองเห็นในสภาพครึ่ง ๆ กลาง ๆ เด็ดขาด RebuildWidgets จึงสร้าง TObjectList ผู้เป็นเจ้าของที่แยกขาดจากของเดิมโดยสิ้นเชิง แล้วสลับมันเข้าที่ด้วยการ assign ครั้งเดียวท้ายสุด เหตุผลไม่ใช่ความสวยงาม TXFALayoutEngine.ComputeLayout ทำงานระหว่างที่ rebuild ยังบินอยู่และวิ่งกลับเข้าโค้ดโฮสต์ผ่านฟังก์ชัน MeasureText ที่คุณส่งเข้ามา และมัน raise EXFAWidgetRuntimeError ได้เมื่อชนเพดานจำนวน widget ถ้ารันไทม์แก้ลิสต์ที่ยังมีชีวิตของตัวเองแทน ทางไหนทางหนึ่งก็จะทิ้งโฮสต์ไว้กับลิสต์ที่ครึ่งหนึ่งเป็นเลย์เอาต์เก่าครึ่งหนึ่งเป็นใหม่ พร้อมพอยเตอร์ DataNode ที่ชี้เข้าเอกสารซึ่งกำลังจะถูกย้อนกลับ ความลู่เข้าของ reflow ถูกตัดสินด้วย LayoutSignature สตริงที่ประกอบจากจำนวน widget บวกทุก ID, page index และ bounding box ที่ปัดทศนิยมสี่ตำแหน่ง: CommitEdit rebuild เทียบ signature และทำซ้ำจนกว่าสอง signature ติดกันจะตรงกันหรืองบรอบจะหมด เมื่อ signature ไม่เคยเปลี่ยนเลย LastReflowPasses คงอยู่ที่ 0 ซึ่งเป็นวิธีแยกการแก้เฉพาะค่าออกจากการแก้ที่ทำฟอร์มโตขึ้นจริง และสถานะ interaction ถูกแบกข้ามการ rebuild ทุกครั้งด้วย widget ID โฟกัสและการแก้ที่พิมพ์ค้างจึงรอดผ่านการแทรกแถว

รันไทม์ XFA ของ HotPDF สร้างรายการ widget ใหม่ลงลิสต์เจ้าของแยกต่างหากระหว่างที่เลย์เอาต์ทำงานและวิ่งกลับเข้าโค้ดวัดขนาดของโฮสต์ แล้วเผยแพร่ลิสต์ที่เสร็จสมบูรณ์ด้วยการ assign ครั้งเดียวที่โฮสต์มองไม่เห็นสภาพครึ่ง ๆ
การ rebuild เกิดในลิสต์ส่วนตัวเพราะ ComputeLayout raise กลางทางได้ และ LayoutSignature ตัดสินว่าสอง reflow ติดกันลู่เข้าหรือยัง

ทำไมฟิลด์ที่มี binding จึงอ่านเรกคอร์ดผิดตัว

เพราะสคริปต์วิ่งโดยไม่มี data context ฟิลด์ที่แบก <bind match="dataRef" ref="$record.actual"/> ไว้ชัดเจน กับฟิลด์ที่ตั้งชื่อตาม data node เดียวกันนั้น เป็น widget สองตัวที่ชี้ค่าเดียวกัน และ subform แบบทำซ้ำที่มี <occur max="2"/> ผลิต widget หลายตัวที่ใช้ชื่อร่วมกัน ต่างกันแค่ว่าใครอยู่แถวข้อมูลไหน ถ้าประเมิน validation กับ calculation จากรากเอกสาร ทุกตัวจะ resolve this ไปติดที่โหนดแรกที่ตรงในแพ็กเกต datasets ทั้งก้อน แถวสองจึงไป validate แถวหนึ่งโดยไม่มีใครรู้ตัว HotPDF เลี่ยงเรื่องนี้ด้วยการเก็บ DataNode ที่ resolve แล้วไว้กับรายการ widget แต่ละตัวตอนที่ layout ผลิตมันขึ้นมา แล้วส่งโหนดนั้นต่อเข้าไปในการเรียก HPDFXFAEvaluateFieldScript ทั้งสองครั้ง ทั้งกรณี xfskValidate และ xfskCalculate บริบทเดียวกันนี้ตัดสินด้วยว่า EnsureValueNode จะสร้างโหนดใหม่เทียบกับอะไรเมื่อ calculation ตกเป้าไปที่ binding ที่ยังไม่มีตัวตน และเมื่อ resolve binding ไม่ได้เลย commit จะล้มอย่างสะอาดด้วยข้อความ XFA calculation target is not bound แทนการเขียนลงแถวผิด ความหมาย FormCalc เบื้องหลังสคริปต์พวกนี้สะท้อนสิ่งที่เอกสาร AcroForm ได้รับจาก actions ที่อธิบายไว้ในบทความ สคริปต์ format และ calculate ของ AcroForm แต่กติกาการ resolve ที่นี่ผูกอยู่กับขอบเขตของ XFA ไม่ใช่ชื่อฟิลด์

งบประมาณถูกตรวจก่อน side effect ไม่ใช่หลังจากนั้น

ลิมิตทุกตัวในรันไทม์เป็นเงื่อนไขเบื้องต้น เพราะงบประมาณที่บังคับหลังการจองเกิดขึ้นไปแล้วไม่ใช่งบประมาณ TXFAWidgetRuntimeOptions.Default ส่งมาพร้อม MaxWidgets ที่ 10000, MaxValueChars ที่ 1048576, MaxCalculationPasses ที่ 16 และ MaxReflowPasses ที่ 4 ส่วน TXFAFormScriptOptions ค่าเริ่มต้นแบก MaxOperations ที่ 100000 กับ MaxElapsedMilliseconds ที่ 500 เบื้องล่าง XFA DOM บังคับใช้ TXFADOMLimits ของตัวเอง: เพดาน 128 MB สำหรับอินพุตและเอาต์พุตหลังคลายการบีบอัด, แพ็กเกตที่ต่อกันได้สูงสุด 1024 ชุด, โหนด 1000000 ตัว และความลึกการซ้อน 256 รายละเอียดสองจุดสำคัญกว่าตัวเลขเอง จุดแรก งบของสคริปต์ครอบคลุมทั้งธุรกรรม ไม่ได้ต่อสคริปต์: CommitEdit หยอดตัวนับ remaining-operations ตัวเดียวกับ deadline แบบ monotonic หนึ่งตัว และการเรียก validate กับ calculate ทุกครั้งต้องกินจากตัวนับเดียวกันนั้นพร้อมรับเฉพาะมิลลิวินาทีที่ยังเหลือ ฟอร์มที่มีฟิลด์คำนวณสองร้อยตัวจึงจ่าย 500 ms เต็มสองร้อยรอบไม่ได้ จุดที่สอง deadline มาจากฟังก์ชัน MonotonicMilliseconds ที่ฉีดเข้ามาได้ ซึ่งเป็นสิ่งที่ทำให้พฤติกรรมด้านเวลาที่ผ่านไปทำซ้ำได้ใน test suite แทนการเสี่ยงหัวก้อนบน build agent ที่ยุ่ง

ชั้นงบประมาณของรันไทม์ XFA ใน HotPDF ตั้งแต่ลิมิต widget กับค่า ผ่านลิมิต operation และเวลาของสคริปต์ ลงไปถึงเพดานของ XFA DOM โดยมีตัวนับ operation หนึ่งตัวกับ deadline หนึ่งตัวที่ทุกการเรียกในธุรกรรมแบ่งกันใช้
งบของสคริปต์ครอบคลุมทั้งธุรกรรม ไม่ได้ต่อสคริปต์ ฟิลด์คำนวณสองร้อยตัวจึงอ้างสิทธิ์ 500 ms สด ๆ ให้กันคนละรอบไม่ได้
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // ค่าเริ่มต้น 10000
  Options.MaxCalculationPasses := 8;                       // ค่าเริ่มต้น 16
  Options.MaxReflowPasses := 2;                            // ค่าเริ่มต้น 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // ทั้งธุรกรรม
  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;   // ยิงเฉพาะเมื่อ reflow ย้าย widget จริง
      end;
    // ... ขับเคลื่อนฟอร์ม ...
  finally
    Runtime.Free;
  end;
end;

จุดที่รันไทม์หยุด และเหตุผลที่มันประกาศเสียงดัง

รันไทม์นี้ตั้งใจไม่เป็นเอนจินสคริปต์ XFA แบบทั่วไป DispatchEvent จัดการ activity enter กับ exit ด้วยตัวเองผ่านการย้ายโฟกัส และสำหรับ activity อื่นที่แบกสคริปต์ มันปฏิเสธด้วย diagnostic ที่เจาะจงและเสถียรแทนการแกล้งทำ: สคริปต์ที่พูดถึง addInstance, removeInstance หรือ instanceManager คืนข้อความ XFA runtime does not support event-driven instance mutation สคริปต์ที่แตะ .presence คืนข้อความฝั่ง presence ที่เทียบเท่า และกรณีอื่นคืน XFA runtime does not support this event script การปฏิเสธที่คาดเดาได้และแตก branch ได้ย่อมชนะการจำลองบางส่วนที่ใช้กับไฟล์ตัวอย่างของคุณได้แต่เหลื่อมล้ำเมื่อถึงไฟล์ของลูกค้า

โมเดลเธรดก็ตรงไปตรงมาเช่นกัน: อินสแตนซ์รันไทม์หนึ่งตัวเป็นของเธรดเดียว ไม่มี locking ภายใน เพราะเอนจินเลย์เอาต์วิ่งกลับเข้า callback วัดขนาดของโฮสต์ และการกุมล็อกรอบสิ่งนั้นคือ deadlock ที่รอ repaint เนื้อหา rich content ในฟิลด์เดินเส้นอนุรักษ์นิยมเดียวกับที่อื่นในไลบรารี โดย payload exData ถูกจัดการตามที่อธิบายในบทความ ข้อความริชเท็กซ์และไฮเปอร์ลิงก์ exData ของ XFA และ widget signature กับ button กลับมาในสถานะ ReadOnly ขณะที่ชนิด UI ที่ไม่รองรับโผล่มาเป็น xwkUnsupported แทนที่จะเป็นกล่องข้อความแก้ไขได้ที่หลุดข้อมูลไปเงียบ ๆ

รวมกันแล้ว นั่นคือคำตอบที่ใช้งานได้จริงของ dynamic XFA ใน Delphi: รักษา DOM ให้มีชีวิต ทำการแก้ทุกครั้งให้เป็นธุรกรรมที่ลงตัวทั้งหมดหรือไม่ทิ้งอะไรเอาไว้เลย ผูกงบประมาณกับทุกรอบ และพูดให้ชัดว่าอะไรอยู่นอกขอบเขต ถ้าคุณกำลังประเมินสิ่งนี้สำหรับเวิร์กโฟลว์เคลม ภาษี หรือสวัสดิการ รันไทม์ XFA มาพร้อม HotPDF Delphi PDF component ควบคู่กับเส้นทาง AcroForm, การ flatten และการเรนเดอร์ที่โปรเจกต์พวกนี้มักจบลงด้วยการต้องใช้พร้อมกัน