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

ฟิลด์ multi-select ใน round trip ของ FDF กับ XFDF (Delphi)

HotPDF ทำ round trip ค่า list box แบบเลือกหลายค่าผ่าน FDF กับ XFDF โดยคงค่าฟิลด์เป็น array ตลอดสาย ตั้งแต่ v2.755.0 ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF กับ ExportLoadedFormToXFDF เขียน option ที่ถูกเลือกแต่ละตัวเป็น FDF string หรือ element <value> ของ XFDF ของมันเอง และ method import ที่คู่กันเช็กทุกค่าเทียบกับ option ของฟิลด์แล้วสร้าง index การเลือก /I ใหม่ก่อนจะแตะอะไร ไม่มีอะไรถูกกาบรวมเป็นสตริงเดียวระหว่างทางอีก

ความล้มเหลวที่จับมาแก้นี้ทำซ้ำง่าย ๆ ยกฟอร์มสั่งซื้อที่มี list box แบบเลือกหลายค่าเป็น option สินค้า ให้ผู้ใช้เลือกสองตัว export ข้อมูลฟอร์มไปให้ระบบ back-office แล้ว import ไฟล์ที่ถูกแก้กลับเข้า PDF ก่อนการเปลี่ยนนี้ list box กลับมาเป็นค่าว่างหรือค่าผิด เหตุผลคือค่า export หนึ่งตัวมี line break อยู่ข้างใน และเส้นทางเดิมปรับการเลือกทั้งชุดลงเป็นสตริงเดียวคั่นด้วยบรรทัด การดึงการเลือกหลายตัวออกจากสตริงนั้นไม่เคยเชื่อถือได้ และเมื่อค่า export เองก็มี line break มันไปไม่ได้เลย

ทำไมการต่อค่า multi-select ด้วย line break จึงทำ round trip พัง

การต่อการเลือกทั้งชุดเป็นสตริงเดียวคือการทิ้งเขตแดนระหว่างค่า และค่าเองก็มีสิทธิ์มีตัวคั่นอยู่ข้างใน importer ตัวไหนจึงแยกสตริงกลับมาถูกไม่ได้ ISO 32000-1 §12.7.4.4 ยอมให้ entry /V ของฟิลด์ choice เป็นสตริงข้อความเดี่ยวหรือ array ของสตริงก็ได้ และ list box ที่มีธง MultiSelect (bit 22 ของ /Ff) ใช้รูป array ทันทีที่เลือกเกินหนึ่งตัว มาตราเดียวกันนิยาม /I เป็น array ของ index option ที่เริ่มที่ศูนย์เรียงจากน้อยไปมาก ซึ่ง viewer ใช้แยก option สองตัวที่บังเอิญใช้ค่า export ร่วมกัน ใน HotPDF getter แบบสเกลาร์ GetFormFieldValue อ่านแค่รูปสตริง ดังนั้นเอา array ผ่านมันแล้ว export เสื่อมเป็นสตริงว่าง และ import ของ XFDF รุ่นเก่าต่อ element <value> ที่ซ้ำด้วย LF ลองนึกภาพ option ที่ export ออกไปเป็น Deep, line feed, Blue: หลังต่อกันแล้ว Deep\nBlue\nRed อาจเป็นการเลือกสองตัวหรือสามตัวก็ได้ และไฟล์ไม่มีทางบอกได้ว่าอย่างไหน การแก้คือเลิกใช้สเกลาร์กลาง round trip เสียทีเดียว

round trip แบบ multi-select รุ่นเก่าของ HotPDF ที่ option list box ที่ถูกเลือกสองตัว ซึ่งหนึ่งตัวมี line break ฝังอยู่ ถูกปรับแบนลงเป็นสตริงเดียว Deep, line feed, Blue, line feed, Red โดยเส้นทางสเกลาร์ GetFormFieldValue ซึ่ง reader ปลายทางจะ parse เป็นการเลือกสองตัวหรือสามตัวก็ได้
การต่อค่า multi-select เป็นสตริงเดียวทำลายเขตแดนระหว่างค่า และค่า export ที่เองก็มี line break ทำให้รูปแบบที่ถูกปรับแบนกำกวม

ไฟล์ FDF กับ XFDF ที่ export ออกไปมีอะไร

HotPDF เขียนค่า multi-select เป็น array ที่มีชนิดกำกับใน FDF และเป็น element <value> หนึ่งตัวต่อหนึ่งการเลือกใน XFDF เขตแดนจึงยังมองเห็นได้บนดิสก์ ใน FDF item แต่ละตัวคงตัวสะกดเดิมที่มีใน PDF ต้นฉบับ: สตริงแบบ hex ออกไปเป็น hex และสตริง literal ถูก escape โดย helper ตัวเดียวที่แปลง CR กับ LF เป็น \r กับ \n ใน XFDF root แบก xml:space="preserve" มาตามที่ ISO 19444-1 ทวง ซึ่งแปลว่า whitespace ใด ๆ ข้างใน element ข้อความนับเป็นข้อมูล HotPDF จึงเขียน start tag, ข้อความที่ escape แล้วกับ end tag ของ <value> แต่ละตัวเป็นชิ้นเดียว เก็บการเยื้องไว้นอก element และ encode CR, LF กับ TAB เป็น character reference เพื่อให้ XML parser ที่ทำ line-ending normalization เปลี่ยนไบต์ต้นฉบับไม่ได้

รูปร่าง export ที่ HotPDF เขียนให้ list box แบบเลือกหลายค่าตั้งแต่ 2.755.0: FDF แบก array ที่มีชนิดกำกับหนึ่งตัวต่อฟิลด์ด้วย /V [(Deep line break Blue) (Red)] กับค่า region แบบ hex ขณะที่ XFDF แบก value element หนึ่งตัวต่อการเลือกใต้ xml:space preserve ทำให้ whitespace นับเป็นข้อมูล
เขตแดนยังมองเห็นได้บนดิสก์: FDF เก็บแต่ละการเลือกเป็น item ของ array ตัวเอง และ XFDF เขียนแต่ละตัวใน value element แยกต่างหาก importer จึงไม่ต้องเดา
<!-- FDF: array ที่มีชนิดกำกับหนึ่งตัวต่อฟิลด์ -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF: <value> หนึ่งตัวต่อหนึ่งการเลือก -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

edge case ของการ export สองตัวสมควรรู้ก่อนเขียนโค้ดเรียก อย่างแรก ExportLoadedFormToFDF ประกอบ body ของ FDF ทั้งก้อนในหน่วยความจำก่อนสร้างไฟล์เป้าหมาย (แก้ใน 2.755.1) ค่าที่ export ไม่ได้ เช่น array ที่ถืออะไรนอกจากสตริง จึงโยน exception โดยไม่ตัดทอนไฟล์ที่มีอยู่ อย่างที่สอง การเลือกเป็นศูนย์บน list box ที่ยังแถมค่า export เป็นสตริงว่างให้เป็นเรื่องกำกวมใน XFDF เพราะ <value/> อาจหมายถึงไม่ได้เลือกอะไรหรือหมายถึงเลือก option ว่างไว้ ExportLoadedFormToXFDF โยนในเคสนั้นแทนที่จะเดา และโยนก่อนไฟล์เป้าหมายจะถูกเปิด FDF ไม่มีความกำกวมแบบนี้ เพราะ /V [] กับ /V [()] แยกกันชัด ผู้ export FDF ทั้งสองตัวยังข้าม terminal ที่เป็นแต่ widget โดยไม่มีชื่อ /T ด้วย ตรงกับฝั่ง exporter XFDF เพราะ importer ไม่มีวันจับ entry พวกนั้นกลับมาจับคู่กับฟิลด์ได้

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // list box แบบเลือกหลายค่าถูกเขียนเป็น /V [(...) (...)]
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // การเลือกเป็นศูนย์บวก option export ว่าง: XFDF แยกไม่ออก
          // และไฟล์ .xfdf ที่มีอยู่ไม่ถูกแตะ
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

HotPDF validate ค่า multi-select ตอน import อย่างไร

HotPDF ยอมรับ array ที่ import เข้ามาเมื่อ target เป็นฟิลด์ choice ที่ติดธง MultiSelect และค่าทุกตัวใน array ตรงกับค่า export ตัวหนึ่งใน array /Opt ของฟิลด์ ช่อง option แต่ละช่องใช้ได้ครั้งเดียว list ที่มีสอง option ใช้ค่า export b ร่วมกันจึงยอม [<62> <62>] เป็นการเลือกสองตัวที่ต่างกัน และปฏิเสธ b ตัวที่สาม /I ที่สร้างใหม่เดินตามลำดับ /Opt ไม่ใช่ลำดับของค่าที่ส่งเข้ามา เพราะ §12.7.4.4 ทวงว่า index ต้องเรียงจากน้อยไปมาก HotPDF สร้าง /V กับ /I ใหม่เป็น object ที่แยกตัวอยู่แล้วกำหนดให้เมื่อค่าทุกตัวผ่านการตรวจแล้วเท่านั้น ค่าที่ถูกปฏิเสธจึงไม่เคยทิ้ง array ครึ่ง ๆ หรือ index ค้างเก่าไว้ สำเนาถูกเขียนลงฟิลด์ที่กำลังถูก import แทนที่จะลงใน array บรรพบุรุษที่ใช้ร่วมกัน ตัวสะกดแบบ hex ที่มาจาก FDF ยังเป็น hex ตลอดการเซฟ และฟิลด์ที่การคำนวณพึ่ง list box ถูกทำเครื่องหมายให้คำนวณใหม่ ถ้าคุณต้องการตั้งค่าเดียว การตั้งค่าฟิลด์ฟอร์มหนึ่งตัวใน PDF ที่โหลดมา เดินเส้นทางสเกลาร์ ซึ่งตั้งใจไม่รับหลายการเลือกอยู่แล้ว

การ validate ตอน import ค่า multi-select ใน HotPDF: target ต้องเป็นฟิลด์ choice ที่ติดธง MultiSelect ใน /Ff, ค่าที่ส่งเข้ามาทุกตัวต้องตรงกับค่า export ของ /Opt โดยแต่ละช่องใช้ได้ครั้งเดียว, /I ถูกสร้างใหม่เรียงขึ้นตามลำดับ /Opt และ /V กับ /I ที่แยกตัวถูกกำหนดเมื่อค่าทุกตัวผ่านเท่านั้น
ค่าที่ส่งเข้ามาแต่ละตัวถูกเช็กเทียบกับ option ของฟิลด์ก่อนจะมีอะไรถูกเขียน ค่าที่ถูกปฏิเสธจึงไม่เคยทิ้ง array ครึ่ง ๆ หรือ index การเลือกค้างเก่าไว้

เครื่องมืออื่นบางตัวเขียนค่า export แบบ ASCII ธรรมดาเป็น hex string โดยไม่มี byte order mark เช่น <416272> แล้ว export XFDF ต่อโดยพิมพ์เลข hex พวกนั้นออกมาเป็นข้อความ การเทียบ literal แบบเคร่งครัดในทางกลับจึงล้มเหลว และการ import ยกเลิกกลางทาง v2.755.1 เพิ่มการลองซ้ำหนึ่งครั้ง: เมื่อค่าไม่ตรงกับ option ใดเลย HPDFHexSpellingText decode ข้อความเป็น payload hex แล้วเทียบผลอีกรอบ การลองซ้ำใช้กับ input ที่ไม่งั้นต้องโยน exception เท่านั้น ค่าที่ตรงแล้วจึงไม่ถูกเปลี่ยน รีลีสเดียวกันยังทำให้เส้นทางสเกลาร์กับ array ใช้ decoder Unicode ตัวเดียวกัน ซึ่งเข้าใจ PDFDocEncoding, UTF-16 ที่มี byte order mark แบบใดก็ได้และ UTF-8 ก่อนหน้านี้ค่าเชิงตรรกะหนึ่งตัวอาจตรงบนเส้นทางหนึ่งแต่พังอีกเส้นทางในเอกสารที่ผสม encoding

ทำไมไฟล์ FDF ที่ถูกต้องยังเสียฟิลด์ระหว่างการ parse ได้

scanner ของ FDF ที่ไม่ติดตามสตริงแบบ hex ตัด dictionary ของฟิลด์หักครึ่งได้เมื่อค่า hex จบพอดีชิดตัวปิด dictionary ใน << /T (region) /V <416273>>> เครื่องหมาย > ตัวแรกปิดสตริง hex แต่ scanner ที่เดาสุ่มจะอ่านมันพร้อม > ตัวถัดไปเป็นปลาย dictionary แล้วทิ้งฟิลด์ไปเงียบ ๆ ตัว import FDF ระดับไฟล์ติดตามเรื่องว่าตัวเองอยู่ข้างในสตริง hex หรือไม่มาแล้ว และใน 2.755.1 scanner ของ array กับ dictionary ที่อยู่หลัง ImportLoadedInterchangeFromFDF ก็ทำแบบเดียวกัน ประเด็นที่สองเรื่อง reference แบบ indirect ไฟล์ FDF เป็นเอกสารแว่นเล็ก ๆ ใช้ไวยากรณ์ PDF ที่มีเลข object ของตัวเอง (ISO 32000-1 §12.7.7) ค่าอย่าง /V [11 0 R] จึงอ้าง object 11 ของไฟล์ FDF ไม่ใช่ object 11 ของ PDF ที่คุณกำลังเติม parser FDF แบบย่อของ HotPDF ไม่ resolve reference ข้างในไฟล์ มันจึงปฏิเสธ array แบบนั้นแทนที่จะไปอ่านอะไรก็ตามที่ object 11 บังเอิญเป็นในเอกสารเป้าหมาย

import แบบไฟล์ สตรีม และ XFDF รายงาน error ไม่เหมือนกัน

เส้นทาง import ทั้งสาม validate แบบเดียวกันแต่รายงานความล้มเหลวไม่เหมือนกัน ควรเลือกตัวใดตัวหนึ่งโดยเจตนา ImportLoadedFormFromFDF ข้ามฟิลด์ใดก็ตามที่ตรวจไม่ผ่านแล้วคืนจำนวนฟิลด์ที่มันใส่จริง จำนวนที่น้อยกว่าที่คาดจึงเป็นสัญญาณปัญหาเพียงอย่างเดียว ImportLoadedInterchangeFromFDF กับ ImportLoadedFormFromXFDF โยนที่ฟิลด์แรกที่ถูกปฏิเสธ แต่ละฟิลด์ถูกกระทำของมันเอง ฟิลด์ที่ประมวลผลก่อน exception จึงคงค่าใหม่ไว้ อย่าถือว่าพวกมันเป็น transaction เหนือไฟล์แลกเปลี่ยนทั้งฉบับ: ถ้าคุณต้องการพฤติกรรมเอาทั้งหมดหรือไม่เอาเลย ให้ทิ้งเอกสารที่โหลดมาเมื่อ exception เกิดขึ้นแทนที่จะเซฟ

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // เฉพาะฟิลด์; ค่านอก /Opt หรือ target ที่ไม่ใช่ multi-select จะโยน
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
    except
      on E: Exception do
        ShowMessage('Import rejected, nothing saved: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

ขยาย callback ของ XFDF โดยไม่ทำ caller เดิมพัง

การรองรับ array ใน unit XFDF ระดับล่างอาศัยอยู่ใน record แยกต่างหากคือ THPDFXFDFArrayAccess และใน overload ใหม่ของ HPDFXFDFExportFields กับ HPDFXFDFImportFields ไม่ใช่ในฟิลด์เพิ่มท้ายของ record THPDFXFDFAccess เดิม เหตุผลคือความเข้ากันได้ระดับไบนารี โค้ดที่เติม THPDFXFDFAccess เป็นตัวแปรท้องถิ่นมักตั้งแค่ช่องที่ตัวเองรู้จักและไม่เคยล้างช่องที่เหลือ ฟังก์ชัน pointer ใหม่ที่แถบท้าย record นั้นจึงจะมีขยะจาก stack และ library จะเอามันไปเป็น callback จริง เมื่อใช้ record แยก caller เดิมคง layout เดิมกับ overload เดิม และ overload เหล่านั้นส่ง array record ที่ nil ทั้งชุดให้ข้างใน overload import แบบสเกลาร์ตัวเดิมยังต่อค่าที่ซ้ำด้วย LF เพื่อความเข้ากันได้ มีแค่ overload ที่รู้เรื่อง array เท่านั้นที่แยกพวกมันไว้ ตอนผูก data store ของตัวเอง เริ่มจาก Default(THPDFXFDFArrayAccess) คืน True จาก GetFormFieldValueArray กับฟิลด์ที่ค่าเป็น list ทุกตัว รวมถึงตัวที่ไม่ได้เลือกอะไร และ False เพื่อย้อนไปใช้ callback แบบสเกลาร์

uses HPDFXFDF;

// function pointer ธรรมดา ไม่ใช่ "of object": Context แบก store ของตัวเอง
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // scalar binding เดิมของคุณ
  ArrayAccess := Default(THPDFXFDFArrayAccess); // ทุกช่องที่ไม่ใช้เป็น nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

การแลกเปลี่ยนแบบ multi-select ใช้กับ list box ที่มีอยู่แล้วซึ่งติด bit MultiSelect ไว้ใน /Ff ส่วนฟิลด์ choice กับ bit ธงของมันถูกสร้างขึ้นมาแรกเริ่มอย่างไร ดูการเพิ่ม ListBox กับฟิลด์ AcroForm อื่นให้ PDF ที่โหลดมา ส่วน markup คอมเมนต์ที่เดินผ่าน tree <annots> ของ XFDF ดูการนำเข้าและส่งออก annotation XFDF ใน HotPDF เอกสารอ้างอิง API เต็มและตัวดาวน์โหลดทดลองอยู่ที่หน้า HotPDF Delphi PDF component