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

เพิ่มฟิลด์ AcroForm ลงใน PDF ที่โหลดมาแล้วใน Delphi

คุณอาจมีเทมเพลตใบแจ้งหนี้จากผู้ให้บริการรายอื่น หรือสัญญาเก่าที่ใครสักคนสร้างไว้เมื่อหลายปีก่อนด้วยซอฟต์แวร์ที่ตอนนี้หาไม่เจอแล้ว และโจทย์คือทำให้มันโต้ตอบได้จริง เช่น ใส่ช่องลายเซ็นที่มุม เพิ่มช่องข้อความสองสามช่อง หรือเปลี่ยนเช็กลิสต์แบบเรียบๆ ให้เป็นเช็กบ็อกซ์จริง ปัญหาคือคุณไม่ได้กำลังสร้าง PDF ฉบับนี้จากศูนย์ มันมีอยู่แล้ว มีหน้า มี content stream และมีฟอนต์ที่คุณควบคุมไม่ได้ และคุณต้องผนวกวิดเจ็ต AcroForm ลงใน object graph นั้นโดยไม่ต้องสร้างมันใหม่ นี่เป็นปัญหาคนละแบบกับการสร้างฟอร์มบนเอกสารใหม่ และจุดที่มักทำให้คนพลาดคือมองไม่เห็นความผิดปกติจนกว่าจะเปิดผลลัพธ์ใน viewer แล้วพบว่าฟิลด์ที่เพิ่งเขียนลงไปไม่ปรากฏบนหน้าเลย

HotPDF เป็นคอมโพเนนต์ PDF แบบ native VCL สำหรับ Delphi และ C++Builder และตั้งแต่ v2.247.0 เป็นต้นมา มันมีชุดเมธอดเฉพาะสำหรับงานนี้โดยตรง คือสร้างฟิลด์มาตรฐานทั้งหกชนิดบนเอกสารที่โหลดด้วย LoadFromFile บทความนี้จะพาไล่ดูว่าเมธอดเหล่านั้นทำอะไร สร้าง dictionary ตาม ISO 32000-1 แบบไหน และแฟลกตัวเดียวที่ถ้าไม่เปิดไว้ ทุกอย่างจะบันทึกออกมาเป็นไฟล์ที่ดูว่างเปล่าโดยไม่มีอาการผิดพลาด

ทำไมการสร้างฟิลด์บนเอกสารที่โหลดมาแล้วจึงต้องแยกเส้นทางโค้ด

ตอนคุณสร้าง PDF ขึ้นมาจากศูนย์ HotPDF จะถือ object model ทั้งหมดไว้เอง หน้าแต่ละหน้าจะเป็น wrapper THPDFPage ที่แก้ไขได้ และการเพิ่มฟิลด์ข้อความผ่าน AddTextField จะเชื่อมวิดเจ็ตใหม่เข้ากับ annotation object ของหน้า, page object เอง และ collection ของฟิลด์ในฟอร์ม จากนั้นจึงสร้าง appearance stream จาก resource ฟอนต์ของเอกสาร Appearance stream คือพื้นผิวที่มองเห็นได้ของวิดเจ็ต ทั้งกล่อง เส้นขอบ และข้อความเริ่มต้นใดๆ ที่ถูกวาดเป็น PDF drawing operators แล้ว viewer ก็เรนเดอร์ตามนั้นตรงๆ

แต่เอกสารที่โหลดเข้ามาให้ scaffolding แบบนั้นไม่ได้เลย หน้าเข้ามาในรูป dictionary ดิบๆ ไม่มี wrapper THPDFPage แบบเขียนได้ให้ผูกวิดเจ็ต และที่สำคัญกว่านั้นคือไม่มี pipeline ของ font resource ที่คอยวาด appearance stream เส้นทาง loaded จึงใช้คนละวิธี มันเขียน dictionary ของฟิลด์ลงบน object graph ที่ parse แล้วโดยตรง และอ้างถึงหน้าด้วยดัชนีเริ่มจากศูนย์แทนที่จะอ้างด้วย page object ชนิดของฟิลด์และบิตแฟลกยังตรงกับเส้นทาง from-scratch ทุกประการ ดังนั้น Text field ก็ยังเป็น Text field เหมือนเดิม สิ่งที่เปลี่ยนคือกลไกเบื้องหลัง และที่สำคัญที่สุดคือพื้นผิวของวิดเจ็ตถูกวาดอย่างไร

แฟลก /NeedAppearances ไม่ใช่ตัวเลือกในกรณีนี้

นี่คือข้อเท็จจริงเพียงข้อเดียวที่ตัดสินว่างานของคุณจะปรากฏหรือไม่ เพราะเส้นทาง loaded ไม่ได้สร้าง appearance stream วิดเจ็ตที่เพิ่มใหม่จึงมาถึง viewer โดยไม่มีรายการ /AP ซึ่งหมายถึงฟิลด์ที่ยังไม่มี surface อธิบายไว้ Viewer จำนวนมากเมื่อถูกขอให้เรนเดอร์วิดเจ็ตที่ไม่มี appearance และไม่มีคำสั่งให้สร้างขึ้นเอง ก็จะวาดอะไรไม่ออกเลย ฟิลด์มีอยู่ในไฟล์ ถูกต้องตามโครงสร้าง เข้าถึงได้ด้วยเครื่องมือกรอกฟอร์ม และมนุษย์มองไม่เห็น

ทางออกมีอธิบายไว้ใน ISO 32000-1 §12.7.3 คือ dictionary ของ AcroForm มีบูลีน /NeedAppearances และเมื่อมันเป็น true ผู้อ่านที่เป็นไปตามมาตรฐานต้องสร้าง appearance stream ที่ขาดหายไปขึ้นมาเองจากสตริง /DA (default appearance) และค่าของแต่ละฟิลด์ HotPDF ตั้งค่านี้ไว้ให้คุณตั้งแต่ต้น ตอนที่คุณเพิ่มฟิลด์ใดๆ ลงในเอกสารที่โหลดมาเป็นครั้งแรก EnsureLoadedAcroForm จะทำงาน ถ้า catalog ไม่มี /AcroForm มันก็สร้างให้ ถ้าไม่มี array /Fields มันก็สร้างให้ และบังคับ /NeedAppearances true คุณไม่ต้องเรียกมันตรงๆ แต่การรู้ว่ามันมีอยู่ช่วยอธิบายพฤติกรรมได้ และยังอธิบายข้อควรระวังตอนใช้งานจริงที่ควรพูดตรงๆ ด้วยว่า viewer บางตัวที่เล็กหรือไม่เป็นมาตรฐานจะมองข้าม /NeedAppearances และยังคงไม่แสดงอะไร สำหรับ reader กระแสหลักแฟลกนี้ทำงานตามหน้าที่ แต่ถ้ากลุ่มผู้ใช้ของคุณใช้ embedded renderer แปลกๆ ควรทดสอบบนตัวนั้นก่อนจะสัญญาอะไร

การเพิ่มฟิลด์ทั้งหกชนิด

แต่ละเมธอดมีรูปแบบเดียวกัน คุณส่งดัชนีหน้าแบบเริ่มจากศูนย์ มุมทั้งสี่ของสี่เหลี่ยมวิดเจ็ตในพิกัด user-space ของ PDF ชื่อฟิลด์ และอาร์กิวเมนต์เสริมตามที่ชนิดนั้นต้องใช้ Rectangle คือ X1, Y1, X2, Y2 โดยจุดกำเนิดของ PDF อยู่มุมล่างซ้ายของหน้า ดังนั้นค่า Y ที่มากกว่าจะอยู่สูงกว่า นี่คือธรรมเนียมพิกัดจากรูปแบบไฟล์ ไม่ใช่ธรรมเนียมแบบหน้าจอที่เริ่มจากมุมบนซ้าย และการสลับมันผิดด้านคือความพลาดที่พบบ่อยเป็นอันดับสองรองจากลืมเปิดแฟลก แต่ละการเรียกจะคืนดัชนีเริ่มจากศูนย์ของฟิลด์ใหม่ หรือ -1 ถ้าดัชนีหน้าอยู่นอกช่วงหรือหา page object ไม่เจอ

var
  Pdf: THotPDF;
  Idx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract.pdf') <= 0 then Exit;

    // Text field: name, initial value, max length (0 = unlimited)
    Idx := Pdf.AddLoadedTextField(0, 72, 680, 320, 700, 'FullName', '', 0);

    // CheckBox: export value, initial checked state
    Pdf.AddLoadedCheckBox(0, 72, 640, 90, 658, 'AgreeTerms', 'Yes', False);

    // Signature field: just a name and a rectangle
    Pdf.AddLoadedSignatureField(0, 360, 72, 540, 132, 'ApproverSig');

    if Idx >= 0 then
      Pdf.SaveLoadedDocument('contract-interactive.pdf');
  finally
    Pdf.Free;
  end;
end;

อาร์กิวเมนต์สตริงตัวที่สามและสี่ของ text field คือชื่อฟิลด์และค่า /V เริ่มต้นของมัน ส่วนจำนวนเต็มคือ /MaxLen ซึ่งจะถูกเขียนต่อเมื่อมากกว่า 0 HotPDF ให้ default appearance string ของแต่ละฟิลด์ที่แก้ไขได้เป็น /Helv 12 Tf 0 0 0 rg และนี่คือสตริงที่ viewer ซึ่งเคารพ /NeedAppearances ใช้ตัดสินว่าจะแสดงฟอนต์และสีอะไรให้ค่าของฟิลด์ Checkbox รับ export value ซึ่งเป็นสตริงที่ฟอร์มจะส่งออกเมื่อมีการติ๊กกล่อง พร้อมบูลีนสำหรับสถานะเริ่มต้น ภายในมันจะเขียนชื่อรายการ /V, /AS และ /DV ที่สอดคล้องกัน เพื่อให้สถานะ on/off ตรงกันตั้งแต่วินาทีที่ไฟล์ถูกเปิด ค่า export ว่างๆ จะถูกตีความเป็น Yes ซึ่งเป็นชื่อ "on" แบบดั้งเดิมของ checkbox

ฟิลด์แบบตัวเลือกและบิตแฟลก /Ff

ComboBox และ ListBox เป็น choice field ทั้งคู่ โดยมี field type /Ch ใน ISO 32000-1 §12.7.4 ความแตกต่างระหว่าง dropdown กับ scrolling list มีอยู่แค่หนึ่งบิตใน integer แฟลกของฟิลด์ /Ff คือบิต 18 หรือ Combo flag ค่า $40000 HotPDF ตั้งบิตนั้นให้ AddLoadedComboBox และปล่อยให้ว่างสำหรับ AddLoadedListBox นอกนั้นทั้งสองเหมือนกัน และทั้งคู่รับตัวเลือกเป็น open array ของสตริงที่เขียนลงรายการ /Opt

// Dropdown (Combo flag set internally) with an initial selection
Pdf.AddLoadedComboBox(0, 72, 600, 300, 620, 'Country', 'Canada',
  ['United States', 'Canada', 'Mexico']);

// Scrolling list, no initial value
Pdf.AddLoadedListBox(0, 72, 520, 300, 590, 'Priority', '',
  ['Low', 'Normal', 'High']);

// Push button with a caption drawn through /MK
Pdf.AddLoadedPushButton(0, 360, 600, 480, 626, 'SubmitBtn', 'Submit');

มีสองข้อควรทราบเกี่ยวกับรายการตัวเลือก HotPDF เขียน /Opt แต่ละรายการเป็นสตริงธรรมดา ซึ่ง export value และ label ที่แสดงจะเป็นข้อความเดียวกัน ISO 32000-1 §12.7.4.4 ยังอนุญาตรูปแบบสองสมาชิก [export display] เมื่อต้องการให้ค่าที่ส่งออกต่างจากข้อความที่ผู้ใช้เห็น เมธอดสร้างฟิลด์แบบ loaded ใช้รูปแบบสตริงเดี่ยวที่ง่ายกว่า ดังนั้นถ้าคุณต้องการให้ export value กับ display value ต่างกัน คุณจะต้องตั้งค่าบน dictionary ที่ได้ด้วยตัวเอง และค่าที่ส่งเป็น selection ปัจจุบันของฟิลด์ควรเป็นหนึ่งในตัวเลือกที่คุณส่งไป เพราะ viewer จะเทียบมันกับรายการนั้น

ฟิลด์ push button ก็เป็นอีกกรณีที่ขับเคลื่อนด้วยแฟลก: field type /Btn กับบิต 17 หรือ PushButton flag ค่า $10000 บิตนี้คือสิ่งที่แยกปุ่มที่คลิกได้ออกจาก checkbox ซึ่งก็เป็นฟิลด์ /Btn เหมือนกันแต่ไม่มีบิตนั้น caption ที่คุณส่งจะถูกเขียนลงใน appearance characteristics dictionary /MK เป็น normal caption /CA ควรพูดตรงๆ เรื่องขอบเขตด้วยว่า ปุ่มถูกสร้างมาพร้อม label และ rectangle แต่เมธอดสร้างแบบ loaded ไม่ได้แนบ action ให้ ดังนั้นตัวปุ่มเองจะดูถูกต้องแต่ไม่ทำอะไรเมื่อคลิก การผูก submit, reset หรือ JavaScript actions เป็นอีกเรื่องหนึ่ง สำหรับฝั่ง authoring แบบสร้างจากศูนย์ เวิร์กโฟลว์ field-plus-action ถูกอธิบายไว้ใน การสร้างฟิลด์และแอ็กชัน AcroForm ใน Delphi ซึ่งเป็นจุดเปรียบเทียบที่เหมาะสมสำหรับสิ่งที่เส้นทาง loaded ตั้งใจไม่ทำ

dictionary ที่ฟิลด์ทุกตัวใช้ร่วมกัน

ใต้เมธอดทั้งหกมี builder ตัวเดียวร่วมกันซึ่งสร้าง widget annotation และลงทะเบียนมันไว้สองที่ มันเขียน /Type /Annot และ /Subtype /Widget รวมถึง array /Rect จากพิกัดทั้งสี่ของคุณ แฟลก annotation /F 4 ซึ่งเปิด Print bit เพื่อให้ฟิลด์ปรากฏทั้งบนกระดาษและบนหน้าจอ ชื่อฟิลด์ /T ประเภทฟิลด์ /FT แฟลก /Ff และ back-reference /P ไปยัง page object จากนั้นมันจะเพิ่มฟิลด์ใหม่ลงใน array /Fields ของ AcroForm และลงใน array /Annots ของหน้านั้น โดย resolve indirect reference ไปตามทาง เพื่อให้มันขยาย array จริงแทนที่จะปล่อยวิดเจ็ตให้ลอยเดี่ยว

การลงทะเบียนคู่แบบนี้สำคัญ เพราะวิดเจ็ตที่อยู่ในรายการใดรายการหนึ่งเพียงฝั่งเดียวจะเสียหายแบบซ่อนๆ ฟิลด์ที่อยู่ใน /Fields แต่ไม่อยู่ใน /Annots ของหน้า ยังรู้จักในระดับฟอร์มแต่ไม่เคยถูกวาด ส่วนกรณีตรงข้ามจะถูกวาดแต่ form logic ไม่รู้จัก HotPDF คอยซิงก์ทั้งสองฝั่งทุกครั้งที่เพิ่มฟิลด์ ซึ่งเป็นงาน bookkeeping ที่ถ้าทำเองคุณต้องเป๊ะตามสเปกมากๆ

ข้อจำกัดบางอย่างที่ควรรู้ตรงๆ

ตั้งความคาดหวังไว้ก่อนจะสร้างเวิร์กโฟลว์บนสิ่งนี้ พฤติกรรมแบบ flatten แล้ว regenerate ขึ้นอยู่กับ viewer ที่ยอมรับ /NeedAppearances ซึ่งครอบคลุม Acrobat, PDF engine ของเบราว์เซอร์สมัยใหม่ และ desktop reader ทั่วไป แต่ไม่ใช่การรับประกันแบบแข็งกับ renderer ทุกตัวในโลก ถ้าคุณต้องสร้างไฟล์ที่ฟิลด์แสดงเหมือนกันทุกที่ รวมถึงใน viewer ที่ไม่สนใจแฟลกนี้ คุณกำลังเข้าสู่ territory ของ appearance stream และเส้นทาง authoring แบบ from-scratch ที่วาด /AP ให้คุณจะเหมาะกว่า ฟิลด์ signature เองก็ถูกสร้างเป็น signature widget ว่างๆ ที่พร้อมให้เซ็น ไม่ใช่การใส่ลายเซ็นคริปโตกราฟิก

ถ้าต้องการเปลี่ยนสิ่งที่มีอยู่แล้วแทนที่จะเพิ่มเข้าไป related operation คือ form flattening ซึ่งฝังฟิลด์แบบโต้ตอบกลับลงใน page content แบบคงที่ให้ค่ากลายเป็นถาวรและแก้ไขไม่ได้ การเดินทางไปกลับแบบนั้น รวมถึงวิธีจัดการกับฟอร์มที่มี XFA อยู่ จะอธิบายไว้ใน การ flatten ฟิลด์ XFA และ AcroForm ใน Delphi การเพิ่มฟิลด์และการ flatten ฟิลด์คือสองปลายของ lifecycle เดียวกัน บทความนี้คือวิธีเอา interactivity ใส่ลงในเอกสารที่ยังไม่มีมัน ส่วน flattening คือวิธีถอดมันออกเมื่อฟอร์มทำหน้าที่เสร็จแล้ว

API สำหรับฟอร์มบนเอกสารที่โหลดมาแล้วที่แสดงที่นี่เป็นส่วนหนึ่งของ HotPDF Component มาตรฐานสำหรับ Delphi และ C++Builder พร้อมเอกสารอ้างอิงครบถ้วนสำหรับ field flags, appearance handling และส่วนอื่นๆ ของโมเดล AcroForm