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

การสร้างฟิลด์และแอ็กชัน AcroForm ด้วย HotPDF ใน Delphi

AcroForm action คือ dictionary ที่แนบอยู่กับ widget ซึ่งบอก viewer ว่าต้องทำอะไรเมื่อมีเหตุการณ์เกิดขึ้นกับ widget นั้น เมื่อคลิกปุ่ม viewer จะอ่าน action dictionary ของปุ่มนั้น: URI action จะเปิดเว็บแอดเดรส, JavaScript action จะรันสคริปต์, SubmitForm action จะส่งค่าฟิลด์ที่รวบรวมไว้ไปยัง endpoint, ResetForm action จะล้างค่ากลับไปเป็นค่าเริ่มต้น action คือข้อมูล ไม่ใช่พฤติกรรมที่ฝังตายตัวอยู่ในไฟล์ ISO 32000-1 §12.6 กำหนดรูปแบบของ dictionary ไว้ ส่วน viewer เป็นผู้จัดหา engine ที่ตีความมัน ความแยกส่วนนี้สำคัญเพราะ action ที่เขียนลงใน PDF อย่างสมบูรณ์แบบก็ยังไม่ทำอะไรเลยถ้าฝั่งผู้อ่านไม่มี engine รองรับ และปัญหาความยุ่งยากของ AcroForm ส่วนใหญ่ก็ย้อนกลับไปที่ช่องว่างนี้มากกว่าที่จะมาจากฟิลด์ที่สร้างผิดรูปแบบ

HotPDF เขียน dictionary เหล่านี้โดยตรงจาก Delphi และ C++Builder ควบคู่ไปกับ field widget ที่มันแขวนอยู่ด้วย ทุกฟอร์มแบบโต้ตอบมีโครงสร้างสองส่วนที่ทำงานร่วมกัน: widget ที่ผู้ใช้เห็นบนหน้ากระดาษ และกลไก field บวก action ที่อยู่เบื้องหลังซึ่งรับผิดชอบข้อมูลและการเชื่อมโยง ทั้งสองส่วนถูกแก้ไขแยกจากกัน และส่วนใดส่วนหนึ่งอาจผิดพลาดได้ในขณะที่อีกส่วนดูเหมือนไม่มีปัญหา หัวข้อด้านล่างจะไล่เรียงเรื่องการตั้งชื่อฟิลด์ ตัว action ของปุ่มเอง JavaScript ระดับฟิลด์ และประเภทของข้อบกพร่องที่รอดพ้นการตรวจสอบด้วยสายตาเพราะมันอยู่ในโครงสร้างส่วนที่สองทั้งหมด

ชั้นวิดเจ็ต AcroForm ใน HotPDF ที่จับคู่กับค่าฟิลด์พื้นฐาน, action dictionary การส่ง และความไม่ตรงกันของ export value การยินยอม
ผู้ใช้คลิกที่เลเยอร์วิดเจ็ต ขณะที่ค่าเดินทางผ่านเลเยอร์ฟิลด์และเลเยอร์ action ที่อยู่ใต้ลงไป ซึ่งความไม่ตรงกันยังคงมองไม่เห็น

ชื่อฟิลด์คือ routing key ไม่ใช่ caption

ทุกฟิลด์ใน AcroForm มีชื่อแบบ fully qualified ISO 32000-1 §12.7.3 กำหนดให้ชื่อนี้ ไม่ใช่ caption ที่มองเห็น เป็นคีย์ที่ค่าของฟิลด์เดินทางไปด้วยเมื่อฟอร์มถูก export หรือ submit นักพัฒนาที่มาจากงานออกแบบ VCL มักมีแนวโน้มมองชื่อของ control ว่าเป็น identifier ภายในโค้ดส่วนตัว แต่ที่นี่ไม่ใช่แบบนั้น มันคือรูปแบบข้อมูลที่ส่งผ่านสาย

สิ่งแรกที่ตามมาคือ ฟิลด์สองฟิลด์ที่มีชื่อแบบ fully qualified เหมือนกันไม่ใช่ฟิลด์สองฟิลด์ PDF ถือว่าทั้งคู่เป็น widget annotation สองอันของฟิลด์เดียว ใช้ค่าร่วมกันหนึ่งค่า ดังนั้นการพิมพ์ในอันหนึ่งจะอัปเดตอีกอันทันที นั่นคือสิ่งที่ต้องการพอดีเมื่อชื่อลูกค้าต้องปรากฏซ้ำในทุกหน้าของสัญญา แต่มันคือบั๊กเมื่อ loop สร้างฟอร์มใช้ชื่อ 'Field1' ซ้ำกันในสามหน้าโดยไม่ได้ตั้งใจ การตรวจด้วยสายตาไม่สามารถจับกรณีที่สองได้ แต่ละหน้ายังคงวาดกล่องของตัวเองตามปกติ และการเชื่อมโยงกันจะปรากฏให้เห็นก็ต่อเมื่อมีคนเริ่มพิมพ์เท่านั้น

ชื่อแบบมีจุดคั่น เช่น applicant.email สร้างลำดับชั้นขึ้นมา โหนดแม่ applicant จะรวบรวมโหนดลูกไว้ด้วยกัน ซึ่งเป็นสิ่งที่ทำให้ reset หรือ submit สามารถเจาะจงเป้าหมายไปที่ส่วนหนึ่งของฟอร์มเท่านั้นได้ การตั้งชื่อฟิลด์แบบนี้ตั้งแต่แรกไม่มีต้นทุนอะไรเลย และมันจะคุ้มค่าทันทีที่ระบบปลายทางร้องขอเฉพาะส่วน applicant เพียงอย่างเดียว

radio button มีกฎเป็นของตัวเอง ปุ่มที่ควรสลับพร้อมกันต้องใช้ชื่อ group ร่วมกัน ใน HotPDF การเรียก AddRadioButton ที่ส่งชื่อ group เดียวกันจะแนบ widget ของมันเข้ากับฟิลด์แม่ตัวเดียวกัน และ export value ของแต่ละปุ่ม ('basic' หรือ 'full') จะระบุตัวเลือกที่ถูกเลือก ถ้าตั้งชื่อให้ทุกปุ่มแตกต่างกันหมด ผลที่ได้จะกลายเป็นแถวของสวิตช์เปิด/ปิดอิสระต่อกัน แทนที่จะเป็นกลุ่มที่เลือกได้เพียงตัวเดียวแบบ mutually exclusive ซึ่งแสดงผลเหมือนกันทุกประการแต่ทำงานผิดพลาด

การสร้างชุดฟิลด์ทีละหน้า

HotPDF วางฟิลด์ผ่านเมธอดของ THPDFPage ดังนั้นทุกฟิลด์จึงเป็นของ page object ที่สร้างมันขึ้นมา กับดักด้านลำดับการเรียกที่ต้องระวังคือ AddPage มันจะชี้ CurrentPage ไปยังหน้าใหม่ทันทีที่ return กลับมา ดังนั้นการเรียกฟิลด์ใด ๆ หลังจากนั้นจะตกไปอยู่ที่หน้าใหม่ แม้ว่าฟิลด์นั้นในทางตรรกะควรเป็นของหน้าที่เพิ่งออกมาก็ตาม ให้ทำหน้าแต่ละหน้าให้เสร็จสมบูรณ์ ทั้งเนื้อหาที่วาดและฟิลด์พร้อมกัน ก่อนที่จะเรียก AddPage

procedure BuildClaimForm(Pdf: THotPDF);
begin
  // หน้า 1: ส่วนของผู้ยื่นคำร้อง
  Pdf.CurrentPage.AddTextField('applicant.name', '', Rect(50, 700, 300, 722));
  Pdf.CurrentPage.AddTextField('applicant.email', '', Rect(50, 660, 300, 682));
  Pdf.CurrentPage.AddCheckBox('consent', 'Y', Rect(50, 620, 70, 640), False);
  Pdf.CurrentPage.AddRadioButton('coverage', 'basic', Rect(50, 580, 70, 600), True);
  Pdf.CurrentPage.AddRadioButton('coverage', 'full', Rect(90, 580, 110, 600), False);
  Pdf.CurrentPage.AddComboBox('plan', 'Standard',
    ['Basic', 'Standard', 'Premium'], Rect(50, 540, 200, 565));

  Pdf.AddPage;  // ตอนนี้ CurrentPage ชี้ไปที่หน้า 2 แล้ว
  Pdf.CurrentPage.AddListBox('riders', 'None',
    ['None', 'Flood', 'Earthquake'], Rect(50, 500, 200, 600));
end;

พิกัดใช้ตามธรรมเนียมของ PDF โดยจุดกำเนิดอยู่ที่มุมล่างซ้ายของหน้า นี่คือจุดกำเนิดเดียวกับที่ TextOut ใช้สำหรับข้อความที่วาด ดังนั้น Rect(50, 100, 200, 120) จะอยู่ใกล้ด้านล่างของหน้ากระดาษขนาด Letter ไม่ใช่ด้านบน VCL วางแกน Y ไว้ที่ด้านบนแล้วขยายลงล่าง ดังนั้นตารางเลย์เอาต์ที่ย้ายมาตรง ๆ จะกลับด้านในแนวตั้ง ทุกฟิลด์ถูกพลิกไปอยู่ผิดฝั่งของหน้า ให้ทำการแปลงค่าเพียงครั้งเดียวใน shared helper แทนที่จะทำที่จุดเรียกใช้แต่ละแห่ง แล้วการแก้ไขเพียงจุดเดียวก็จะซ่อมทั้งฟอร์มได้

การเชื่อมปุ่มเข้ากับ action แบบ URI, JavaScript และ submit

push button จะไม่ทำอะไรเลยจนกว่าจะมี action แนบเข้าไป HotPDF เปิดให้ใช้ประเภท action ตาม ISO 32000-1 §12.6.4 ผ่าน enumeration THPDFButtonAction (baURI, baJavaScript, baSubmitURL, baResetForm, baHide, baShow, baNamed) และมีเมธอดสองตัวที่สร้างปุ่มพร้อมผูก action เข้าไปในการเรียกครั้งเดียว

ชนิด action ปุ่มกดของ HotPDF ใน Delphi: ลิงก์ baURI, สคริปต์ baJavaScript และ SubmitForm ที่ส่งข้อมูลพร้อม flag รูปแบบที่ระบุชัดเจน
การเรียก binding หนึ่งครั้งผูก action dictionary ได้ทั้งสามแบบ และมีเพียงแบบ submit เท่านั้นที่แบกสัญญาฟลากไปกับ endpoint ปลายทาง
// เปิดหน้าช่วยเหลือในเบราว์เซอร์ของระบบ
Pdf.CurrentPage.AddPushButtonWithAction('btnHelp', 'Help',
  'https://www.example.com/claims-help', Rect(320, 700, 420, 730), baURI);

// รัน JavaScript ฝั่ง viewer
Pdf.CurrentPage.AddPushButtonWithAction('btnRecalc', 'Recalculate',
  'app.alert("Totals updated.");', Rect(320, 660, 420, 690), baJavaScript);

// ส่งข้อมูลแบบ XFDF และเก็บฟิลด์ที่ว่างไว้ใน payload ด้วย
Pdf.CurrentPage.AddPushButtonWithSubmitAction('btnSubmit', 'Submit claim',
  'https://api.example.com/claims', Rect(320, 620, 420, 650),
  [sffXFDF, sffIncludeNoValueFields]);

submit flag สมควรได้รับความคิดมากกว่าที่มักจะได้รับ AddPushButtonWithSubmitAction รับ set ชนิด THPDFSubmitFormFlags และถ้า set ว่างเปล่าจะได้ผลลัพธ์เป็น post แบบ url-encoded ธรรมดา ซึ่งเป็นรูปแบบที่ endpoint ตัวอย่างจำนวนมากยอมรับ แต่ endpoint ที่ใช้งานจริงจำนวนมากปฏิเสธ การเพิ่ม sffXFDF จะเปลี่ยน payload ให้เป็น XFDF sffGetMethod เปลี่ยน HTTP verb ที่ใช้ sffIncludeNoValueFields จะเก็บฟิลด์ที่ว่างไว้ใน payload แทนที่จะตัดทิ้งไปเงียบ ๆ ซึ่งสำคัญมากในจังหวะที่ฝั่งรับต้องแยกความหมายระหว่าง "ไม่มีค่า" กับ "ว่างเปล่า" ชุดของ flag เหล่านี้เป็นส่วนหนึ่งของสัญญาอินเทอร์เฟซกับ endpoint ปลายทาง จึงควรตกลงกันกับทีมที่ parse ข้อมูลที่ส่งเข้ามาให้เรียบร้อยก่อน ไม่ใช่หลังจาก batch แรกถูกปฏิเสธไปแล้ว

JavaScript ระดับฟิลด์: keystroke, format, validate

action ไม่ได้อยู่แค่ที่การคลิกปุ่มเท่านั้น HotPDF ยังแนบ JavaScript เข้ากับ event ระดับฟิลด์ที่ viewer ซึ่งรองรับสคริปต์จะยิงขึ้นขณะผู้ใช้กำลังกรอกข้อมูล มี trigger อยู่สามแบบ และแต่ละแบบจะยิงในจังหวะที่ต่างกันของวงจรการป้อนข้อมูล keystroke action จะรันทุกครั้งที่ตัวอักษรแต่ละตัวมาถึง และรันอีกครั้งตอน commit format action จะเขียนค่าที่แสดงผลใหม่หลังจากการเปลี่ยนแปลง commit แล้ว เพื่อการแสดงผลล้วน ๆ validate action เป็นผู้ตัดสินคนสุดท้าย จะยอมรับหรือปฏิเสธค่าที่ commit ก่อนที่มันจะกลายเป็นค่าจริงของฟิลด์

วงจรชีวิตเหตุการณ์ JavaScript ระดับฟิลด์ของ HotPDF จาก keystroke ไป validate แล้วไป format พร้อมคำเตือนการตรวจสอบฝั่งเซิร์ฟเวอร์ด้านล่าง
สคริปต์ keystroke และ validate ปฏิเสธอินพุตได้ ขณะที่ format แค่แต่งการแสดงผล และไม่มีสคริปต์ใดรอดอยู่ในผู้อ่านที่ไม่มีเอนจิน JavaScript
// ปฏิเสธค่าที่ commit แล้วซึ่งไม่ใช่รูปแบบอีเมลที่เป็นไปได้
Pdf.AttachFieldKeyStrokeAction('applicant.email',
  'if (event.willCommit && !/^[\w.-]+@[\w.-]+\.\w+$/.test(event.value)) event.rc = false;');

// แสดงเบอร์โทรศัพท์สหรัฐฯ ในรูปแบบ (NNN) NNN-NNNN
Pdf.AttachFieldFormatAction('applicant.phone',
  'event.value = event.value.replace(/(\d{3})(\d{3})(\d{4})/, "($1) $2-$3");');

// ปฏิเสธผู้ยื่นคำร้องที่อายุต่ำกว่า 18 ปีตอน commit
Pdf.AttachFieldValidateAction('applicant.age',
  'if (parseInt(event.value) < 18) event.rc = false;');

การตั้ง event.rc = false ภายในสคริปต์ keystroke หรือ validate จะบอกให้ viewer ปฏิเสธข้อมูลที่ป้อนเข้ามา ข้อควรระวังคือ ทั้งหมดนี้จะไม่ทำงานเลยถ้า viewer ไม่มี JavaScript engine ติดตั้งมาด้วย Acrobat และผลิตภัณฑ์เดสก์ท็อปบางตัวมี engine นี้ แต่ mobile reader ส่วนใหญ่ ตัว renderer ที่ฝังอยู่ในเบราว์เซอร์ และ pipeline การพิมพ์ส่วนใหญ่ไม่มี และพวกมันจะทิ้งสคริปต์เหล่านี้ไปเฉย ๆ โดยไม่แจ้งเตือนอะไร ดังนั้น field script จึงช่วยยกระดับคุณภาพข้อมูลได้เฉพาะกลุ่มผู้ใช้ที่ reader ของพวกเขารันมันได้เท่านั้น และนั่นคือทั้งหมดที่มันทำได้ มันไม่ใช่ขอบเขตความปลอดภัย ค่าทุกค่าที่ส่งเข้ามายังคงต้อง validate บนเซิร์ฟเวอร์อีกครั้งเมื่อมันมาถึง เพราะเราไม่สามารถสมมติได้ว่าฝั่ง client ตรวจสอบอะไรมาแล้ว

ข้อบกพร่องที่ผ่านการตรวจสอบด้วยสายตาไปได้

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

  • ค่าที่ export เพี้ยนไปจากที่คาด checkbox ที่สร้างด้วย AddCheckBox('consent', 'Yes', ...) จะส่งค่า Yes ออกไป ฝั่งที่รับข้อมูลถ้าเทียบกับ Y จะปฏิเสธทุกการส่งข้อมูลทั้งที่หน้าตาดูสมบูรณ์แบบ ให้กรอกฟอร์ม export เป็น XFDF จาก Acrobat แล้ว diff ค่ากับ schema ที่ฝั่งรับคาดหวังจริง ๆ
  • ค่าถูกสะท้อนซ้ำกันโดยไม่ตั้งใจ ฟิลด์สองฟิลด์ที่ใช้ชื่อแบบ fully qualified ร่วมกันจะรวมเป็นฟิลด์เดียว อาการนี้จะปรากฏตอนกรอกข้อมูล ไม่ใช่ตอนสร้างฟอร์ม ดังนั้นการทดสอบที่ถูกต้องคือลองพิมพ์ลงในฟอร์มจริง ๆ ไม่ใช่แค่ render แล้วมองด้วยตา
  • ค่าใน combo box อยู่นอกรายการตัวเลือก เมื่อค่าปัจจุบันที่ส่งให้ AddComboBox ไม่ใช่หนึ่งในตัวเลือกที่ระบุไว้ viewer แต่ละตัวจะไม่เห็นตรงกันว่าจะแสดงค่านั้น เว้นว่าง หรือตีธงเตือน ให้ค่าเริ่มต้นอยู่ในรายการเสมอ แล้วความไม่ลงรอยกันนี้ก็จะหายไป
  • ฟิลด์ยังแก้ไขได้แม้ workflow จะปิดไปแล้ว HotPDF ไม่มีเมธอด flatten appearance สำหรับฟิลด์ AcroForm วิธีที่รองรับสำหรับการล็อกฟอร์มที่กรอกเสร็จแล้วคือสร้างฟิลด์ด้วยแฟล็ก ffReadOnly ซึ่งจะคงค่าให้มองเห็นได้ผ่าน appearance stream ของฟิลด์เองในขณะที่ปฏิเสธการแก้ไข ฟิลด์ยังคงเป็น form object ที่ใช้งานอยู่จริง ซึ่งเป็นสิ่งที่เครื่องมือ assembly และ signing ปลายทางคาดหวังจะพบ

มีพฤติกรรมฝั่ง viewer อยู่อย่างหนึ่งที่ควรจดบันทึกไว้เป็น regression note แม้จะไม่มีการแก้โค้ดใดจัดการมันได้ก็ตาม การติดตั้ง Acrobat ระดับองค์กรสามารถปิด JavaScript หรือจำกัด submit target ได้ตามนโยบาย ดังนั้น action ที่ทำงานได้ดีตลอด development build ทุกตัวอาจนิ่งสนิทไม่ทำอะไรเลยบนเดสก์ท็อปลูกค้าที่ถูกล็อกไว้ ให้วางแผนสำรองที่มองเห็นได้ไว้สำหรับกรณีที่ปุ่มไม่ทำอะไรเลย แม้ว่าตัวสำรองนั้นจะเป็นแค่คำแนะนำที่พิมพ์ไว้บอกผู้ใช้ว่าควรทำอะไรแทนก็ตาม

จุดที่งานฟอร์มเชื่อมโยงกับส่วนอื่นของเอกสาร

signature field ก็คือฟิลด์ AcroForm ประเภทหนึ่งเช่นกัน ฟอร์มที่จะถูก certify หรือ counter-sign ในภายหลังนั้น จะดีกว่าถ้าจองฟิลด์นั้นไว้ตั้งแต่ตอนสร้างฟอร์ม แทนที่จะมาแปะเพิ่มทีหลัง และเหตุผลระดับไบต์ว่าทำไมถึงเป็นแบบนั้นอยู่ในบทความคู่กันเรื่องลายเซ็นดิจิทัลและการเซ็น PAdES ด้วย HotPDF ส่วนอินพุตที่มาในรูปแบบแพ็กเกจ XFA แทนที่จะเป็น AcroForm แบบดั้งเดิมนั้นเป็นสถานการณ์ที่ต่างออกไป: การflatten XFA ให้กลายเป็นฟิลด์ AcroFormเป็น workflow ของตัวมันเองที่มี loss model เป็นของตัวเอง เพราะเทคโนโลยีฟอร์มทั้งสองแบบไม่สามารถอยู่ร่วมกันในไฟล์เดียวได้

เมธอดของฟิลด์ action และ trigger ที่แสดงในบทความนี้เป็นส่วนหนึ่งของ API มาตรฐานของHotPDF Delphi Componentสำหรับ Delphi และ C++Builder หน้าผลิตภัณฑ์มีลิงก์ไปยังเอกสารอ้างอิงฉบับเต็ม รวมถึง overload ของ field-flag และ enumeration ของ submit-flag แบบครบถ้วน