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

บั๊ก Checkbox PDF Flatten: Field Value กับ Widget ใน Delphi

Checkbox กับ radio button flatten ออกมาเป็นไม่ถูกติ๊ก เพราะ appearance state /AS ไม่เคยถูก synchronise กับ field value /V เลย PDFium Component ซึ่งเป็น VCL และ LCL component ที่สร้างบน PDFium สำหรับ Delphi, C++Builder และ Lazarus ตอนนี้อ่านค่านั้นด้วย FPDFAnnot_GetFormFieldValue ซึ่ง resolve parent field dictionary แทนที่จะเป็น widget annotation

รายงานบั๊กที่นำมาสู่เรื่องนี้เป็นแบบที่คุณไม่เชื่อในตอนแรก ลูกค้ารายหนึ่ง flatten แบบฟอร์มยินยอมที่เซ็นแล้ว เปิดผลลัพธ์ออกมา แล้ว checkbox ทุกอันก็ว่างเปล่า เปิดไฟล์ต้นฉบับใน Acrobat แล้วกล่องต่าง ๆ ก็ถูกติ๊กไว้อย่างเห็นได้ชัด อ่านไฟล์ต้นฉบับกลับผ่าน component เดียวกันแล้ว field value ก็ถูกต้อง มีแค่ output ที่ flatten แล้วเท่านั้นที่สูญเสียมันไป และเฉพาะกับ checkbox กับ radio button เท่านั้น ในขณะที่ text field บนหน้าเดียวกันออกมาถูกต้องดี

ทำไม checkbox ถึงกลายเป็นไม่ถูกติ๊กหลัง flatten

เพราะการ flatten ไม่เคยมองที่ /V เลย FPDFPage_Flatten อบ appearance stream ของ widget ลงไปเป็น page content และ appearance ที่มันเลือกคือตัวที่ถูกตั้งชื่อโดย /AS ถ้า /AS ยังบอกว่า /Off ในขณะที่ field value บอกว่ากล่องนั้นเปิดอยู่ การ flatten ก็จะอบ appearance แบบ off ออกมาอย่างซื่อสัตย์ ค่านั้นไม่เคยหายไปเลย มันแค่ไม่เคยถูกปรึกษาเท่านั้นเอง

FPDFPage_Flatten ใน Delphi อบ appearance stream ของเช็กบ็อกซ์ที่ /AS ระบุเข้าไปในหน้า ขณะที่ค่าฟิลด์ /V ไม่เคยถูกปรึกษาเลย /AS /Off ที่เก่าค้างจึงทำให้กล่องที่อบแล้วแสดงเป็นยังไม่ติ๊ก
การ flatten อ่านตัวเลือก /AS และไม่เคยปรึกษา /V ลักษณะปรากฏ Off ที่ล้าสมัยจึงถูกอบติดทั้งที่ค่าฟิลด์ยังบอกว่ากล่องเปิดอยู่

ISO 32000-1 §12.5.5 นิยาม appearance dictionary /AP ไว้มีสาม entry ที่เป็นไปได้ คือ /N, /R และ /D สำหรับ check box หรือ radio button entry /N ไม่ใช่ stream แต่เป็น subdictionary ที่คีย์ของมันคือชื่อ appearance state และ §12.5.2 กำหนดให้ /AS เป็นตัวเลือกที่บังคับเมื่อ /N เป็น subdictionary ดังนั้น checkbox จึงบรรทุก appearance ที่สร้างไว้ล่วงหน้าสองแบบและตัวชี้หนึ่งตัว ถ้าตัวชี้ผิด การ render ก็จะผิดในแบบที่ไม่มี /V ที่ถูกต้องมากแค่ไหนก็ซ่อมไม่ได้ นี่ก็เป็นเหตุผลที่โหมดความล้มเหลวต่างจาก text field ด้วย เพราะ text field ไม่มี appearance ที่สร้างไว้ล่วงหน้าให้เลือกเลย: /N ของ text field เป็น stream เดียวที่ต้องสร้างขึ้นใหม่ตั้งแต่ต้นหลังจากค่าเปลี่ยน ดังนั้น GenerateFormAppearances จึงจัดการสองกรณีนี้ผ่าน code path ที่แยกจากกันโดยสิ้นเชิง และมีแค่ path ของปุ่มเท่านั้นที่พัง

ค่าของ checkbox อยู่ที่ไหนจริง ๆ

อยู่บน field dictionary ไม่ใช่บน widget ISO 32000-1 §12.7.5.2 อธิบาย check box กับ radio button ว่าเป็น button field ที่ /V ของมันเป็น name object ที่ระบุ appearance state ปัจจุบัน และ §12.7.3.1 วาง /V ไว้ในกลุ่ม entry ที่ใช้ร่วมกันในทุก field dictionary widget annotation ที่นิยามไว้ใน §12.5.6.19 มีส่วนร่วมแค่ /AS กับ /AP ไม่มีอะไรในสเปกที่บังคับให้ widget ต้องบรรทุก /V เลย

// ผิด: อ่าน widget annotation dictionary โดยตรง
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// สำหรับฟอร์มจริงส่วนใหญ่ buflen จะกลับมาเป็น 2 (สตริง UTF-16 ว่างเปล่า),
// ดังนั้น /AS จะไม่ถูกเขียนและกล่องถูกราบเรียบเป็น Off

{ ลักษณะของ object สองตัวเมื่อ field มี widgets หลายตัว:

  12 0 obj                          % dictionary ของ field (parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (child)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue ไม่ได้บกพร่องเลย สัญญาของมันคือสิ่งที่ชื่อของมันบอกพอดี: ดึง string entry จาก annotation dictionary ที่คุณส่งให้มัน การถาม /V บนอ็อบเจกต์ 13 ไม่คืนอะไรเลยเพราะอ็อบเจกต์ 13 ไม่มี /V จริง ๆ ข้อบกพร่องอยู่ที่ผู้เรียก ซึ่งสันนิษฐานโมเดลอ็อบเจกต์แบบแบนที่ ISO 32000-1 ไม่เคยสัญญาไว้เลย

เมื่อไรที่ field กับ widget ใช้ dictionary ร่วมกัน

เมื่อใดก็ตามที่ field หนึ่งมี widget พอดีหนึ่งตัว §12.5.6.19 อนุญาตให้ field dictionary กับ widget annotation ตัวเดียวของมันรวมเข้าเป็นอ็อบเจกต์เดียวกันได้ และเครื่องมือสร้างส่วนใหญ่ก็ใช้ทางลัดนั้น ในอ็อบเจกต์ที่รวมกันแล้ว /FT, /T, /V, /AS และ /AP ทั้งหมดอยู่เคียงข้างกัน ดังนั้นการอ่าน /V ระดับ widget จึงสำเร็จ และบั๊กทั้งตัวก็ยังคงมองไม่เห็น

ทันทีที่ field หนึ่งมี widget ตั้งแต่สองตัวขึ้นไป การรวมกันก็เป็นไปไม่ได้ และ §12.7.3.1 บังคับให้ widget กลายเป็น /Kids ของ field dictionary แยกต่างหาก radio group ทุกกลุ่มมีรูปร่างนี้โดยธรรมชาติของมัน checkbox ยินยอมที่ซ้ำกันในหัวกระดาษกับท้ายกระดาษก็เช่นกัน และ field ใดก็ตามที่เครื่องมือสร้างได้คัดลอกไปยังหน้าที่สอง นั่นคือคำอธิบายทั้งหมดว่าทำไมข้อบกพร่องนี้ถึงรอดจาก regression suite มาได้: corpus การทดสอบเต็มไปด้วยฟอร์มแบบ single-widget ในขณะที่ไฟล์ของลูกค้าไม่ใช่แบบนั้น ถ้าคุณไล่ widget เองแทนที่จะพึ่งพา component ความไม่สมมาตรแบบเดียวกันนี้ก็ปรากฏในลำดับการ enumerate ด้วย และบันทึกเรื่อง การนำทาง form field ของ PDF ด้วย PDFium Component ครอบคลุมว่าการไล่ annotation ระดับหน้าเกี่ยวข้องกับ field tree ระดับเอกสารอย่างไร

การอ่านค่าตามที่ PDFium ตั้งใจไว้

FPDFAnnot_GetFormFieldValue คือ API ที่ถูกต้อง และมันถูกผูกไว้ใน component มาสักระยะแล้วโดยที่ path ของ checkbox ไม่ได้ใช้มัน มันรับ form handle เพิ่มเติมจาก annotation ซึ่งเป็นสัญญาณที่สำคัญ: เมื่อมี form-fill environment อยู่ PDFium จะ resolve annotation ไปยัง form control ของมันแล้วอ่านค่าจาก field object ดังนั้นมันจึงคืนคำตอบที่ถูกต้องทั้งสำหรับ layout แบบรวมและแบบแยก

โมเดลออบเจ็กต์ฟิลด์เช็กบ็อกซ์ใน Delphi: dictionary ฟิลด์แม่เป็นเจ้าของ /V /On ขณะที่ annotation วิดเจ็ตลูกแบกเพียง /AS กับ /AP, FPDFAnnot_GetStringValue จึงหาอะไรไม่เจอ และ FPDFAnnot_GetFormFieldValue ไล่หาแม่แล้วคืน On
ค่าอาศัยอยู่บน dictionary ฟิลด์แม่ ในขณะที่ widget ลูกพาเพียง /AS และ /AP การอ่าน /V ระดับ widget จึงกลับมาว่างเปล่า และ API ของ form-field ไปแก้อ้างอิงที่ตัวแม่แทน
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP ถูกสร้างไว้ล่วงหน้าต่อสถานะ; เฉพาะ /AS ที่ต้องซิงค์กับ /V.
    // FPDFAnnot_GetFormFieldValue แก้ parent field dictionary,
    // ซึ่งเป็นที่ที่ ISO 32000-1 12.7.5.2 เก็บค่าไว้
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

มีสองรายละเอียดใน snippet นั้นที่ทำผิดพลาดได้ง่าย ความยาวที่คืนมาเป็นจำนวน byte สำหรับข้อความ UTF-16 รวมตัวจบด้วย ดังนั้นจำนวนตัวอักษรคือ buflen div 2 - 1 และค่า 2 หมายถึงสตริงว่างเปล่า การป้องกันด้วย buflen >= 4 จึงหมายถึงมีตัวอักษรจริงอย่างน้อยหนึ่งตัว ซึ่งเป็นสิ่งที่ป้องกัน field ที่ไม่มี /V เลยจากการถูกเขียนทับ /AS ด้วยชื่อว่างเปล่า

/AS กับ /AP /N ตกลงกันจริง ๆ ว่าอะไร

พวกมันตกลงกันเรื่องชื่อ และชื่อนั้นถูกเลือกโดยใครก็ตามที่สร้างไฟล์ §12.7.5.2 บังคับให้ off state ต้องถูกเรียกว่า /Off และปล่อยให้ on state เป็นเรื่องของ producer ทั้งหมด /Yes เป็นธรรมเนียม ไม่ใช่กฎ Acrobat เขียน /Yes แต่ตัวสร้างจำนวนมากเขียน /On, /1, /Choice1 หรือคำที่แปลเป็นภาษาท้องถิ่น และ radio group โดยปกติจะให้แต่ละ kid มีชื่อ on-state ที่แตกต่างกัน เพื่อให้กลุ่มสามารถแสดงได้ว่าปุ่มไหนถูกเลือก นี่คือเหตุผลที่แม่นยำว่าทำไมการคัดลอก /V ตามตัวอักษรเข้าไปใน /AS ถึงเป็นการดำเนินการที่ถูกต้อง ไม่ใช่แค่ทางลัด: สำหรับ control ที่ถูกติ๊ก PDFium รายงานชื่อ on-state ที่ไฟล์เองนิยามไว้ และสำหรับตัวที่ไม่ถูกติ๊กมันรายงาน Off ดังนั้นค่าที่คุณเขียนลงใน /AS จึงรับประกันว่าเป็นคีย์ที่มีอยู่ใน subdictionary /AP /N ของ widget นั้น การ hard-code /Yes จะใช้ได้กับ output ของ Acrobat แต่จะพังอย่างเงียบ ๆ ในทุกที่อื่น

ลำดับการดำเนินการ และจุดที่ยังต้องระมัดระวัง

ลำดับนี้ตายตัวและไม่ยอมผ่อนปรน: เปิดใช้ form fill กำหนดค่า สร้าง appearance ใหม่ flatten แล้วจึงเซฟ ข้ามขั้นตอนการสร้างใหม่แล้ว FPDFPage_Flatten จะพบ appearance stream ที่ว่างเปล่าหรือล้าสมัยและอบมันโดยไม่บ่นอะไรเลย ซึ่งเป็นการสูญเสียข้อมูลแบบเงียบมากกว่าจะเป็นค่าคืน error

ลำดับการทำงานคงที่สำหรับการแบนฟอร์มใน Delphi: เปิดใช้การกรอกฟอร์ม, กำหนดค่าฟิลด์, สร้าง appearance ใหม่, แบน แล้วบันทึก โดยจะสูญเสียข้อมูลอย่างเงียบๆ หากข้ามการสร้าง appearance ใหม่
Form fill ต้องเปิดก่อนกำหนดค่า, GenerateFormAppearances ต้องซิงก์ /AS ก่อน flatten และการข้ามขั้นตอนนั้นปล่อยให้ flattening อบสตรีมล้าสมัยติดไว้โดยไม่มีข้อผิดพลาด
Pdf.FileName := FormPath;
Pdf.FormFill := True;          // จำเป็น: FormHandle ต้องมีอยู่
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // เขียนเฉพาะ /V

Pdf.GenerateFormAppearances;   // ซิงค์ /AS สำหรับปุ่ม, สร้าง /AP ใหม่สำหรับข้อความ
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

ยังมีขอบเขตที่ซื่อตรงอยู่สองข้อ ข้อแรก การ sync เขียน field value ลงใน /AS ของทุก widget ของ field นั้น ซึ่งถูกต้องสำหรับ checkbox แต่เป็นการประมาณสำหรับ radio group ที่แต่ละ kid นิยามชื่อ on-state ของตัวเอง kid ที่ /AP /N ของมันไม่มี entry ที่ตรงกับ /AS ที่เขียนไว้จะไม่มี appearance ให้เลือกภายใต้ §12.5.5 ดังนั้นปุ่มที่ไม่ถูกเลือกอาจ flatten ออกมาเป็นความว่างเปล่าแทนที่จะเป็นวงกลมว่าง การตรวจสอบ radio group ด้วย FPDFAnnot_GetFormControlIndex ก่อน flatten คุ้มค่ากับไม่กี่บรรทัด ข้อสอง ไม่มีข้อไหนที่ใช้ได้กับ XFA เลย ซึ่งค่าอยู่ใน XML data packet แทนที่จะเป็น AcroForm dictionary การแยกนี้ครอบคลุมไว้ในบันทึกเรื่อง การแก้ไข field ของ XFA ที่ไม่ถูกบันทึก บทเรียนทั่วไปนี้ควรจดจำไว้เกินกว่าแค่การแก้ไขครั้งนี้: เมื่อใดก็ตามที่ API รับ form handle เพิ่มเติมจาก annotation มันกำลังบอกคุณว่ามันจะ resolve field hierarchy ให้คุณ และเมื่อใดก็ตามที่มันรับแค่ annotation มันจะอ่านอ็อบเจกต์ที่คุณส่งให้พอดีเท่านั้น ความแตกต่างนั้นยังควบคุมการแลกเปลี่ยนข้อมูลด้วย เพราะ การ export และ import ข้อมูลฟอร์ม XFDF ทำงานด้วยชื่อ field แบบเต็มรูปแบบเสมอ ไม่เคยใช้ตำแหน่งของ widget เลย

การ flatten ฟอร์มเป็นหนึ่งในฟีเจอร์เหล่านั้นที่ดูเหมือนเป็นการเรียก API เดียว แต่กลับกลายเป็นสัญญาระหว่าง dictionary สามตัว ถ้าคุณอยากทำงานกับ component ที่เข้ารหัสสัญญานั้นไว้แล้ว PDFium Component สำหรับ Delphi และ C++Builder มาพร้อมการสร้าง appearance ใหม่ การ flatten และการเข้าถึง form field ที่อธิบายไว้ที่นี่ในรูปแบบ property และ method ธรรมดา