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

การนำทางฟิลด์ฟอร์ม PDF ใน Delphi (PDFium Component)

กดปุ่ม Tab ในฟอร์ม PDF ที่โค้ดของคุณสร้างขึ้น แล้วเคอร์เซอร์กลับไปตกที่ฟิลด์ที่ห่างจากตำแหน่งที่ควรจะเป็นสองฟิลด์ หรือข้ามคอลัมน์ที่สองไปเลย หรือกระโดดกลับไปด้านบนหลังฟิลด์ที่สามแทนที่จะเป็นฟิลด์ที่สี่ ผู้ใช้ที่กำลังกรอกใบแจ้งหนี้ในตัวแสดงผลของคุณคาดหวังว่าคีย์บอร์ดจะไล่ลำดับฟอร์มแบบเดียวกับที่เคยใช้กับฟอร์มเว็บทุกแบบที่เขาเคยเจอมา เมื่อมันไม่เป็นแบบนั้น เขาก็จะหันไปใช้เมาส์ ไล่หากล่องถัดไปเอง แล้วเงียบ ๆ ตัดสินใจว่าเครื่องมือของคุณยังทำไม่เสร็จ การไล่ลำดับฟิลด์ที่คาดเดาได้คือความต่างระหว่างตัวแสดงผลสำหรับกรอกข้อมูลที่คนแค่ทนใช้ กับตัวที่คนไว้ใจใช้จริง และเกือบทั้งหมดขึ้นอยู่กับการใช้ focus API ที่ถูกต้อง แทนที่จะปลอมการกดคีย์บอร์ดด้วยการจำลองคลิกเมาส์

ตัวอย่างด้านล่างใช้ PDFium Component ซึ่งเป็นคอมโพเนนต์ VCL/LCL ที่สร้างบน PDFium สำหรับ Delphi, C++Builder และ Lazarus การไล่ลำดับการนำทางเป็นหนึ่งในสามเรื่องที่ตัวแสดงผลฟอร์มต้องทำให้ถูกต้อง ส่วนอีกสองเรื่องคือการเปิดฟอร์มอย่างถูกวิธี และการบันทึกค่าที่กรอกแล้วให้ปรากฏขึ้นจริง ซึ่งเป็นจุดที่ความประหลาดใจส่วนใหญ่ซ่อนอยู่ บทความนี้จึงครอบคลุมทั้งสามเรื่อง

การเปิดฟอร์ม: FormFill, FormType และคำถามเรื่อง XFA

การเข้าถึงฟิลด์ต้องเปิดใช้งานระบบย่อย form-fill ซึ่งควบคุมด้วยพร็อพเพอร์ตี้ FormFill ก่อนที่จะเปิดเอกสาร เมื่อเปิดใช้งานแล้ว FormType จะบอกว่าคุณกำลังเจอฟอร์มประเภทใด และคำตอบนั้นเปลี่ยนชุดฟีเจอร์ที่คุณรับปากลูกค้าได้:

แผนภาพการตั้งค่า FormFill กับกิ่งการตรวจจับ FormType ใน viewer PDFium Component ของ Delphi ที่แยกการจัดการ ftNone, ftAcroForm และ ftXfaFull
FormType แยกทางเมื่อ FormFill ถูกเปิด และแต่ละทางสัญญาชุดฟีเจอร์ที่ต่างกัน
Pdf.FileName := FormPath;
Pdf.FormFill := True;   // เปิดใช้งานก่อน Active; จำเป็นสำหรับการเข้าถึงฟิลด์ใด ๆ
Pdf.Active := True;

case Pdf.FormType of
  ftNone:
    DisableFormPanel('This document has no interactive form');
  ftAcroForm:
    BuildFieldList;     // ไล่ลำดับฟิลด์และแก้ไขได้เต็มรูปแบบ
  ftXfaFull:
    ShowXfaNotice;      // XFA เรนเดอร์จากเทมเพลต XML ของตัวเอง;
                        // ถือว่าการแก้ไขฟิลด์มีข้อจำกัด
end;

มีข้อสังเกตเชิงปฏิบัติสองข้อตามมาจากตัวสวิตช์นี้ AcroForm คือโมเดลฟอร์มมาตรฐานตาม ISO 32000 และเป็นสิ่งที่ API ทุกตัวในบทความนี้มุ่งเป้าไป ส่วนเอกสาร XFA ฝังสถาปัตยกรรมฟอร์ม XML ของตัวเองไว้ ดังนั้นการรับปากลูกค้าว่าจะแก้ไข XFA ได้เต็มรูปแบบหลังจากสาธิต AcroForm สั้น ๆ คือคำมั่นที่คุณจะต้องเสียใจภายหลัง ข้อสังเกตที่สองเกี่ยวกับผลข้างเคียง: การตั้งค่า FormFill เป็น True ยังเป็นการเริ่มต้นระบบ JavaScript ของเอกสารด้วย ในตัวแสดงผลสำหรับกรอกข้อมูลนี่คือสิ่งที่ถูกต้องพอดี เพราะสคริปต์คำนวณคือสิ่งที่ทำให้ยอดรวมทันสมัยขณะที่มีคนพิมพ์อยู่ แต่ในหน้าต่างพรีวิวสำหรับไฟล์ที่ไม่ทราบที่มา นี่คือสิ่งที่ผิดพอดีเช่นกัน บทความเรื่องการพรีวิว PDF อย่างปลอดภัย ครอบคลุมฝั่ง FormFill := False ของการแลกเปลี่ยนนี้

การไล่ลำดับด้วยปุ่ม Tab ที่พาไปยังจุดที่ผู้ใช้คาดหวัง

กลับไปที่ปัญหาคีย์บอร์ดตั้งแต่ต้น สิ่งที่ล่อใจให้ทำคือปลอมปุ่ม Tab ด้วยการจำลองคลิกเมาส์ลงบนสี่เหลี่ยมของวิดเจ็ตถัดไป ซึ่งจะพังทันทีที่ฟิลด์เลื่อนออกนอกจอหรือวิดเจ็ตสองตัวซ้อนทับกัน แทนที่จะทำแบบนั้น focus API จะย้าย focus ของฟอร์มเองโดยตรง โดยไม่ต้องเดาเรื่องตำแหน่งเรขาคณิตเลย มีฟังก์ชันห้าตัวที่ครอบคลุมเรื่องนี้: FocusFormField ตามดัชนี, FocusNextFormField และ FocusPreviousFormField สำหรับการก้าวไปทีละฟิลด์, FocusedFormFieldIndex สำหรับอ่านตำแหน่งปัจจุบัน และ ClearFormFieldFocus สำหรับยกเลิก focus ทั้งหมด

แผนภาพการเดินโฟกัสด้วยแป้น Tab ใน viewer PDFium Component ของ Delphi ที่ FocusNextFormField หมุนวนภายในลำดับแท็บของหน้าเดียว และ API โฟกัสห้าตัวรองรับการนำทางด้วยคีย์บอร์ด
การไล่วนซ้ำอยู่ภายใน tab order ของหน้าเดียว การข้ามไปหน้าถัดไปจึงยังเป็นหน้าที่ของ viewer
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
begin
  if ssShift in Shift then
    PdfView.FocusPreviousFormField
  else
    PdfView.FocusNextFormField;
  UpdateFieldStatus;  // เช่น "Field 4 of 17: InvoiceDate"
end;

พฤติกรรมจุดเดียวที่ทำให้คนสับสนคือการวนกลับ การไล่ลำดับทำงานผ่านลำดับ tab ของหน้าปัจจุบันแล้ววนอยู่ในนั้น: ก้าวผ่านฟิลด์สุดท้ายไปแล้วคุณจะกลับมาที่ฟิลด์แรกอีกครั้ง ฟังก์ชันการก้าวทั้งสองตัวคืนค่าดัชนีฟิลด์ใหม่ หรือ -1 เมื่อหน้านั้นไม่มีฟิลด์เลย การวนนี้เกิดขึ้นต่อหน้า ไม่ใช่ต่อเอกสาร ซึ่งหมายความว่าการข้ามไปหน้าถัดไปเป็นหน้าที่ของคุณ ไม่ใช่ของไลบรารี ให้เทียบดัชนีที่ได้กับดัชนีที่คุณเริ่มต้นไว้ สังเกตว่ามันวนกลับมาแล้วหรือยัง แล้วเลื่อน PageNumber เองถ้าฟอร์มนั้นตั้งใจให้อ่านเป็นลำดับต่อเนื่องเดียว ถ้าข้ามการตรวจสอบนี้ไป ฟอร์มสองหน้าจะขังเคอร์เซอร์ไว้ที่หน้าแรกอย่างเงียบ ๆ ซึ่งเป็นอาการอีกแบบหนึ่งของปัญหา Tab เสีย

การไล่ลำดับจะมีประโยชน์จริงเมื่อ UI ส่วนที่เหลือตอบสนองต่อมันด้วย เหตุการณ์ OnFormFieldEnter จะยิงขึ้นเมื่อ focus มาถึง และบนตัวแสดงผล OnFormFieldFocusChange จะรายงานดัชนีฟิลด์ใหม่ เพื่อให้แผงด้านข้างตามทันสิ่งที่คีย์บอร์ดเพิ่งเลือกไป เมื่อคุณต้องการการแมปย้อนกลับ จากตำแหน่งบนหน้าจอไปยังฟิลด์ พร็อพเพอร์ตี้แบบดัชนี FormFieldAt จะทำการ hit-testing ให้สำหรับพรีวิว tooltip และแผงคลิกเพื่อแก้ไข ทั้งหมดนี้ยังมีผลพลอยได้เงียบ ๆ ด้านการช่วยการเข้าถึงด้วย: เพราะ focus เดินตามลำดับฟิลด์ของเอกสารเอง เส้นทางที่คุณต่อไว้สำหรับปุ่ม Tab จึงเป็นเส้นทางเดียวกับที่โปรแกรมอ่านหน้าจอประกาศ โดยไม่ต้องทำอะไรเพิ่มเลย

การแสดงชื่อฟิลด์แทนเลขดัชนีดิบต้องใช้พร็อพเพอร์ตี้อีกตัวหนึ่ง FormFieldInfo[] คืนค่าเป็นเรคคอร์ด TPdfFormFieldInfo ต่อดัชนีหนึ่งตัว ซึ่งบรรจุชื่อฟิลด์ ประเภท ขนาดฟอนต์ สถานะการติ๊กเลือก ค่า export และการเป็นสมาชิกกลุ่ม ซึ่งเป็นสิ่งที่รายการนำทางควรแสดง ("Field 4 of 17: InvoiceDate" แทนที่จะเป็นแค่ "4") กลุ่มปุ่มตัวเลือก (radio group) เป็นกรณีที่คุ้มค่าจะมีไฟล์ทดสอบเฉพาะ วิดเจ็ตหลายตัวสามารถใช้ชื่อฟิลด์เดียวกันได้ ดังนั้นรายการที่ประกอบขึ้นแบบไร้เดียงสาจากวิดเจ็ตจะแสดงกลุ่มเดียวกันซ้ำหลายครั้งและทำให้ทุกคนที่อ่านสับสน

ทำไมค่าที่กรอกแล้วถึงออกมาว่างเปล่า และฟังก์ชันที่แก้ปัญหานี้

ข้อร้องเรียนอีกอย่างที่ถมคิวซัพพอร์ตน่าตกใจกว่าปุ่ม Tab ที่ทำงานผิดปกติเสียอีก: ฟอร์มถูกกรอกด้วยโปรแกรม ลูกค้าเปิดมันใน Acrobat แล้วทุกฟิลด์ดูว่างเปล่า พอคลิกเข้าไปในฟิลด์ ค่าที่กรอกกลับปรากฏขึ้นทันที ข้อมูลอยู่ในไฟล์มาตลอด สิ่งที่ขาดหายไปคือ "ภาพ" ของข้อมูล และเหตุผลนั้นควรทำความเข้าใจไว้สักครั้งเพราะมันอธิบายบั๊กได้ทั้งตระกูลเลย

ฟิลด์ข้อความของ AcroForm เก็บค่าไว้ในรายการ /V ของ field dictionary (ISO 32000-1 §12.7.3.3) แต่สิ่งที่ตัวแสดงผลวาดออกมาจริง ๆ เป็นอีกเรื่องหนึ่งแยกต่างหาก: appearance stream ของวิดเจ็ตที่อยู่ใต้ /AP (§12.5.5) ซึ่งเป็นชิ้นเนื้อหาเล็ก ๆ ที่เรนเดอร์ไว้ล่วงหน้า เขียน /V แล้วปล่อย /AP ทิ้งไว้เฉย ๆ ทั้งสองอย่างก็จะเริ่มไม่ตรงกัน ค่ามีอยู่จริง แต่เวอร์ชันที่เรนเดอร์ออกมานั้นเก่าหรือไม่มีอยู่เลย Acrobat บังเอิญสร้าง appearance ของฟิลด์ใหม่เมื่อฟิลด์ได้ focus ซึ่งเป็นคำอธิบายทั้งหมดว่าทำไมค่าถึงปรากฏก็ต่อเมื่อคลิกเท่านั้น แฟล็ก NeedAppearances แบบเก่า ซึ่งขอให้ตัวแสดงผลสร้าง appearance ใหม่ให้เอง ไม่เคยทำงานได้สม่ำเสมอและถูกเลิกใช้ (deprecated) แล้วใน PDF 2.0 อีกทั้ง print server และตัวสร้าง thumbnail ก็เพิกเฉยต่อมันโดยสิ้นเชิง โปรแกรมพวกนี้วาดแค่ /AP เท่านั้น ดังนั้นถ้า /AP ว่างเปล่า มันก็จะพิมพ์กล่องเปล่าออกมา

การกำหนดค่าโดยผ่าน FormField[i] เขียนแค่ /V เท่านั้น นี่คือเหตุผลที่การกรอกฟอร์มเป็นลำดับสามขั้นตอน และขั้นตอนที่ทีมมักตกหล่นไปคือขั้นตอนตรงกลาง:

แผนภาพค่า /V เทียบกับการเหลื่อมของ appearance /AP ในฟิลด์ AcroForm และลำดับการกรอกของ Delphi สามขั้นที่สร้างรอบ GenerateFormAppearances
การกำหนดค่าเขียนเฉพาะ /V และขั้นตอนกลางคือขั้นที่วาดใหม่สิ่งที่เซิร์ฟเวอร์พิมพ์เรนเดอร์จริง
procedure TFormViewer.FillAndSave(const Values: array of WString;
  const OutputPath: string);
var
  i: Integer;
begin
  for i := 0 to Pdf.FormFieldCount - 1 do
    Pdf.FormField[i] := Values[i];   // เขียนแค่ /V เท่านั้น

  // สร้าง appearance stream ของ /AP ใหม่; ถ้าไม่ทำขั้นตอนนี้ ฟอร์ม
  // จะดูว่างเปล่าใน Acrobat จนกว่าจะมีการคลิกแต่ละฟิลด์
  Pdf.GenerateFormAppearances;

  Pdf.SaveAs(OutputPath);
end;

GenerateFormAppearances คือทางแก้ทั้งหมดในตัวเดียว มันสร้าง appearance stream ของทุกวิดเจ็ตขึ้นใหม่จากค่าปัจจุบัน ฟอนต์ และการจัดวางข้อความ (quadding) ทำให้ตัวแสดงผลที่ไม่เคยเรียก focus event เลย อย่าง print server หรือตัวสร้าง thumbnail ก็ยังวาดสถานะที่กรอกแล้วออกมาได้ ให้เรียกมันครั้งเดียวหลังจากกำหนดค่าเป็นชุดเสร็จแล้ว ไม่ใช่เรียกทีละฟิลด์ เพราะการสร้าง appearance เป็นงาน layout จริง ๆ การเรียกทีละฟิลด์จะทวีคูณงานนั้นไปทั่วทั้งฟอร์มขนาดใหญ่โดยเปล่าประโยชน์

การสร้าง appearance ใหม่ยังเป็นจังหวะที่ฟอนต์และการจัดตำแหน่งแสดงตัวตนออกมาด้วย ซึ่งเป็นที่มาของความประหลาดใจขั้นที่สอง สตรีมใหม่จะจัดวางแต่ละค่าลงในสี่เหลี่ยมของวิดเจ็ตโดยใช้ฟอนต์ ขนาด และการจัดวางข้อความของฟิลด์นั้น ค่าที่พอดีสบาย ๆ ในฟอร์มทดสอบของคุณ อาจถูกตัดหรือหดตัวในสำเนาของลูกค้าที่ฟิลด์เดียวกันแคบกว่า ฟิลด์แบบปรับขนาดอัตโนมัติ (ขนาดฟอนต์เป็นศูนย์) จะหดข้อความให้พอดี ส่วนฟิลด์ขนาดตายตัวก็แค่ตัดข้อความทิ้ง ทั้งสองแบบถูกต้องตามกติกา และวิธีเดียวที่จะรู้ได้จริงว่าฟอร์มหนึ่ง ๆ ทำแบบไหน คือดูที่ผลลัพธ์ที่สร้างใหม่ ไม่ใช่ดูจากสตริงที่คุณเขียน เมื่อมีคนแจ้งว่าข้อความถูกตัดที่ขอบกล่อง สาเหตุมักจะเป็นเรื่องนี้เกือบทุกครั้ง

ให้ถือว่าการตรวจสอบเป็นส่วนหนึ่งของการทำงานให้เสร็จสมบูรณ์ ไม่ใช่สิ่งที่นึกขึ้นได้ทีหลัง เปิดไฟล์ที่บันทึกแล้วใน Acrobat และยืนยันว่าค่าต่าง ๆ มองเห็นได้ก่อนที่จะแตะฟิลด์ใด ๆ จากนั้นพิมพ์มันออกเป็น PDF หรือเป็นภาพจากตัวแสดงผลอีกตัวหนึ่งที่ไม่สนใจตรรกะของฟอร์มเลย แล้วยืนยันว่าค่าต่าง ๆ รอดผ่านเส้นทางนั้นด้วยเช่นกัน การตรวจสอบสองแบบนี้รวมกันจับความคลาดเคลื่อนระหว่าง /V กับ /AP ได้ทุกรูปแบบ

การตั้งค่าฟิลด์ที่ผ่านการสาธิตแต่พังจริงเมื่อใช้งาน

ฟอร์มสาธิตที่สะอาดหมดจดมักซ่อนกรณีขอบ (edge case) ชุดหนึ่งที่ไฟล์ของลูกค้าไม่ยอมซ่อนไว้ สี่กรณีต่อไปนี้คือต้นเหตุของรายงานแบบ "มันทำงานได้บนเครื่องผม" ส่วนใหญ่

  • ค่า export ของ checkbox สถานะ "on" ไม่ได้เป็น Yes เสมอไป ฟอร์มมีสิทธิ์กำหนดค่า export ของตัวเองได้อย่างอิสระ และการเขียนสตริงผิดจะทำให้กล่องดูเหมือนยังไม่ถูกติ๊กเลือกทั้งที่โค้ดของคุณเชื่อว่าตั้งค่าไปแล้ว ให้อ่านค่า export จาก FormFieldInfo[] แทนที่จะเดาเอาเอง
  • กลุ่มปุ่มตัวเลือกที่ใช้ชื่อร่วมกัน ฟิลด์เดียว แต่มีวิดเจ็ตหลายตัว ค่าที่คุณกำหนดจะเป็นตัวตัดสินว่าวิดเจ็ตตัวไหนจะอ่านว่าถูกเลือก ดังนั้นโค้ด UI ที่สมมติว่าหนึ่งชื่อแมปกับหนึ่งสี่เหลี่ยม จึงลงเอยด้วยการวาดกรอบ focus ผิดปุ่ม
  • ฟิลด์ที่คำนวณอัตโนมัติ ยอดรวมที่ดูแลด้วย JavaScript ของเอกสารจะอัปเดตตามเหตุการณ์ของฟิลด์ การกรอกด้วยโปรแกรมที่ข้ามเหตุการณ์เหล่านั้นไปต้องเลือกอย่างใดอย่างหนึ่ง คือสั่งให้คำนวณใหม่ หรือเขียนทับฟิลด์ที่คำนวณโดยตรง ฟอร์มที่รายการย่อยกับยอดรวมไม่ตรงกันแย่ยิ่งกว่าทางแก้ทั้งสองแบบเสียอีก
  • ฟิลด์บังคับกรอกที่ถูกซ่อนไว้ ฟอร์มแบบมีเงื่อนไขจะซ่อนฟิลด์ที่ยังคงถูกตั้งค่าเป็นบังคับกรอกอยู่ ตัดสินใจล่วงหน้าว่าการตรวจสอบความถูกต้องของคุณจะยึดตามการมองเห็นได้หรือยึดตามแฟล็กบังคับกรอกดิบ แล้วบันทึกการตัดสินใจนั้นไว้ในที่ที่ทีมซัพพอร์ตหาเจอ

มีความแตกต่างจุดหนึ่งที่ควรทำความเข้าใจให้ชัดก่อนที่มันจะย้อนกลับมากัดคุณ: การสร้าง appearance ไม่ใช่การ flatten GenerateFormAppearances ทำให้ค่ามองเห็นได้ทุกที่โดยยังคงให้แก้ไขฟิลด์ได้อยู่ ส่วนการ flatten จะฝัง appearance ลงไปเป็นเนื้อหาหน้าแบบคงที่และตัดความสามารถในการโต้ตอบทิ้งไปถาวร ซึ่งเหมาะกับสำเนาสำหรับเก็บถาวร แต่ไม่เหมาะกับฟอร์มที่คนถัดไปยังต้องกรอกอยู่ ถ้า FormType รายงานว่าเป็น ftXfaFull แทนที่จะเป็น ftAcroForm พื้นที่การแก้ไขทั้งหมดที่กล่าวมานี้จะใช้ไม่ได้อย่างเรียบร้อยอยู่ดี เพราะเอกสารเรนเดอร์จากเทมเพลต XML ของตัวเอง ให้ตรวจจับกรณีนี้แล้วบอกผู้ใช้ไปตรง ๆ ดีกว่าปล่อยให้เขาไปเจอข้อจำกัดนี้เอง

ระบบย่อย form-fill การไล่ลำดับ focus และการสร้าง appearance ที่แสดงในบทความนี้เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus/FPC ถ้าตัวแสดงผลของคุณจัดการมาร์กอัปของผู้ตรวจทานควบคู่ไปกับข้อมูลฟอร์มด้วย บทความการตรวจทานคำอธิบายประกอบครอบคลุมโมเดลที่เกี่ยวข้องนั้น