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

ทำไมการแก้ไขฟิลด์ XFA ถึงหายไปตอนบันทึกใน PDFium สำหรับ Delphi

TPdf.SetFocusedFormFieldText ใน PDFium Component เขียนเข้า edit buffer ที่มีชีวิตของฟิลด์ฟอร์มที่ focus อยู่ในขณะนั้น และสำหรับฟอร์ม XFA buffer นั้นไม่เคยไปถึง packet datasets ที่ถูก serialize ลงดิสก์เลย ดังนั้นค่าที่ผู้ใช้พิมพ์ และโค้ดของคุณยืนยันว่าถูกรับแล้ว จะหายไปเงียบๆ ในครั้งถัดไปที่ไฟล์เปิด ฟิลด์ AcroForm ไม่มีปัญหานี้ การเรียกเดียวกัน commit เข้า entry /V ของฟิลด์ทันทีที่ focus เคลื่อนออกไป ผู้ใช้ที่กรอกฟอร์มรับสมัคร XFA, บันทึก แล้วเปิดใหม่และพบว่าฟิลด์จำนวนเงินว่างเปล่าอีกครั้ง ไม่ได้เจอ glitch การ render มันคือขอบเขตของสิ่งที่เอนจิ้น PDFium เองเปิดให้เขียนข้อมูลฟอร์ม

นี่เป็นคำถามที่แคบกว่าการตรวจจับฟอร์ม XFA ตั้งแต่แรก หรือทำให้ JavaScript ของมันรัน ไม่ใช่ "PDFium รองรับ XFA ไหม" และไม่ใช่ "ฉันจะรัน AcroForm script ได้อย่างไร" แต่เจาะจงว่าเกิดอะไรขึ้นกับค่าหลังจาก SetFocusedFormFieldText รายงานความสำเร็จ คำตอบสั้นๆ คือ AcroForm และ XFA ไม่ใช่ dialect สองแบบของ form model เดียวกันเท่าที่เกี่ยวกับเส้นทางการเขียนของ PDFium มันเป็น form model สองแบบที่มีความสัมพันธ์ที่ต่างกันโดยสิ้นเชิงสองแบบระหว่างสิ่งที่ผู้ใช้พิมพ์กับสิ่งที่การบันทึกจับไว้จริง และการปนกันทั้งสองคือสิ่งที่เปลี่ยนการเรียก API บรรทัดเดียวให้กลายเป็น support ticket สามสัปดาห์หลังจากการทดลองใช้งานของลูกค้าเปิดตัวจริงบทความ AcroForm JavaScriptแสดงการเรียกบรรทัดเดียวและระบุผลลัพธ์ AcroForm-เทียบกับ-XFA ไว้ใน comment โค้ด บทความนี้อยู่กับ API เดียวกันนั้นและเดินผ่านเส้นทางการเขียนภายใน, หลักฐาน datasets-packet ที่การเขียน XFA ไม่เคยลงเอย, ทำไมช่องว่างถึงอยู่ใน PDFium เองแทนที่จะเป็น Delphi binding และวิธีแก้ไข XML ของคุณเองสำหรับเอกสารที่ต้องการให้การแก้ไขอยู่รอดหลังการบันทึก

SetFocusedFormFieldText เขียนค่าฟิลด์อย่างไร

TPdf.SetFocusedFormFieldText ทำงานด้วยการจำลองการแก้ไขระดับการกดแป้นพิมพ์ ไม่ใช่การจิ้มค่าเข้า document model โดยตรง ภายในมันเรียก FORM_SelectAllText เพื่อเลือกเนื้อหาปัจจุบันของฟิลด์ที่ focus อยู่ แล้วเรียก FORM_ReplaceSelection เพื่อเขียนทับสิ่งที่เลือกด้วย string ใหม่ เป็นสองการดำเนินการเดียวกับที่การเลือกทั้งหมดแล้วพิมพ์ด้วยคีย์บอร์ดจะกระตุ้น เพราะการเขียนวิ่งผ่านเส้นทางการแก้ไขข้อความแบบโต้ตอบของ PDFium แทนที่จะอ้อมมัน script ประเภทกดแป้น, format หรือ calculate ใดก็ตามที่ผูกกับฟิลด์จะยิงเหมือนกับที่มันจะยิงสำหรับมนุษย์ที่กำลังพิมพ์ ซึ่งเป็นสิ่งที่ทำให้ API มีประโยชน์สำหรับการกรอกฟอร์มแบบโปรแกรมใน viewer ที่รักษา JavaScript ให้ทำงานอยู่ ฝั่งอ่านคู่กันคือ FocusedFormFieldText หนุนหลังด้วย FORM_GetFocusedText และมันสะท้อน buffer ที่มีชีวิตเดียวกับที่ SetFocusedFormFieldText เพิ่งเขียนไป

if Pdf.FocusedFormFieldIndex >= 0 then
begin
  if Pdf.SetFocusedFormFieldText('1284.50') then
    Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
  else
    Log('No field is focused, or it does not accept text');
end
else
  Log('Focus a field first - FocusFormField or a real click');

ทำไม AcroForm ถึงเก็บค่าไว้ได้ แต่ XFA เสียมันไป

ฟิลด์ข้อความและ combo ของ AcroForm อยู่รอดได้เพราะ form-fill environment ของ PDFium เอง commit edit buffer ให้คุณเลย ทันทีที่ฟิลด์เสีย focus buffer จะถูกเขียนเข้า entry /V ของฟิลด์ ซึ่งเป็น key เดียวกับที่ PDF reader ที่เป็นไปตามมาตรฐานทุกตัวดูเพื่อรู้ค่าที่เก็บไว้ของฟิลด์ TPdf.ClearFormFieldFocus ซึ่งเรียก FORM_ForceToKillFocus ข้างใต้ บังคับ commit นั้นตามต้องการ ดังนั้นโค้ดที่ตั้งค่าแบบโปรแกรมจึงไม่ต้องรอการคลิกเมาส์จริงที่อื่นใน UI บันทึกทันทีหลังจากนั้น และข้อความใหม่ก็เป็นส่วนหนึ่งของ document object graph ก่อนที่ TPdf.SaveAs จะรันเลยด้วยซ้ำ เพราะ /V เป็น entry จริงใน field dictionary จริง ไม่ใช่สิ่งที่ผูกเข้ามาภายหลัง

Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus;              // forces the /V commit now
Pdf.SaveAs('invoice-acroform.pdf');

// Reopen and confirm - this is an AcroForm document, so it holds
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50');   // passes

การแก้ไขฟิลด์ XFA อยู่ตรงไหนจริงๆ

ฟิลด์ XFA ไม่มีการเชื่อมต่อแบบนั้นเลย ข้อความที่ผู้ใช้พิมพ์ลงเอยใน buffer CPWL_Edit ที่เป็นของชั้น rendering และ interaction ของ XFA ใน PDFium และชั้นนั้นไม่มี code path ใดที่ copy buffer กลับเข้า packet datasets ที่เก็บไว้ใน PDF TPdf.GetXfaDatasets ทำให้ช่องว่างนี้มองเห็นได้ เรียกมันก่อนและหลังการแก้ไขบนฟิลด์ XFA แล้วไบต์ที่มันคืนกลับมาเหมือนกันเป๊ะ เพราะ method นี้อ่าน packet ต้นฉบับที่เอกสารถูกเปิดด้วย ไม่เคยอ่าน state ที่มีชีวิตของ widget ที่คุณเพิ่งแก้ไขเลย ไม่มีอะไรในนี้ที่เป็นบั๊ก cache หรือปัญหาจังหวะการรีเฟรชเลย packet datasets บนดิสก์และ edit buffer ในหน่วยความจำเป็นแค่ state สองชิ้นที่ต่างกันที่ public API ของ PDFium ไม่เคยเชื่อมต่อกันเลย

var
  Before, After: TBytes;
begin
  Before := Pdf.GetXfaDatasets;
  Pdf.FocusFormField(FieldIndex);
  Pdf.SetFocusedFormFieldText('1284.50');
  After := Pdf.GetXfaDatasets;
  // Before and After are byte-for-byte identical on an XFA document -
  // the edit never touched the packet GetXfaDatasets reads from
end;

นี่คือบั๊กของ PDFium Component หรือข้อจำกัดของ PDFium

ชิ้นส่วนที่หายไปอยู่ใน PDFium เอง ไม่ใช่ใน Delphi binding ที่อยู่บนมัน public API ของ PDFium ไม่มี FPDF_SetXFAPacket ให้ฉีด packet ที่อัปเดตแล้ว และไม่มี FPDF_SaveAsXFA ให้ขอ XFA engine serialize DOM ปัจจุบันของมันกลับเป็น XML datasets ก่อนการบันทึก FPDF_SaveAsCopy การ export ที่หนุนหลัง TPdf.SaveAs เขียน document object graph ที่ PDFium มีอยู่แล้วออกมา มันไม่มี hook ให้ขอ XFA engine flush state ที่มีชีวิตของมันก่อนเลย เพราะ hook นั้นไม่มีอยู่ต้นทาง PDFium Component เพิ่ม reconciliation ที่ PDFium เองไม่เคย implement ไม่ได้ และการส่ง DOM-เป็น-XML serializer ที่สร้างขึ้นเองที่เดา internal XFA state ของ PDFium จะแย่กว่าช่องว่างที่ตรงไปตรงมา มันจะดูเหมือนทำงานได้จนกว่า PDFium เวอร์ชันถัดไปจะเปลี่ยนอะไรบางอย่างที่ไม่มีใครนอกโปรเจกต์เห็นได้

ขอบเขตนี้ปรากฏขึ้นระหว่างการตรวจสอบ v2.13.2 เดียวกันที่สร้าง SetFocusedFormFieldText ขึ้นมาตั้งแต่แรก FORM_ReplaceSelection ถูกผูกไว้ใน DLL import table มาหลายเวอร์ชันโดยไม่เคยถูกเรียกจากโค้ด Pascal เลย และการเพิ่มเส้นทางการเขียนที่ในที่สุดก็ใช้มัน คือสิ่งที่ทำให้ช่องว่างการคงอยู่ชัดเจนพอที่จะบันทึกเป็นเอกสาร แทนที่จะเป็นแค่ทฤษฎี รอบการตรวจสอบเดียวกันพบช่องว่างที่ไม่เกี่ยวข้องกันแต่เกี่ยวข้องกันในจิตวิญญาณ AcroForm JavaScript ถูกปิดใช้งานอย่างเงียบๆ ตั้งแต่ v2.13.0 เพราะ JS platform ถูกต่อสายไว้แค่ภายใน branch การเริ่มต้น XFA เท่านั้น ดังนั้นเอกสาร AcroForm ธรรมดาที่มี app.alert หรือฟิลด์คำนวณจึงไม่เคยได้ script engine เลย อันนั้นแก้ได้ ขยาย JS platform ไปยังทุกเอกสารไม่ว่า XFA หรือไม่ และมันถูกส่งออกมาในเวอร์ชันเดียวกัน ช่องว่างการคงอยู่ที่ครอบคลุมตรงนี้แก้ไม่ได้ ด้วยเหตุผลข้างต้น การแก้ไข JavaScript และ event host-veto รอบมันครอบคลุมในการรัน AcroForm JavaScript ด้วย PDFium Component

คุณควรทำอย่างไรเกี่ยวกับเรื่องนี้ใน Delphi

สำหรับเอกสาร AcroForm ทางแก้ไม่ใช่อะไรมากไปกว่านิสัยที่ดี เรียก ClearFormFieldFocus (หรือย้าย focus ออกไปด้วยวิธีอื่น) ก่อน SaveAs ทุกครั้งที่ค่าถูกตั้งแบบโปรแกรม แทนที่จะสมมติว่าการโต้ตอบ UI ในภายหลังจะกระตุ้น commit ให้คุณ สำหรับเอกสารที่อาจเป็น AcroForm หรือ XFA ก็ได้ ซึ่งเป็นกรณีทั่วไปใน viewer อเนกประสงค์ ตรวจสอบ FormType หรือ boolean XFA ก่อนที่คุณจะสัญญากับผู้เรียกว่าการบันทึกจะติด และอ่านการตรวจจับฟอร์ม XFA และการดึง packet XFAสำหรับชุดการตรวจสอบเต็มรูปแบบ รวมถึงกรณี XFAF ที่เนื้อหา XFA ถูกซ้อนทับบน widget AcroForm ปกติที่เคารพ /V จริงๆ

สำหรับฟอร์ม XFA แบบ dynamic แท้ๆ ที่ค่าที่แก้ไขต้องอยู่รอดหลังการบันทึก edit buffer แบบโต้ตอบไม่ใช่เครื่องมือที่ถูกต้องเลย เส้นทางที่ทนทานคือปฏิบัติต่อ GetXfaDatasets เป็น baseline ของคุณ ไม่ใช่ผลลัพธ์ของคุณ อ่านมันครั้งเดียวตอนเอกสารเปิด เก็บบันทึกของคุณเองว่าผู้ใช้เปลี่ยนอะไรทีละฟิลด์ ซึ่งเป็นค่าเดียวกับที่ UI ของคุณมีอยู่แล้วพอดี เพราะ PDFium จะไม่ส่งมันกลับให้คุณภายหลัง แพตช์สิ่งเหล่านั้นเข้า baseline XML ด้วยตัวคุณเอง และขับเคลื่อนเอาต์พุตของคุณเอง การเขียนที่วิ่งผ่าน XML ที่โค้ดของคุณเองควบคุมจะอยู่รอดหลังการบันทึกที่ buffer CPWL_Edit ไม่มีทางทำได้เลย

function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
  NewValue: string): TBytes;
var
  DatasetsXml: string;
begin
  // GetXfaDatasets ships with PDFium Component; PatchXmlNode below is
  // your own helper over your own XML library, nothing PDFium provides
  DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
  DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
  Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;

การจับช่องว่างนี้ก่อนที่ลูกค้าจะเจอ

TPdf.SaveAs คืนค่า True ไม่ว่าค่าฟิลด์ XFA จะอยู่รอดหรือไม่ก็ตาม เพราะจากมุมมองของ PDFium การบันทึกสำเร็จจริงๆ มันเขียนทุกไบต์ที่มันถูกขอให้เขียน นั่นทำให้นี่เป็นข้อบกพร่องประเภทพอดีที่หลุดผ่าน smoke test และไปถึงลูกค้า ไม่มีอะไร throw ไม่มีอะไร log ไฟล์เปิดได้ดี มีแค่ค่าเฉพาะที่ผิดเท่านั้น การทดสอบไปกลับที่เปิดไฟล์ที่บันทึกไว้ใหม่จริงๆ และเปรียบเทียบค่าของฟิลด์ หรือเปรียบเทียบ GetXfaDatasets ก่อนและหลัง ตามตัวอย่างก่อนหน้า ควรอยู่ใน regression suite สำหรับ viewer ใดก็ตามที่ให้ผู้ใช้แก้ไขเนื้อหา XFA ไม่ใช่แค่เส้นทาง AcroForm ที่บังเอิญทำงานได้ตามค่าเริ่มต้น

ไม่มีอะไรในนี้ที่เป็นข้อบกพร่องให้แจ้งต่อ PDFium Component มากเท่ากับเป็นขอบเขตให้ออกแบบรอบมัน SetFocusedFormFieldText ทำสิ่งที่ชื่อของมันบอกไว้พอดีสำหรับทั้งสอง form model และความแตกต่างในผลลัพธ์ก็สาวไปได้อย่างชัดเจนถึงสิ่งที่ AcroForm กับ XFA แต่ละตัวต่อสาย buffer นั้นไว้ที่ฝั่ง PDFium API, primitive ของ focus และ save และตัวอ่าน packet ที่อ้างอิงตรงนี้เป็นส่วนหนึ่งของPDFium Componentสำหรับ Delphi และ C++Builder