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

ตั้งค่าฟิลด์ฟอร์มใน PDF ที่โหลดมาด้วย Delphi

HotPDF Delphi Component เติมค่าฟิลด์ AcroForm ที่มีอยู่บน PDF ที่โหลดมาผ่าน THotPDF.SetFormFieldValue โดยอ้างด้วย index ของฟิลด์ที่เริ่มที่ศูนย์หรือด้วยชื่อฟิลด์แบบเต็ม การเขียน entry /V ใหม่เป็นส่วนที่ง่าย สิ่งที่ทำให้ call นี้เชื่อถือได้บนฟอร์มจริงคือ method เดียวกันยังทำให้สถานะสามอย่างสอดคล้องกัน ซึ่งมองไม่เห็นจนกว่ามันจะพัง: identity ของฟิลด์ที่ถูก decode เพื่อให้หาชื่อที่ไม่ใช่ ASCII เจอ, appearance state /AS บน widget ของ checkbox และ radio และ array index การเลือก /I บนฟิลด์ choice ส่วน appearance stream ที่มองเห็นเป็นขั้นตอนแยกและชัดเจนผ่าน EnsureLoadedFieldAppearanceStream

สถานการณ์คือแบบธรรมดา ๆ ที่เจอทุกวัน: ลูกค้าส่งฟอร์มของตัวเองมา เป็นใบแสดงภาษี ใบเคลมประกัน หรือใบสั่งซื้อที่ใครสักคนสร้างใน Acrobat เมื่อหลายปีก่อน และแอปพลิเคชัน Delphi ของคุณต้องเติมข้อมูลจากฐานข้อมูลแล้วส่งไฟล์กลับที่เปิดถูกต้องทุกที่ คุณควบคุมวิธีที่ฟอร์มถูกสร้างไม่ได้เลย ชื่อฟิลด์อาจเข้ารหัสแบบ UTF-16 ค่า export ของ checkbox อาจเป็น 2 ไม่ใช่ Yes และ combo box อาจใช้คู่ option แบบ [export display] รายละเอียดแต่ละข้อมีกฎอยู่ใน ISO 32000-1 และแต่ละกฎเป็นสิ่งที่ SetFormFieldValue จัดการให้แล้ว บทความนี้ว่าด้วยว่ามันทำอะไร ทำไม และหยุดตรงไหน สำหรับปัญหาพี่น้องอย่างการสร้างฟิลด์ที่ยังไม่มี ดูการเพิ่มฟิลด์ AcroForm ให้ PDF ที่โหลดมาใน Delphi

ทำไม SetFormFieldValue หาฟิลด์ที่ชื่อไม่ใช่ ASCII ไม่เจอ

ก่อน v2.752.1 คำตอบคือเรื่อง encoding: ฟิลด์อยู่ในไฟล์ใต้ชื่อ UTF-16BE แบบ hex และ name cache เก็บตัวสะกดแบบ hex แทนที่จะเก็บข้อความ ISO 32000-1 §12.7.3.1 นิยามชื่อฟิลด์บางส่วน /T ว่าเป็น text string และ §7.9.2.2 บอกว่า text string อาจเป็น UTF-16BE ที่มี byte order mark FE FF นำหน้า เครื่องมือสร้างฟอร์ม serialize ชื่อแบบนั้นเป็น hex string ตาม §7.3.4.3 เป็นปกติ ฟิลด์ชื่อ Straße จึงมาในรูป <FEFF005300740072006100DF0065> ภายใน HotPDF THPDFStringObject.Value ถือข้อความ hex ดิบทุกครั้งที่ IsHexadecimal ถูกตั้ง ซึ่งเป็นสิ่งที่คุณต้องการพอดีสำหรับการไปกลับแบบไม่สูญเสียของ dictionary ต้นฉบับ และเป็นสิ่งที่คุณไม่ต้องการเลยในฐานะคีย์ค้นหา HPDFLoadedFormTextName แยกสองเรื่องนี้ออกจากกัน เมื่อ relationship cache ถูกสร้าง ค่า /T ทุกตัวผ่านมัน: ถ้า string object เป็น hex HPDFHexToBytes คืนลำดับไบต์กลับมา ถ้าไบต์เริ่มด้วย FE FF และความยาวเป็นเลขคู่ payload จะถูก decode เป็น UTF-16BE แล้ว encode ใหม่เป็น UTF-8 จากนั้นผลลัพธ์ถูกต่อกับชื่อ parent ด้วยจุดเพื่อสร้างชื่อแบบเต็มที่ §12.7.3.1 อธิบาย kid ชื่อ City ใต้ parent ชื่อ Address จึงถูกลงทะเบียนเป็น Address.City คีย์ใน cache ถูก normalize เป็นตัวพิมพ์เล็ก ซึ่งทำให้ SetFormFieldValue('address.city', ...) สำเร็จด้วย นั่นเป็นความสะดวกเกินมาตรฐาน เพราะสเปกถือว่าชื่อแยกตัวพิมพ์เล็กใหญ่ สิ่งสำคัญคือมีแค่คีย์ใน cache ที่เปลี่ยน object /T ใน dictionary ของฟิลด์ยังคง encoding แบบ hex ไว้ การเซฟเอกสารจึงไม่เขียน identity ของฟิลด์ที่คุณแค่เติมค่าใหม่

แผนภาพวิธีที่ HotPDF resolve ชื่อ AcroForm ที่ไม่ใช่ ASCII: HPDFHexToBytes คืน payload UTF-16BE ที่อยู่หลังสตริง /T แบบ hex, byte order mark FE FF ถูก decode แล้ว encode ใหม่เป็น UTF-8 และชื่อแบบเต็มต่อกับ parent ทำให้ทั้ง Applicant.FullName และฟิลด์ชื่อ Straße ลงใน cache สำหรับค้นหา
มีแค่คีย์ใน cache ที่เปลี่ยน: dictionary ของฟิลด์ยังคง encoding แบบ hex การค้นหาถูก normalize เป็นตัวพิมพ์เล็กเป็นความสะดวกเกินมาตรฐาน และการเซฟเอกสารไม่เคยเขียน identity ของฟิลด์ที่คุณแค่เติมค่าใหม่
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ชื่อแบบเต็มถูก decode จากสตริง /T แบบ UTF-16BE และ
    // ต่อด้วยจุด ชื่อที่ซ้อนกันและชื่อที่ไม่ใช่ ASCII จึง resolve ได้
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // ค่าที่ไม่ใช่ Latin-1 เดินทางเป็น hex UTF-16BE ที่มี FEFF นำหน้า
    // และถูกเขียนเป็น PDF hexadecimal string
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue เขียนอะไรลงไปจริง ๆ

overload ทั้งสองแบบรันห้าขั้นตอนเดียวกัน: หา dictionary ของฟิลด์ เขียน /V ผ่าน HPDFSetDictFormValue ปรับ index การเลือกของ choice ทำเครื่องหมาย dictionary ว่า dirty ปรับ appearance state ของปุ่ม และสุดท้ายบันทึก index ของฟิลด์ผ่าน NoteLoadedFormFieldDirty ขั้นสุดท้ายสำคัญถ้าฟอร์มพา calculation script เพราะ dirty set คือสิ่งที่ overload ของ RecalculateLoadedFormFieldsIncremental ที่ไม่มีพารามิเตอร์ใช้เพื่อรันใหม่เฉพาะการคำนวณที่อ่านฟิลด์ที่เปลี่ยนไปโดยตรงหรือโดยทอดเดียว HPDFSetDictFormValue เองระวังเรื่องชนิด object ที่มันแทนที่ ถ้า /V เดิมเป็น name object ซึ่งเป็นสิ่งที่ฟิลด์ checkbox และ radio ใช้เป็นค่า export ค่าใหม่จะถูกเขียนเป็น name ไม่เคยเขียนเป็น string เพราะ PDF name เป็น ASCII เท่านั้นโดยธรรมชาติ ไม่งั้นมันเขียน string object แล้วตรวจค่าที่คุณส่ง: สตริงที่เริ่มด้วย FEFF มีความยาวเป็นเลขคู่ และประกอบด้วยตัวอักษร hex ล้วนจะถูกถือเป็นรูปแบบ wire แบบ UTF-16BE จาก §7.9.2.2 และเก็บโดยตั้ง IsHexadecimal จึง serialize เป็น <FEFF...> แทนที่จะเป็น (FEFF...) แบบ literal นั่นคือกลไกที่บรรทัด City ข้างบนพึ่งพา สตริงอื่น ๆ ถูกเก็บเป็น literal string ตามไบต์ที่คุณให้ ข้อความละตินธรรมดาจึงส่งเป็นข้อความธรรมดา

ทำไม checkbox ยังติ๊กเดิมหลังเปลี่ยนค่า

เพราะสำหรับฟิลด์ปุ่ม ค่าเพียงอย่างเดียวไม่ได้ตัดสินว่าจะวาดอะไร ISO 32000-1 §12.7.4.2.3 ระบุว่า widget ของ checkbox พา appearance state /AS ที่ระบุว่า stream ตัวไหนใน /AP /N ถูกแสดงอยู่ และ viewer วาดจาก /AS ไม่ได้วาดจาก /V ถ้าคุณเปลี่ยน /V เป็น Yes แต่ทิ้ง /AS ไว้ที่ Off ไฟล์จะขัดแย้งกันเองภายใน และการ flatten จะอบ appearance ที่ยังไม่ติ๊กและค้างเก่าลงในหน้าอย่างสบายใจขณะที่ข้อมูลฟอร์มบอกว่าติ๊กแล้ว ReconcileLoadedButtonAppearanceStates มีอยู่เพื่อปิดช่องว่างนั้น: สำหรับฟิลด์ที่ /FT เป็น Btn มันเยี่ยม dictionary ของฟิลด์เองและทุก entry ใน array /Kids อ่านชื่อ on state จาก /AP /N แล้วเขียน /AS ใหม่เป็นชื่อนั้นเมื่อตรงกับค่าของฟิลด์ หรือเป็น Off เมื่อไม่ตรง

แผนภาพว่าทำไม checkbox ของ HotPDF ยังเก็บติ๊กเดิมเมื่อมีแค่ /V ที่เปลี่ยน: viewer วาดจาก appearance state /AS เข้าไปใน /AP /N ดังนั้น ReconcileLoadedButtonAppearanceStates จึงเยี่ยมฟิลด์และ kid ทุกตัว อ่านชื่อ on state เป็นคีย์ตัวแรกที่ไม่ใช่ Off และเขียน /AS ใหม่เมื่อตรงหรือเป็น Off เมื่อไม่ตรง
กลุ่ม radio เทียบ kid แต่ละตัวกับค่าของ parent ที่ InheritedButtonValue กู้คืนด้วยการเดินตามห่วงโซ่ /Parent การตั้งกลุ่มเป็นค่า export ตัวหนึ่งจึงเปิดเฉพาะ widget นั้นและปิดพี่น้องทุกตัว

รายละเอียดสองอย่างจากฟอร์มจริงเป็นตัวกำหนด fix ใน v2.752.3 อย่างแรก normal appearance dictionary ได้รับอนุญาตให้มีเฉพาะ on state §12.7.4.2.3 เรียก off appearance ว่า Off แต่เครื่องมือสร้างฟอร์มมักละ stream ของมันและปล่อยให้ viewer วาดอะไรก็ไม่วาด โค้ดก่อนหน้าถอยออกเมื่อ dictionary มีน้อยกว่าสอง entry checkbox แบบ state เดียวเหล่านั้นจึงเก็บติ๊กเดิมไว้เงียบ ๆ ตอนนี้การตรวจเป็นแค่ว่า dictionary ไม่ว่าง และชื่อ on state ถูกเอาเป็นคีย์ตัวแรกที่ไม่ใช่ Off อย่างที่สอง ชื่อ on state คืออะไรก็ตามที่ผู้เขียนเลือก ฟอร์มจริงใช้ 2, Yes, On หรือคำในภาษาท้องถิ่น การเปรียบเทียบจึงทำกับคีย์จริง แบบไม่แยกตัวพิมพ์เล็กใหญ่ ไม่เคยเทียบกับ Yes ที่ฮาร์ดโค้ดไว้ radio button มีอีกจุดยิบย่อยหนึ่งที่อธิบายใน §12.7.4.2.4: การเลือกอยู่ใน /V บนฟิลด์ parent ขณะที่ kid แต่ละตัวเป็นเจ้าของ widget และมักไม่มี /V ของตัวเอง helper InheritedButtonValue ที่ซ้อนอยู่จึงเดินขึ้นตามห่วงโซ่ /Parent สูงสุด 64 ระดับจนเจอค่าที่ไม่ว่าง kid แต่ละตัวจึงถูกเทียบกับค่าของกลุ่มที่มันอยู่ การตั้ง parent เป็นค่า export ของ kid ตัวหนึ่งจะเปิดเฉพาะ kid ตัวนั้นและปิดพี่น้องทุกตัว

// checkbox: ค่า export ต้องตรงกับคีย์ on state ใน /AP /N
// (มักเป็น 'Yes' แต่ฟอร์มจริงใช้ '2', 'On' หรืออะไรก็ได้)
Pdf.SetFormFieldValue('Consent', 'Yes');

// กลุ่ม radio: /V ถูกเขียนบน parent และ widget ของ kid ทุกตัวได้
// /AS เป็นชื่อ export ของตัวเองหรือเป็น Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// การล้าง checkbox: ค่าที่ไม่ตรงกับ on state ใดเลยให้ /AS เป็น Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

ฟิลด์ choice: ทำ /I ให้ตรงกับ /V

สำหรับ combo box หรือ list box /V ไม่ใช่ที่เดียวที่บันทึกการเลือก ตาราง 231 ใน §12.7.4.4 นิยาม /I เป็น array ของ index ที่เริ่มที่ศูนย์ชี้เข้าไปใน /Opt ซึ่งระบุรายการที่ถูกเลือก และ viewer ที่เจอ /I ชี้ไปที่ option 0 ขณะที่ /V บอกว่า option 3 อาจไฮไลต์ผิดแถว ตั้งแต่ v2.754.1 HPDFReconcileChoiceSelection รันในทุกการเรียก SetFormFieldValue และเมื่อ /FT ที่สืบทอดมาเป็น Ch มันสร้าง /I ใหม่จากค่าใหม่ ลำดับการทำงานจงใจให้เป็นแบบนี้ entry /I ท้องถิ่นถูกลบก่อน โดยไม่แตะเนื้อหาของมัน: ถ้า array เก่าเป็น indirect object ที่ใช้ร่วมกับฟิลด์อื่น การ mutate มันในที่เดิมจะทำลายการเลือกของฟิลด์อื่น routine จึงทิ้ง reference นั้นแล้วสร้าง array แบบ direct ใหม่ จากนั้นมัน resolve /Opt ผ่านห่วงโซ่ /Parent เพราะ option ของ choice อาจถูกสืบทอดมา และสแกน entry ต่าง ๆ option ที่เป็นสตริงเปล่า ๆ ถูกเทียบตรง ๆ ส่วนคู่ [export display] ถูกเทียบที่สมาชิก export และคู่ที่มีสมาชิกน้อยกว่าสองตัวถูกข้าม ทั้งสองฝ่ายผ่าน HPDFLoadedFormTextName option ที่เป็น hex UTF-16 จึงตรงกับค่าที่เป็น hex UTF-16 โดยที่คุณไม่ต้องสะกดให้เหมือนกัน เมื่อเจอที่ตรงตัวแรก /I ขนาดหนึ่งสมาชิกถูกเขียนและการสแกนหยุด ค่าสเกลาร์แทนที่การเลือกหลายรายการก่อนหน้าเสมอ ไม่ว่าธง MultiSelect จะเป็นอย่างไร

แผนภาพวิธีที่ HotPDF ทำให้ฟิลด์ choice สอดคล้อง: HPDFReconcileChoiceSelection ลบ array /I ท้องถิ่นก่อนแตะมัน resolve /Opt ผ่านห่วงโซ่ /Parent เทียบครึ่ง export ของแต่ละ option ผ่าน HPDFLoadedFormTextName เขียน /I ขนาดหนึ่งสมาชิกเมื่อเจอที่ตรงตัวแรก และไม่เขียนอะไรเมื่อค่าของ combo แบบแก้ไขได้ไม่มี index
option ที่เป็นสตริงเปล่า ๆ ถูกเทียบตรง ๆ และคู่ export display ถูกเทียบที่ครึ่ง export ขณะที่ค่านอก /Opt ไม่เหลือ index อย่างถูกต้อง index /I ที่ค้างเก่าและชี้ผิดแถวจะแย่กว่าไม่มี

เมื่อไม่มีอะไรตรงเลย จะไม่มี /I ถูกเขียน นั่นคือผลลัพธ์ที่ถูกสำหรับ combo box แบบแก้ไขได้ ซึ่ง §12.7.4.4 ยอมให้ผู้ใช้พิมพ์ค่านอกเหนือรายการ option ค่าแบบนั้นไม่มี index และ index ที่ค้างเก่าจะแย่กว่าไม่มี และนั่นก็คือสิ่งที่คุณได้ถ้าส่ง label ที่แสดงแทนค่า export ให้รายการ option แบบคู่ ดังนั้นเมื่อ combo box ไม่ยอมแสดงค่าที่คุณเลือก ให้ตรวจว่าคุณส่งครึ่งไหนของคู่ไป

// /Opt คือ [[US United States] [CA Canada] [MX Mexico]]:
// จับคู่ที่ค่า export และ /I กลายเป็น [1]
Pdf.SetFormFieldValue('Country', 'CA');

// combo แบบแก้ไขได้ที่มีค่านอก /Opt: /V ถูกเขียน
// /I ถูกลบ และไม่มี index ถูกกุขึ้นมา
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

ค่าและ appearance เป็นสอง operation แยกกัน

SetFormFieldValue ไม่เคยแตะ appearance stream ของฟิลด์ text หรือ choice หลังการเรียก /V ถือข้อความใหม่ขณะที่ /AP /N ยังวาดข้อความเก่า และ viewer จะแสดงตัวไหนขึ้นกับว่า dictionary AcroForm มี /NeedAppearances true ตาม §12.7.3.3 และ viewer เคารพมันหรือไม่ ถ้าคุณต้องการให้ไฟล์ render ค่าใหม่ในทุก reader รวมถึง flattener และตัวสร้าง thumbnail ที่มองข้ามธงนั้น ให้เรียก EnsureLoadedFieldAppearanceStream ด้วย index ของฟิลด์ มันสร้าง Form XObject จากสตริง /DA ที่สืบทอดมา, quadding /Q, เลย์เอาต์ comb ของ /MaxLen และค่าของฟิลด์ resolve ฟอนต์ที่มีชื่อผ่าน resource /DR ของ AcroForm เพื่อให้ฟอนต์ Type0 ยังคง descendant font ของตัวเองแทนที่จะเสื่อมไปเป็น Helvetica และคืน True เมื่อมี widget อย่างน้อยหนึ่งตัวได้รับ stream overload แบบใช้ชื่อของ SetFormFieldValue ไม่คืน index กลับมา จึงต้องไปเอามาผ่าน GetFormField ซึ่งคืน THPDFLoadedFormField ที่คุณเป็นเจ้าของและต้อง free เอง ชุด regression ของการเปลี่ยนใน v2.752.1 พูดชัดเรื่องการแยกนี้: มันตั้งค่า เรียก EnsureLoadedFieldAppearanceStream แล้ว render หน้าและตรวจว่าพิกเซลในกรอบ widget เปลี่ยนขณะที่พิกเซลนอกรอบไม่เปลี่ยน การยืนยันว่า /V เปลี่ยนไม่ได้พิสูจน์อะไรเกี่ยวกับสิ่งที่ผู้ใช้จะเห็น

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // วาดค่าใหม่ลง /AP เพื่อให้ viewer ที่มองข้าม
    // /NeedAppearances ยังแสดงมันอยู่
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

ข้อจำกัดที่ควรรู้ก่อนสร้างทับสิ่งนี้

ReconcileLoadedButtonAppearanceStates ทดสอบ /FT ท้องถิ่นของ dictionary ที่คุณอ้างถึง มันจึงทำงานบน parent ของ radio หรือบน checkbox ที่พา /FT ของตัวเอง ส่วน kid widget ที่ถูกอ้างถึงตัวเดียวโดยมี /FT อยู่แค่บน parent จะไม่ถูกปรับผ่านเส้นทางนั้น HPDFReconcileChoiceSelection จัดการค่าแบบสเกลาร์เดียวและเขียน index ได้ไม่เกินหนึ่งตัว list box ที่เลือกหลายรายการจึงอยู่นอกเหนือสิ่งที่ SetFormFieldValue จำลองไว้ ไม่มี routine ไหนตรวจค่าที่คุณส่งเทียบกับ /Opt หรือเทียบกับคีย์ on state การพิมพ์ผิดจึงให้ checkbox ที่เป็น Off หรือ combo ที่ไม่มี index แทนที่จะโยน exception และ GetFormFieldValue คืนข้อความ /V ที่เก็บไว้ตามที่มันอยู่ใน dictionary ซึ่งสำหรับค่าที่เข้ารหัสแบบ hex หมายถึงตัวสะกดแบบ hex ไม่ใช่ข้อความที่ decode แล้ว

เมื่อค่าเข้าไปแล้วและ appearance ถูกวาดแล้ว ขั้นต่อไปตามธรรมชาติสองขั้นอยู่คนละข้างของ operation นี้ การแลกเปลี่ยนข้อมูลฟิลด์กับระบบภายนอกเป็นชุด แทนที่จะเรียก SetFormFieldValue ทีละครั้ง คือสิ่งที่การนำเข้าและส่งออก XFDF ใน Delphiครอบคลุม และเมื่อฟอร์มที่เติมแล้วเสร็จสมบูรณ์และไม่ควรแก้ได้อีก การ flatten ฟิลด์ AcroForm และ XFA ใน Delphi จะอบสถานะ /AS กับ appearance stream ที่อธิบายตรงนี้ลงในเนื้อหาหน้าคงที่พอดี ซึ่งเป็นเหตุผลที่การทำให้มันสอดคล้องก่อน flatten จึงไม่ใช่เรื่องเลือกได้

API แก้ไขฟอร์มที่โหลดมาในบทความนี้ รวมถึง SetFormFieldValue, EnsureLoadedFieldAppearanceStream และกราฟการคำนวณใหม่แบบ incremental ship เป็นส่วนหนึ่งของ HotPDF Delphi Component สำหรับ Delphi และ C++Builder