ใน PDFium Component ซึ่งเป็น VCL/LCL component ที่สร้างบน PDFium สำหรับ Delphi, C++Builder และ Lazarus form field index ไม่ใช่ annotation index หน้าหนึ่งบรรทุก annotation แบบ Link, Text และ Ink ไว้ข้าง ๆ widget ของมัน ดังนั้นการ enumerate field จึงต้องกรองด้วย FPDFAnnot_GetSubtype และเปิดเผย logical index แบบ zero-based ที่ map กลับไปเป็นตำแหน่ง annotation จริงเฉพาะตอนเรียก native เท่านั้น
บั๊กที่เปิดโปงเรื่องนี้ชัดเจนไม่มีข้อสงสัยเมื่อคุณเคยเห็นมันมาแล้ว ผู้ทดสอบกด Tab ในแบบฟอร์มใบแจ้งหนี้ที่กรอกแล้วแล้วเคอร์เซอร์ก็หายไป เพราะ focus ไปที่ hyperlink ในท้ายกระดาษ หรือแย่กว่านั้น ไม่มีอะไรเกิดขึ้นเลย: โค้ดของคุณบันทึกว่า field 3 ถูก focus แล้ว UI panel อัปเดต และ FORM_SetFocusedAnnot ก็คืนค่า false อย่างเงียบ ๆ มาตลอด อาการทั้งสองมาจากความผิดพลาดในการออกแบบเดียวกัน และหนึ่งในนั้นมีสาเหตุรากที่สองซ่อนอยู่ข้างใต้ด้วย
พื้นที่ index สองแบบที่ PDFium ส่งให้คุณ
PDFium เปิดเผยระบบการนับสองแบบบนหน้าเดียวกัน และทั้งสองจะตรงกันก็ต่อเมื่อเอกสารบังเอิญไม่มีอะไรนอกจาก form widget เท่านั้น แบบแรกคือ annotation index ซึ่งเป็นตำแหน่งใน array /Annots ของหน้า และนี่คือสิ่งที่ FPDFPage_GetAnnotCount นับและ FPDFPage_GetAnnot รับ (ISO 32000-1 §12.5.2) แบบที่สองคือ logical field index ที่ API ระดับแอปพลิเคชันควรเสนอ ซึ่งไล่จากศูนย์ผ่าน field แบบ interactive ที่ผู้ใช้เข้าถึงได้จริง ISO 32000-1 §12.5.6.19 นิยาม widget annotation ว่าเป็นการแสดงผลด้านภาพของ field ฟอร์มแบบ interactive และ §12.7 นิยามตัวฟอร์มเอง ทุกอย่างอื่นบนหน้ากระดาษเป็น subtype ที่ต่างกันพร้อม semantics ที่ต่างกัน: Link annotation มี destination, Ink annotation มีรายการ stroke, Text annotation คือ sticky note ไม่มีตัวไหนควรอยู่ในการนับ field เลย และไม่มีตัวไหนรับ focus ของฟอร์มได้ แต่ใน array /Annots พวกมันเรียงสลับกับ widget ตามลำดับที่แอปพลิเคชันที่สร้างมันเขียนไว้ ซึ่งบ่อยครั้งไม่ใช่ลำดับที่อะไรอื่นในเอกสารบ่งบอก
ทำไม Tab ถึงลงเอยที่ hyperlink แทนที่จะเป็น field ถัดไป
เพราะการนับ field แท้จริงแล้วเป็นการนับ annotation implementation ดั้งเดิมคืน FPDFPage_GetAnnotCount ตรง ๆ จาก FormFieldCount ในขณะที่ accessor ข้อมูล field, tab order helper และ focus helper ทั้งหมดปฏิบัติต่อ integer เดียวกันนั้นเป็นตำแหน่ง widget บนหน้า AcroForm ที่สะอาดที่มี widget หกตัวและไม่มีอะไรอื่นเลย หกเท่ากับหกและการทดสอบทุกตัวผ่าน เพิ่ม hyperlink ในท้ายกระดาษและ comment ของผู้ตรวจสอบในระยะขอบเข้าไป แล้วการนับจะรายงานแปด field, index 6 กับ 7 resolve ไปเป็นอ็อบเจกต์ที่ไม่ใช่ฟอร์ม และ Tab ก็เดินตรงเข้าไปในนั้น
วิธีแก้ที่ปลายการ enumerate คือนับ subtype แทนที่จะนับ annotation เปิดแต่ละ annotation ถามหา subtype ของมัน เก็บ widget ไว้ และปิด handle ใน block finally เพราะ FPDFPage_GetAnnot คืน handle ที่เป็นเจ้าของซึ่งต้องส่งกลับผ่าน FPDFPage_CloseAnnot
function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
Count, I: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := 0;
if Page = nil then
Exit;
Count := FPDFPage_GetAnnotCount(Page); // every annotation, not just fields
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
Inc(Result);
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
สังเกตสิ่งที่มันจงใจไม่ทำ มันไม่ถามอะไร form-fill environment เลย และมันไม่ต้องการ form handle เพราะ subtype อยู่ใน annotation dictionary และอ่านได้จากหน้ากระดาษเพียงอย่างเดียว นั่นสำคัญสำหรับลำดับการทำงาน: การนับมีอยู่ก่อนที่คุณจะตัดสินใจว่าเอกสารนี้สมควรได้ form-fill environment หรือไม่ ซึ่ง บทความเรื่อง AcroForm JavaScript และ host event ครอบคลุมไว้ในฐานะการตัดสินใจด้านความปลอดภัยมากกว่าความสะดวก
การ map logical index กลับที่ขอบเขต native
กฎที่ป้องกันไม่ให้พื้นที่ทั้งสองรั่วไหลเข้าหากันนั้นง่าย ๆ: logical index เป็นตัวเลขเดียวที่ข้าม public API ของคุณ และมันถูกแปลงเป็น annotation index ในฟังก์ชันสุดท้ายก่อนการเรียก native helper สำหรับการ mapping ตัวเดียว ที่ใช้ร่วมกันโดยข้อมูล field, focus, ตัวตั้งค่า flag และ tab order คือสิ่งที่ทำให้กฎนี้บังคับใช้ได้จริง
function AnnotationIndexForField(Page: FPDF_PAGE;
FieldIndex: Integer): Integer;
var
Count, I, Current: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := -1;
if (Page = nil) or (FieldIndex < 0) then
Exit;
Count := FPDFPage_GetAnnotCount(Page);
Current := 0;
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
begin
if Current = FieldIndex then
Exit(I); // real /Annots position: native calls only
Inc(Current);
end;
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
มีสองคุณสมบัติของ helper นี้ที่ควรระบุให้ชัดเจน มันเป็นการสแกนแบบเชิงเส้น ดังนั้นลูปที่ไร้เดียงสาผ่านทุก field มีต้นทุนเป็นจำนวน quadratic ของการเปิด annotation บนหน้าที่มี widget หลายร้อยตัว ถ้าคุณกำลัง enumerate ทั้งหน้า ให้ไล่ผ่าน annotation ครั้งเดียวแล้วเก็บ handle ของ widget ไปด้วยระหว่างทาง แทนที่จะเรียก mapper ต่อ field และมันคืนค่า -1 แทนที่จะ raise ซึ่งให้ผู้เรียกตัดสินใจว่า index ที่ล้าสมัยเป็น programming error ที่คุ้มค่าจะได้ exception หรือเป็นการแข่งขันที่คุ้มค่าจะเพิกเฉย เช่น หลังจากการแก้ไขที่ลบ annotation ที่ UI list ที่แคชไว้ยังอ้างถึงอยู่
ทำไม FORM_SetFocusedAnnot ถึงล้มเหลวบนหน้าแบบ headless
เพราะ PDFium ปฏิเสธที่จะ focus widget ที่ page view ของมันไม่เคยถูกทำเครื่องหมายว่าถูกต้อง FORM_SetFocusedAnnot resolve annotation ไปเป็น page view ภายใน form-fill environment และถ้า page view นั้นไม่มีอยู่ มันจะคืนค่า false โดยไม่มีการวินิจฉัยใด ๆ เลย การแก้ไข index mapping เพียงอย่างเดียวจึงแก้ปัญหา Tab ที่ลงเอยที่ hyperlink ได้ แต่ปล่อยอาการที่สองไว้ไม่แตะต้อง: บันทึก logical focus ของคุณบอกว่า field 3 แต่ native focused widget ยังคงเป็นความว่างเปล่า และทุก accessor ที่สร้างบน native focus, focused text, focused value, choice selection state ก็ยังคงคืนค่าว่างเปล่าต่อไป page view ถูกสร้างโดย FORM_OnAfterLoadPage และถูกทำลายโดย FORM_OnBeforeClosePage ใน viewer ที่สร้างขึ้นรอบ visual control การเรียกเหล่านั้นเกิดขึ้นเป็นส่วนหนึ่งของการแสดงหน้า ซึ่งเป็นเหตุผลที่ความล้มเหลวนี้มักดูเหมือนบั๊กที่เกิดเฉพาะแบบ headless: โค้ดเดียวกันที่ทำงานได้ในเดโม GUI ล้มเหลวในเครื่องมือ batch lifecycle นี้เป็นของ document object ไม่ใช่ของ viewer ดังนั้น PDFium Component จึงเรียกทั้งสองครั้งตอนนี้ทุกครั้งที่หน้าถูกโหลดหรือยกเลิกโหลดโดยมี form handle อยู่ signature ของ C รับหน้ากระดาษก่อนแล้วจึงรับ form handle ทีหลัง ซึ่งกลับด้านได้ง่ายเมื่อเขียน binding ด้วยมือ
procedure ReportFirstField(const FileName: string);
var
Pdf: TPdf;
Idx: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FormFill := True; // form-fill environment, before Active
Pdf.FileName := FileName;
Pdf.Active := True;
Pdf.PageNumber := 1; // page load also runs FORM_OnAfterLoadPage
Idx := Pdf.FocusNextFormField; // logical index, 0-based over widgets
if Idx < 0 then
Exit; // page holds no widget annotations
Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
string(Pdf.FocusedFormFieldValue)); // reads the native focused widget
finally
Pdf.Free; // page unload runs FORM_OnBeforeClosePage
end;
end;
การตรวจสอบที่พิสูจน์ว่าการแก้ไขได้ผลคือตัวที่เปรียบเทียบทั้งสองฝั่งกัน เรียก FocusFormField ด้วย logical index แล้วอ่านค่าผ่าน accessor ที่ผ่าน native focused widget แทนที่จะผ่านบันทึกของคุณเอง เช่น FocusedFormFieldValue หรือ FocusedFormOptionSelected ถ้า logical index round-trip ได้แต่ native accessor คืนค่าว่างเปล่ากลับมา page view ที่หายไปเป็นปัญหา ไม่ใช่การ mapping
Logical field index ไม่ได้สัญญาอะไรบ้าง
Field index แบบ zero-based เป็นความสะดวก ไม่ใช่ identity เชิงความหมาย และมีข้อจำกัดสี่ข้อที่ตามมาจากนั้น มันเป็นต่อหน้ากระดาษ ไม่ใช่ต่อเอกสาร ดังนั้น index 0 บนหน้า 2 จึงเป็น widget คนละตัวจาก index 0 บนหน้า 1 และการเปรียบเทียบทั้งสองไม่มีความหมายอะไรเลย มันเป็นแบบตามตำแหน่ง ดังนั้นการแทรกหรือลบ annotation ทำให้ index ที่แคชไว้ทุกตัวที่อยู่เหนือการเปลี่ยนแปลงเป็นโมฆะ ปฏิบัติต่อ index ที่เก็บไว้ว่าถูกต้องแค่ตราบเท่าที่หน้ากระดาษยังคงโหลดอยู่และไม่ถูกแก้ไขเท่านั้น
ข้อจำกัดข้อที่สามเป็นข้อที่ทำให้คนที่ทบทวนรายการ field แปลกใจ index นี้ enumerate widget ไม่ใช่ field radio group เป็น field เดียวที่มี widget kid หลายตัว ดังนั้นกลุ่มปุ่มสามปุ่มจึงมีส่วนร่วมสาม index ติดต่อกันที่ทั้งหมดรายงาน Name เดียวกัน record TPdfFormFieldInfo บรรทุก GroupCount กับ GroupIndex ไว้พอดีสำหรับกรณีนี้ และ UI รายการที่เพิกเฉยต่อมันจะแสดง field เดียวกันซ้ำสามครั้ง ข้อจำกัดข้อที่สี่เกี่ยวกับลำดับการไล่ผ่าน: tab order ที่เปิดเผยไว้ที่นี่คือลำดับการ enumerate widget ซึ่งตาม array /Annots ไม่ใช่ entry /Tabs ของหน้ากระดาษ (ISO 32000-1 §7.7.3.3) และไม่ใช่ field tree ของ AcroForm สำหรับ producer ส่วนใหญ่ทั้งสองตรงกัน สำหรับฟอร์มที่จัด layout เป็นสองคอลัมน์โดย generator ที่ปล่อยคอลัมน์ขวาออกมาก่อน ทั้งสองไม่ตรงกัน และ path คีย์บอร์ดที่อธิบายไว้ใน บทความเรื่องการนำทาง form field จะรู้สึกผิดปกติแม้ว่าทุก index จะถูกต้องก็ตาม เมื่อไฟล์ของลูกค้าทำงานแปลก ๆ ให้ dump พื้นที่ index ทั้งสองเทียบกันก่อนตั้งสมมติฐานใด ๆ: มุมมองของ annotation กับมุมมองของ field บนหน้าเดียวกัน พิมพ์ออกมาด้วยกัน มักทำให้สาเหตุชัดเจนได้ในการมองครั้งเดียว
procedure DumpIndexSpaces(Pdf: TPdf);
var
I: Integer;
Info: TPdfFormFieldInfo;
begin
for I := 0 to Pdf.AnnotationCount - 1 do
Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));
for I := 0 to Pdf.FormFieldCount - 1 do
begin
Info := Pdf.FormFieldInfo[I];
Writeln('field ', I, ': ', string(Info.Name),
' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
end;
end;
จำนวน annotation ที่สูงกว่าจำนวน field มากหมายความว่าหน้ากระดาษผสม subtype เข้าด้วยกัน ซึ่งเป็นเรื่องปกติในเอกสารที่ถูกตรวจทาน และเป็นสถานการณ์พอดีที่การ mapping มีอยู่เพื่อรองรับ บทความเรื่อง workflow การตรวจทาน annotation มองหน้าเดียวกันนี้จากฝั่ง markup ในทางกลับกัน จำนวนที่เท่ากันในทุกไฟล์ทดสอบหมายความว่า fixture ของคุณตรวจจับบั๊กประเภทนี้ไม่ได้เลย และการตอบสนองที่ซื่อตรงคือเพิ่ม form fixture ที่บรรทุกลิงก์และ sticky note เข้าไปด้วย
API การ enumerate field, focus และ annotation ที่อธิบายไว้ที่นี่มาพร้อมกับ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus หน้าผลิตภัณฑ์มีเอกสารอ้างอิง form-field แบบเต็ม รวมถึง record ข้อมูล field และ accessor ของ focus