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

คำอธิบายประกอบการทำเครื่องหมายข้อความด้วย PDFium QuadPoints ใน Delphi

คอมโพเนนต์ PDFium สามารถสร้างคำอธิบายประกอบการทำเครื่องหมายข้อความ (text markup annotations) ซึ่งหมายถึง การทำไฮไลต์ ขีดเส้นใต้ ขีดฆ่า และขีดเส้นหยัก ผ่านฟังก์ชัน TPdf.CreateAnnotation: โดยคุณจะต้องตั้งค่า HasAttachmentPoints := True บนเรกคอร์ด TPdfAnnotation และกรอกข้อมูลรูปสี่เหลี่ยมด้านไม่เท่า AttachmentPoints และคอมโพเนนต์จะเขียนรายการ QuadPoints ที่กำหนดไว้ในมาตรฐาน ISO 32000-1 §12.5.6.10 นั่นคือโครงสร้าง API ทั้งหมด เหตุผลที่มีบทความนี้เกิดขึ้นคือสิ่งที่เป็นผลลัพธ์ภายใต้กลไกดังกล่าว เนื่องจากสายโซ่การเรียกใช้ PDFium แบบดิบมีโหมดความล้มเหลวที่แจ้งรหัสข้อผิดพลาดที่ไร้ประโยชน์ที่สุดในเครื่องมือ: ฟังก์ชัน FPDFAnnot_SetAttachmentPoints จะส่งกลับค่าเท็จกับคำอธิบายประกอบที่สร้างขึ้นใหม่เสมอ โดยไม่มีรหัสข้อผิดพลาดและคำแนะนำใดๆ บทความนี้เป็นเนื้อหาคู่ขนานฝั่งการสร้างร่วมกับบทความของเราเกี่ยวกับการอ่านและตรวจสอบคำอธิบายประกอบที่มีอยู่ ซึ่งจะอธิบายโครงสร้างเดียวกันนี้ในทิศทางตรงกันข้าม

ขั้นตอนการดีบักจะเหมือนกันเสมอ คุณสร้างคำอธิบายประกอบไฮไลต์ เรียกใช้ฟังก์ชันกำหนด attachment-points ด้วยดัชนี 0 ฟังก์ชันส่งกลับค่าเท็จ และคุณเริ่มสงสัยในพิกัดแกนของคุณเอง คุณลองสลับค่าจุด พลิกแกน Y หรือเปลี่ยนพื้นที่หน้ากระดาษเป็นพื้นที่อุปกรณ์ แต่สิ่งเหล่านั้นไม่ได้ช่วยอะไรเลย เพราะปัญหาไม่ได้อยู่ที่พิกัด ปัญหาที่แท้จริงคือความหมายของดัชนีใน C API และเมื่อคุณเข้าใจมันแล้ว การแก้ไขจะมีรหัสเพียงแค่สองบรรทัดเท่านั้น

ความหมายของ QuadPoints ในมาตรฐาน ISO 32000-1

โครงสร้าง QuadPoints คืออาร์เรย์ของตัวเลขขนาด 8×n ที่ระบุรูปสี่เหลี่ยมด้านไม่เท่าจำนวน n รูป และมาตรฐาน ISO 32000-1 §12.5.6.10 กำหนดให้ต้องมีสิ่งนี้ในทุกคำอธิบายประกอบการทำเครื่องหมายข้อความ: รูปสี่เหลี่ยมแต่ละรูปจะระบุคำหรือกลุ่มคำที่ต่อเนื่องกันซึ่งจะถูกทำไฮไลต์ ขีดเส้นใต้ หรือขีดฆ่า รายการข้อมูล Rect ของคำอธิบายประกอบจะยังคงมีอยู่ แต่สำหรับประเภทย่อยของการทำเครื่องหมาย มันจะทำหน้าที่เพียงขอบเขตพื้นที่เท่านั้น ส่วนรูปสี่เหลี่ยมด้านไม่เท่าจะเป็นส่วนที่ตัววาดผล (renderer) จะวาดสีลงไปจริง เหตุผลที่ต้องใช้รูปสี่เหลี่ยมด้านไม่แทนแทนรูปสี่เหลี่ยมผืนผ้าปกติ เนื่องจากข้อความสามารถหมุนหรือเฉียงได้ มุมทั้งสี่จึงถูกจัดเก็บในรูปของจุดอิสระสี่จุด ได้แก่ x1 y1 x2 y2 x3 y3 x4 y4

ลำดับของจุดทั้งสี่นี้เป็นจุดที่ข้อกำหนดดั้งเดิมและการนำไปใช้งานจริงขัดแย้งกัน ข้อความในสเปกกำหนดว่าจุดต่างๆ ควรอ้างอิงรูปสี่เหลี่ยมตามทิศทางทวนเข็มนาฬิกา แต่ตัววาดผลของ Adobe กลับแปลงค่าในรูปแบบตัว Z (Z pattern) เสมอ: คือ ขอบด้านบนจากซ้ายไปขวา ตามด้วยขอบด้านล่างจากซ้ายไปขวา เนื่องจากผู้สร้างเกือบทั้งหมดทดสอบเอกสารกับ Acrobat ตัววาดผลเกือบทั้งหมดรวมถึง PDFium จึงทำงานตามรูปแบบตัว Z และไฟล์ที่จัดจุดตามสเปกตัวอักษรจะแสดงผลไฮไลต์ที่บิดเบี้ยวหรือพับตัวในโปรแกรมอ่านบางตัว โครงสร้าง FS_QUADPOINTSF ของ PDFium เข้ารหัสตามมาตรฐานนี้โดยตรง: (x1,y1) คือมุมซ้ายบน, (x2,y2) คือขวาบน, (x3,y3) คือซ้ายล่าง และ (x4,y4) คือขวาล่าง ในค่าพิกัดหน้ากระดาษที่แกน Y ชี้ขึ้นบน โปรดจัดลำดับตามนี้ให้เสร็จสิ้น ตัววาดผลสามารถยอมผ่อนปรนในหลายเรื่องได้ แต่การจัดจุดรูปสี่เหลี่ยมที่สลับสับสนไม่ใช่หนึ่งในนั้น

ทำไม FPDFAnnot_SetAttachmentPoints จึงส่งกลับค่าเท็จ?

ฟังก์ชัน FPDFAnnot_SetAttachmentPoints ล้มเหลวกับคำอธิบายประกอบใหม่เนื่องจากข้อตกลงการทำงานของมันคือการ แทนที่ (replace) รูปสี่เหลี่ยม ณ ดัชนีที่กำหนด และคำอธิบายประกอบที่สร้างขึ้นใหม่จะมีจำนวนรูปสี่เหลี่ยมเป็นศูนย์ให้แทนที่ โครงสร้างฟังก์ชันต้องการแฮนเดิลคำอธิบายประกอบ, ค่า quad_index และข้อมูลจุด ดัชนี 0 ไม่ได้หมายถึง "ช่องแรก และให้สร้างขึ้นใหม่ถ้าจำเป็น" แต่มันหมายถึง "รูปสี่เหลี่ยมหมายเลข 0 ที่มีอยู่แล้ว" และเมื่อ FPDFAnnot_CountAttachmentPoints รายงานค่าเป็น 0 ย่อมหมายถึงไม่มีรูปสี่เหลี่ยมดังกล่าว ส่งผลให้การเรียกใช้ส่งกลับค่าเท็จ ฟังก์ชันที่ทำหน้าที่สร้างช่องเก็บคือ FPDFAnnot_AppendAttachmentPoints คำอธิบายประกอบทุกตัวที่สร้างผ่าน FPDFPage_CreateAnnot จะเริ่มด้วยจำนวนศูนย์ เส้นทางการสร้างจึงต้องเรียกใช้ Append ก่อน และเฉพาะการอัปเดตครั้งถัดไปจึงเรียกใช้ Set ได้

ตัวอย่างการทำงานเดียวกันนี้ถูกนำมาปรับใช้ในไลบรารี: ตั้งแต่เวอร์ชัน v1.79.1 เป็นต้นมา ฟังก์ชันการเขียนคำอธิบายประกอบของคอมโพเนนต์จะแยกแยะการทำงานเพื่อเรียกใช้ Append สำหรับเอกสารใหม่:

// ภายในฟังก์ชันเขียนคำอธิบายประกอบของคอมโพเนนต์ (v1.79.1+):
// คำอธิบายประกอบใหม่ยังไม่มีช่องเก็บรูปสี่เหลี่ยม Append จึงจะสร้าง
// ช่องแรกขึ้นมา ส่วน Set จะใช้แทนที่ช่องเดิมที่มีอยู่แล้วเท่านั้น
if FPDFAnnot_CountAttachmentPoints(Annotation) = 0 then
  Check(FPDFAnnot_AppendAttachmentPoints(Annotation, QuadPoints) <> 0,
    'ไม่สามารถตั้งค่าจุดแนบได้')
else
  Check(FPDFAnnot_SetAttachmentPoints(Annotation, 0, QuadPoints) <> 0,
    'ไม่สามารถตั้งค่าจุดแนบได้');

รูปแบบการทำงานเดียวกันนี้จะใช้กับการเรียกใช้ฟังก์ชัน C โดยตรง ซึ่งคอมโพเนนต์เปิดโอกาสให้คุณทำได้เนื่องจากจุดเข้าใช้ FPDFAnnot_* ทั้งหมดมีให้ใช้ใน PDFium.pas เมื่อใดก็ตามที่คุณถือแฮนเดิล FPDF_ANNOTATION และต้องการเขียนรูปสี่เหลี่ยม ให้ตรวจสอบค่าจาก FPDFAnnot_CountAttachmentPoints ก่อนและเลือกเส้นทางให้เหมาะสม หากคุณกำลังค้นหาข้อมูลสาเหตุที่ "FPDFAnnot_SetAttachmentPoints returns false" การเขียนแยกสาขา count-then-append นี้คือคำตอบของปัญหาอย่างแน่นอน

การสร้างไฮไลต์ด้วย TPdf.CreateAnnotation

การเขียนไฮไลต์ด้วยคอมโพเนนต์จะลดขั้นตอนการเขียนโค้ดลงเหลือเพียงการกำหนดค่าให้กับเรกคอร์ด ตัวอย่างต่อไปนี้เป็นการสร้างคำอธิบายประกอบไฮไลต์โปร่งแสงสีเหลืองขนาด 200×20 จุด บนหน้ากระดาษ A4 สังเกตว่าพิกัดมุมของรูปสี่เหลี่ยมจะเรียงลำดับแบบตัว Z และมีการระบุขอบเขต Rectangle ให้ครอบคลุมรูปสี่เหลี่ยม เพื่อการแสดงผลและการคลิกเลือกที่ถูกต้องบนโปรแกรมอ่าน

var
  Pdf: TPdf;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);

    FillChar(A, SizeOf(A), 0);
    A.Subtype := anHighlight;
    A.HasColor := True;
    A.Color := clYellow;
    A.ColorAlpha := $80;                     // ความโปร่งแสง 50%
    A.HasAttachmentPoints := True;
    A.AttachmentPoints[1].X := 50;  A.AttachmentPoints[1].Y := 700; // ซ้ายบน
    A.AttachmentPoints[2].X := 250; A.AttachmentPoints[2].Y := 700; // ขวาบน
    A.AttachmentPoints[3].X := 50;  A.AttachmentPoints[3].Y := 680; // ซ้ายล่าง
    A.AttachmentPoints[4].X := 250; A.AttachmentPoints[4].Y := 680; // ขวาล่าง
    A.Rectangle.Left := 50;  A.Rectangle.Top := 700;
    A.Rectangle.Right := 250; A.Rectangle.Bottom := 680;
    A.ContentsText := 'พื้นที่ไฮไลต์';
    Pdf.CreateAnnotation(A);

    Pdf.SaveAs('highlighted.pdf');
  finally
    Pdf.Free;
  end;
end;

การเปลี่ยนประเภทย่อยใช้รหัสเพียงบรรทัดเดียว ประเภท anUnderline, anStrikeout และ anSquiggly จะใช้โครงสร้างเรกคอร์ด รูปสี่เหลี่ยม และข้อมูลทั้งหมดเหมือนกัน เนื่องจากมาตรฐาน ISO 32000-1 ถือว่าทั้งสี่แบบเป็นกลุ่มคำอธิบายประกอบเดียวกันและต่างกันเพียงวิธีการตกแต่งบริเวณรูปสี่เหลี่ยม ประเภทย่อยอื่นๆ ที่ไม่ใช่การทำเครื่องหมายข้อความ เช่น anSquare, anCircle และ anText จะระบุพิกัดตำแหน่งจาก Rectangle เพียงอย่างเดียว คุณจึงควรปล่อยค่า HasAttachmentPoints เป็น False สำหรับพวกมัน และกลไกรูปสี่เหลี่ยมจะไม่ทำงานเลย

ทำไม AttachmentPoints[0] จึงคอมไพล์ผ่านใน Delphi แต่ล้มเหลวใน FPC?

ประเภทข้อมูล TQuadrilateralPoint ถูกประกาศไว้ในรูปแบบ array [1..4] of TPdfPoint ซึ่งเป็นอาร์เรย์ที่เริ่มต้นด้วย 1 (1-based array) และมักจะทำให้ผู้ที่คุ้นเคยกับดัชนีเริ่มต้นด้วยศูนย์เขียนผิดพลาด หากคุณเขียน A.AttachmentPoints[0] ตัวคอมไพเลอร์ dcc32 ของ Delphi จะคอมไพล์ผ่านโดยไม่มีการแจ้งเตือนใดๆ เนื่องจากฟังก์ชันตรวจสอบขอบเขต (range checking) ถูกปิดเป็นค่าเริ่มต้น ในเวลาทำงานจริงคำสั่งจะเข้าไปอ่านหรือเขียนหน่วยความจำก่อนหน้าอาร์เรย์ ซึ่งในเรกคอร์ด TPdfAnnotation จะเป็นฟิลด์ข้อมูลข้างเคียง ส่งผลให้ไฮไลต์ของคุณได้ค่าพิกัดมุมที่เป็นขยะหนึ่งจุด หรือฟิลด์ข้างเคียงถูกเขียนข้อมูลทับโดยไม่มีระบบแจ้งเตือน ตัวคอมไพเลอร์ Free Pascal ได้ตรวจพบข้อผิดพลาดนี้ในโค้ดตัวอย่างของเราในขั้นตอนพอร์ตไปยัง Lazarus: fpc จะทำการตรวจสอบขอบเขตตั้งแต่ช่วงเวลาคอมไพล์กับดัชนีที่เป็นค่าคงที่และปฏิเสธคำสั่ง AttachmentPoints[0..3] ทันที ซึ่งนั่นทำให้เราพบปัญหาความขัดแย้งของดัชนีและข้อผิดพลาด Set-versus-Append พร้อมๆ กัน

สิ่งที่คุณควรปฏิบัติมีสองประการ ประการแรกคือให้กำหนดดัชนีรูปสี่เหลี่ยมที่ 1 ถึง 4 ตามลำดับมุมที่แสดงในตัวอย่างโค้ดข้างต้น และประการที่สองคือให้รันคอมไพล์รหัสของคุณอย่างน้อยหนึ่งครั้งโดยเปิดการตรวจสอบขอบเขตไว้ ไม่ว่าจะเป็นคำสั่ง {$R+} ใน Delphi หรือการรันคอมไพล์ผ่าน fpc ก่อนที่จะนำไปใช้งานจริง การที่ตัวคอมไพเลอร์ dcc32 ค่าเริ่มต้นทำงานผ่านไม่ได้เป็นหลักฐานว่าดัชนีถูกต้อง แต่มันเป็นเพียงข้อพิสูจน์ว่าไม่มีระบบพังบนหน่วยความจำที่วางอยู่ตรงนั้นพอดีเท่านั้น

การดึงค่าพิกัดรูปสี่เหลี่ยมจากข้อความจริง

การระบุพิกัดแบบกำหนดค่าตายตัว (hardcoded) อาจเพียงพอสำหรับการสาธิต แต่ในการใช้งานจริงการทำไฮไลต์จะต้องอ้างอิงตำแหน่งตัวอักษรจริง และค่าพิกัดควรจะดึงมาจากคุณลักษณะรูปทรงข้อความในหน้ากระดาษของ PDFium แทนการเดาตัวเลข เมธอดที่เราอธิบายไว้ในคู่มือการแยกข้อความด้วยคอมโพเนนต์ PDFium จะช่วยให้คุณได้ข้อมูลขอบเขตของตัวอักษรแต่ละตัวในระบบพิกัดเดียวกับที่รูปสี่เหลี่ยมใช้งาน ดังนั้นผลลัพธ์การค้นหาจะสามารถแปลงเป็นจุดมุมได้ทันที: ได้แก่ ขอบซ้ายของตัวอักษรตัวแรก ขอบขวาของตัวอักษรตัวสุดท้าย และระยะบนล่างจากขอบเขตของบรรทัด หากคุณเป็นผู้สร้างข้อความขึ้นมาเองและต้องการทราบตำแหน่งการตัดคำล่วงหน้า บทความเกี่ยวกับการวัดขนาดข้อความและการตัดคำ จะช่วยครอบคลุมขั้นตอนการคำนวณเหล่านี้ล่วงหน้า

ขอบเขตการทำงานที่ควรแจ้งตรงๆ คือ: เรกคอร์ด TPdfAnnotation จะบรรจุ TQuadrilateralPoint เพียงตัวเดียว การเรียกใช้ CreateAnnotation หนึ่งครั้งจึงจะเขียนรูปสี่เหลี่ยมได้เพียงหนึ่งรูปเท่านั้น การทำเครื่องหมายข้อความที่ยาวครอบคลุมสามบรรทัดจึงต้องการรูปสี่เหลี่ยมสามรูป (หนึ่งรูปต่อหนึ่งบรรทัด) ตามหัวข้อ §12.5.6.10 และคุณมีสองทางเลือกในการจัดการ ทางเลือกที่ง่ายคือการสร้างหนึ่งคำอธิบายประกอบต่อหนึ่งบรรทัด ซึ่งแสดงผลได้ถูกต้องในทุกโปรแกรมและรักษาการใช้งาน API ระดับคอมโพเนนต์ไว้ ทางเลือกที่กระชับคือการสร้างหนึ่งคำอธิบายประกอบที่พ่วงสามรูปสี่เหลี่ยม ซึ่งทำได้โดยการสร้างคำอธิบายประกอบผ่านคอมโพเนนต์ก่อน แล้วจึงเรียกใช้ฟังก์ชัน C FPDFAnnot_AppendAttachmentPoints ด้วยตัวเองสำหรับรูปสี่เหลี่ยมรูปที่สองและสาม ซึ่งทำงานได้เนื่องจาก Append จะสร้างช่องเก็บข้อมูลใหม่แทนการเขียนทับ โปรดอย่าพยายามเพิ่มรูปสี่เหลี่ยมด้วยการเรียกใช้ฟังก์ชัน SetAttachmentPoints ซ้ำๆ เนื่องจากดัชนีใดๆ ที่เกินจำนวนปัจจุบันจะส่งกลับค่าเท็จเสมอ ด้วยเหตุผลเดียวกับที่ดัชนี 0 ล้มเหลวกับคำอธิบายประกอบที่เพิ่งสร้างขึ้นใหม่

หลังจากเขียนรหัสแล้ว ให้ตรวจสอบผลลัพธ์ผ่านโปรแกรมอ่านจริงแทนการเชื่อถือเฉพาะรหัสส่งกลับ: โปรดเปิดไฟล์ด้วยโปรแกรม Acrobat หรือโปรแกรมอ่านที่ใช้ PDFium และตรวจสอบว่าการทำเครื่องหมายตกลงบนข้อความอย่างถูกต้อง มีระดับความโปร่งแสงตามต้องการ และข้อมูลยังคงอยู่หลังจากการบันทึกและเปิดขึ้นมาใหม่ ประเภทคำอธิบายประกอบ การจัดการรูปสี่เหลี่ยม และฟังก์ชันการเขียนที่รับรู้จำนวนข้อมูลที่แสดงในบทความนี้ ทั้งหมดเป็นส่วนหนึ่งของคอมโพเนนต์มาตรฐาน PDFium Component สำหรับ Delphi, C++Builder และ Lazarus หน้ารายละเอียดผลิตภัณฑ์จะมีข้อมูลอ้างอิง API คำอธิบายประกอบฉบับเต็มและรายละเอียดการทำงานอื่นๆ ของไลบรารี