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

การตรวจสอบคำอธิบายประกอบ PDF ใน Delphi ด้วย PDFium Component

annotation ของ PDF คือ dictionary ที่ผูกไว้กับหน้าหนึ่ง ไม่ใช่รอยที่ถูกวาดลงไปบนมัน ISO 32000-1 §12.5 นิยาม subtype ไว้ประมาณสองโหล และแต่ละตัวพก /Subtype สี่เหลี่ยมในพิกัดของหน้า ชุด flag และมักจะมี appearance stream ที่ตัดสินว่า viewer จะวาดอะไรออกมาจริง ๆ subtype เหล่านี้ไม่ได้มีความหมายเดียวกันหมดสำหรับคนที่กำลังตรวจทานเอกสาร Highlight กับ Ink stroke คือความคิดเห็น Link คือการนำทาง Popup คือหน้าต่างเล็ก ๆ ที่เปิดขึ้นเมื่อคุณคลิกโน้ตกาว ซึ่งถูกเก็บเป็นอ็อบเจ็กต์ของตัวเองและถูกชี้ไปโดยตัวแม่ Reply คือ Text annotation เต็มรูปแบบที่อ้างอิงกลับไปยังความคิดเห็นที่มันตอบผ่านรายการ in-reply-to ดังนั้นอาเรย์ annotation ระดับหน้าจึงไม่ใช่รายการความคิดเห็นของผู้ตรวจทานเลย มันคือถุงแบนราบที่บรรจุความคิดเห็น ระบบท่อที่เชื่อมพวกมันเข้าด้วยกัน และอีกหลายอย่างที่ผู้ตรวจทานคนไหนก็ไม่เรียกว่าความคิดเห็นเลยด้วยซ้ำ panel ที่ปฏิบัติต่ออาเรย์นี้ราวกับเป็นรายการความคิดเห็นจะให้ตัวเลขไม่ตรงกับ viewer ตัวอื่นทุกตัวที่ลูกค้ารันอยู่

การสร้าง workflow ตรวจทาน annotation บน PDFium Component คอมโพเนนต์ VCL/LCL ที่สร้างจากเอนจิ้น PDFium สำหรับ Delphi, C++Builder และ Lazarus หมายถึงการมุ่งความสนใจไปที่จุดที่ช่องว่างระหว่างอาเรย์ดิบกับมุมมองของมนุษย์ก่อปัญหา: การนับ การจัดทำ index การเปลี่ยนสีรอยที่เอนจิ้นแช่แข็งไว้แล้ว การลบโดยไม่เหลือผี และการเพิ่มรอยของคุณเองเข้าไป

แผนภาพแสดงว่าแผงตรวจทาน PDFium ของ Delphi กรองอาร์เรย์ annotation หน้าดิบทั้งคอมเมนต์ popup การตอบกลับ และลิงก์ ให้เหลือรายการคอมเมนต์ที่คัดแล้วซึ่งผู้ตรวจทานเห็นอย่างไร
อาร์เรย์ annotation ของหน้าผสมคอมเมนต์กับ popup, การตอบกลับ, ลิงก์ และเครื่องหมายที่ซ่อนไว้ พาเนลตรวจทานจึงต้องมีกฎการนับก่อนแสดงยอดรวม

เหตุใดจำนวนของคุณจึงไม่เคยตรงกับ comment pane ของ Acrobat

เปิดสัญญาที่มีการทำเครื่องหมายไว้ใน viewer ของคุณและใน Acrobat เคียงข้างกัน แล้วผลรวมแทบไม่เคยตรงกันเลย Acrobat แสดงมุมมองที่คัดสรรมาแล้ว: markup ถูกจัดกลุ่มเป็น reply thread, popup ถูกพับเข้าไปในโน้ตที่มันเป็นของ, link และ form widget ถูกตัดออกไป อาเรย์ดิบเก็บทุกอย่างไว้โดยไม่แยกแยะ ดังนั้นการนับแบบไร้เดียงสาจึงสูงเกินในบางแง่และต่ำเกินในอีกบางแง่พร้อมกัน

Popup ทำให้ยอดรวมพองตัว เพราะโน้ตกาวแต่ละอันมาพร้อมอ็อบเจ็กต์ Popup แยกต่างหาก การนับทั้งคู่จึงนับโน้ตซ้ำสองครั้ง Reply ทำให้ยอดรวมยุบลงถ้าคุณกรองเฉพาะรอยที่มองเห็นได้ เพราะ reply คือ Text annotation ที่ไม่มีอะไรถูกวาดเลยจนกว่าใครสักคนจะขยาย thread ออกมา และการทิ้งมันไปก็ทำให้เสียบทสนทนาไป flag Hidden และ NoView เอา annotation ออกจากหน้าจอโดยไม่เอามันออกจากอาเรย์ ดังนั้นการนับที่มองไม่เห็น flag จึงรวมรอยที่ผู้ใช้มองไม่เห็นเข้าไปด้วย annotation แบบ Link นั่งอยู่ในอาเรย์เดียวกันกับความคิดเห็น และไม่ควรอยู่ทั้งในจำนวนนับและในรายการเลย ตัดสินใจกฎการนับก่อนเขียน loop แล้วจดการตัดสินใจนั้นไว้ เพราะ "ทำไม panel ของคุณถึงแสดงตัวเลขไม่ตรงกับ Acrobat" คือ ticket แรกที่ฟีเจอร์ตรวจทานจะได้รับ

จัดทำ index ทุกอย่างครั้งเดียว แล้วไม่ต้อง re-parse หน้าอีกเลย

กฎการออกแบบข้อเดียวขับเคลื่อนทุกอย่างที่ตามมา: การกรองตาม author, type หรือ page ต้องไม่ re-parse page object เลยเด็ดขาด บนเอกสาร 300 หน้าที่มี markup หนาแน่น การ re-parse ทุกครั้งที่เปลี่ยน dropdown จะทำให้ panel กลายเป็นอะไรที่กระตุกอยู่หลายวินาทีต่อครั้ง component เปิดให้ใช้ AnnotationCount และ property Annotation[] แบบ index ทั้งคู่ผูกขอบเขตไว้กับหน้าที่โหลดอยู่ในปัจจุบัน และ record TPdfAnnotation ที่มันส่งกลับมามีสิ่งที่ list view ต้องการ: Subtype, Flags, Color, Rectangle, ContentsText, AuthorText วิธีที่ถูกต้องคือกวาดทุกหน้าครั้งเดียวตอนเปิดไฟล์ แล้วเก็บ index แบบแบนราบของคุณเองไว้:

procedure TReviewPanel.BuildIndex;
var
  PageNo, i: Integer;
  A: TPdfAnnotation;
begin
  FItems.Clear;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    for i := 0 to Pdf.AnnotationCount - 1 do
    begin
      A := Pdf.Annotation[i];
      // เก็บเฉพาะ subtype ที่เกี่ยวข้องกับผู้ตรวจทาน; บันทึกหน้าและ
      // คู่ index ไว้ เพราะการแก้ไขทุกครั้งในภายหลังจะอ้างอิงผ่านคู่นี้
      if A.Subtype in [anText, anHighlight, anInk] then
        FItems.Add(TReviewItem.Create(PageNo, i,
          A.AuthorText, A.ContentsText, A.Rectangle, A.Color));
    end;
  end;
end;

คู่ที่ควรขีดเส้นใต้ไว้คือ (PageNo, i) การเปลี่ยนแปลงทุกครั้งในภายหลัง ไม่ว่าจะเป็นการเปลี่ยนสีหรือการลบ จะถูกอ้างอิงผ่านหมายเลขหน้าบวก annotation index และ index นั้นก็เปราะบาง: การลบ annotation หนึ่งตัวจะเปลี่ยนหมายเลขทุกอย่างที่อยู่หลังมันบนหน้านั้นใหม่ ดังนั้นให้วางแผนสร้างรายการของหน้าที่ได้รับผลกระทบขึ้นใหม่หลังการลบทุกครั้ง แทนที่จะแก้ตัวเลข index ในที่เดิม การสร้างใหม่มีต้นทุนแค่หนึ่งมิลลิวินาที ในทางกลับกัน index ที่ค้างอยู่จะลบความคิดเห็นของผู้ตรวจทานคนผิด ซึ่งเป็นบั๊กประเภทที่กัดกร่อนความเชื่อมั่นในฟีเจอร์ทั้งหมด

threading ก็ควรมีที่ทางใน index ด้วย แม้ว่า release แรกของคุณจะแค่นับ reply แทนที่จะแสดงมันก็ตาม จัดกลุ่มรายการตาม parent reference ของมันในขณะที่คุณเปิดหน้านั้นอยู่ เพื่อให้ panel พับ thread ได้ในภายหลังแบบเดียวกับที่ Acrobat ทำ การสร้างกลุ่มนั้นขึ้นใหม่แบบ lazy ระหว่างการเลื่อนหน้าจอทำลายจุดประสงค์ทั้งหมดของการจัดทำ index ครั้งเดียว เพราะมันเปิดหน้าที่คุณจ่ายต้นทุน parse ไปแล้วซ้ำอีกครั้ง เรื่องเรขาคณิตก็ต้องการวินัยแบบเดียวกัน Rectangle ในแต่ละ record เป็นแบบ page-space และการแปลงมันเป็นพิกัดของ view ควรอยู่ใน helper ที่ใช้ร่วมกันตัวเดียว ไม่ใช่กระจัดกระจายอยู่ทั่วโค้ด panel มักเกิดบั๊กเรื่องพิกัดเมื่อ selection, hit-testing และการวาด ต่างคิดค้นสูตรคำนวณ zoom และการหมุนของตัวเอง ให้ทั้งสามอย่างวิ่งผ่านการแปลงจุดเดียว แล้ว highlight แถวของมันในรายการ และเป้าหมายการคลิกของมัน จะยังคงปักอยู่กับรอยหมึกเดียวกันเสมอ

เปลี่ยนสี markup และการยับยั้งของ appearance stream

การเปลี่ยน highlight จากสีเหลืองเป็นสีเหลืองอำพันฟังดูเหมือนแค่บรรทัดเดียว และบางครั้งก็เป็นแบบนั้นจริง ๆ ข้อดักจับอยู่ที่ ISO 32000-1 §12.5.5 เมื่อ annotation พก appearance stream แบบ /AP ไว้ viewer ที่สอดคล้องตามมาตรฐานจะวาด stream ที่สร้างไว้ล่วงหน้านั้น และปฏิบัติต่อรายการสีใน dictionary เป็นแค่ metadata ที่ตายแล้ว Acrobat เขียน appearance stream ให้แทบทุกอย่างที่มันสร้าง ดังนั้น annotation ส่วนใหญ่ที่มาจากลูกค้าจึงอยู่ในสถานะนี้อยู่แล้ว และสีที่คุณตั้งไปอย่างมั่นใจก็ไม่มีวันไปถึงหน้าจอ การเปลี่ยนสีคือการ read-modify-write ผ่าน property Annotation[] และ component ก็ซื่อตรงเกี่ยวกับความขัดแย้งนี้: เมื่อเอนจิ้นปฏิเสธไม่ให้สีใน dictionary แทนที่ appearance ที่ฝังไว้แล้ว การเขียนจะโยน EPdfError

แผนภาพเส้นทางเปลี่ยนสีแบบอ่าน-แก้-เขียน ในคอมโพเนนต์ PDFium ของ Delphi ที่ appearance stream ที่อบตัวแล้วใช้สิทธิ์ยับยั้งสีใน dictionary และยก EPdfError
เมื่อ annotation พาสตรีม /AP ที่สร้างไว้ก่อน เอนจินจะปฏิเสธสีจาก dictionary และ raise EPdfError พาเนลจึงต้องลงสี overlay ของตัวเองใหม่หรือทำเครื่องหมายแถวว่า appearance-locked
A := Pdf.Annotation[Item.Index];
A.HasColor := True;
A.Color := $0000B0FF;       // เหลืองอำพัน
A.ColorAlpha := 160;
try
  Pdf.Annotation[Item.Index] := A;
except
  on EPdfError do
  begin
    // annotation นี้มี /AP stream ที่ render ไว้ล่วงหน้าแล้ว; สีใน dictionary
    // เพียงอย่างเดียวเปลี่ยนสิ่งที่ viewer วาดไม่ได้
    Item.AppearanceLocked := True;
    StatusBar.SimpleText := 'Color is fixed by the annotation appearance';
  end;
end;

จับ exception นั้นทุกครั้ง แล้วปฏิบัติต่อมันเป็นข้อมูล ไม่ใช่ความล้มเหลว ข้าม guard ไป แล้ว panel ของคุณก็จะแสดงสีเหลืองอำพันในรายการของมันเองอย่างร่าเริง ในขณะที่หน้ากระดาษยังคงวาดเป็นสีเหลืองอยู่ ผู้ใช้จะยื่นเรื่องเข้ามาอีกหลายสัปดาห์ต่อมาว่า "viewer ของคุณเพิกเฉยต่อการแก้ไขของฉัน" แล้วคุณก็จะเสียเวลาทั้งบ่ายพยายามจำลองปัญหาซ้ำบนไฟล์ที่บังเอิญไม่มี appearance stream ไม่สำเร็จ เมื่อคุณรู้แล้วว่า appearance ถูกล็อกไว้ คุณมีทางตอบสนองที่ซื่อตรงอยู่สองทาง: เปลี่ยนสี selection overlay ของคุณเองแทนที่จะเปลี่ยนสี annotation เพื่อให้ผู้ตรวจทานอย่างน้อยก็เห็น highlight ที่พวกเขาเลือกไว้ หรือทำเครื่องหมายแถวนั้นว่า appearance ถูกล็อกไว้ เพื่อไม่ให้ใครคาดหวังว่าการเปลี่ยนแปลงจะติดอยู่จริง

ลบ annotation โดยไม่เหลือผี

DeleteAnnotation เอาอ็อบเจ็กต์ออกจากต้นไม้ annotation ของหน้าปัจจุบัน แต่มันปล่อย page raster ที่แคชไว้ไม่ให้ยุ่ง วาดทันทีหลังจากเรียกและ highlight ที่ถูกลบไปแล้วก็ยังอยู่บนหน้าจอ นั่งอยู่ใน bitmap ที่ไม่ตรงกับโมเดลเอกสารที่อยู่เบื้องหลังมันอีกต่อไป ทางแก้คือปฏิบัติต่อการ re-render เป็นส่วนหนึ่งของการลบ ไม่ใช่ขั้นตอนที่ผู้เรียกอาจลืม:

แผนภาพวงจรลบสามขั้นของ PDFium ใน Delphi ที่ถอด annotation ออก, เรนเดอร์หน้าใหม่ด้วย reAnnotations และสร้างดัชนีหน้าใหม่
การลบแตะเพียงต้นไม้ annotation พาเนลจึงต้องเรนเดอร์ใหม่ด้วย reAnnotations และสร้างรายการหน้าใหม่ก่อนที่การแสดงผลและดัชนีจะซื่อตรงต่อกันอีกครั้ง
Pdf.PageNumber := Item.PageNo;
Pdf.DeleteAnnotation(Item.Index);   // จะโยน EPdfError เมื่อล้มเหลว
Bmp := Pdf.RenderPage(0, 0, ViewWidth, ViewHeight, ro0, [reAnnotations]);
try
  PaintPageBitmap(Bmp);
finally
  Bmp.Free;  // RenderPage ส่งความเป็นเจ้าของ bitmap ให้ผู้เรียก
end;
RebuildPageEntries(Item.PageNo);  // index หลัง Item.Index เปลี่ยนตำแหน่งไปแล้ว

มีสองรายละเอียดในบล็อกนั้นที่พลาดได้ง่าย ตัวเลือก reAnnotations ต้องมีอยู่เสมอ ไม่งั้น raster ใหม่จะทิ้ง annotation ที่เหลือทั้งหมด และหน้ากระดาษก็จะดูเหมือนคุณลบชุดความคิดเห็นทั้งหมด แทนที่จะลบแค่รอยเดียว และ Bmp.Free ก็ไม่ใช่ทางเลือกที่ข้ามได้: overload แบบฟังก์ชันของ RenderPage ส่งความเป็นเจ้าของ bitmap ให้ผู้เรียก ดังนั้นการลืม free จะรั่ว raster เต็มหน้าทุกครั้งที่ลบหนึ่งครั้ง ซึ่งผู้ตรวจทานที่ทำงานผ่านเอกสารยาว ๆ จะเปลี่ยนมันให้กลายเป็นแรงกดดันหน่วยความจำจริงภายในไม่กี่นาที

เพิ่มรอยของผู้ตรวจทานจาก UI ของคุณเอง

การสร้าง annotation ทำผ่าน CreateAnnotation ซึ่งรับ record TPdfAnnotation ที่กรอกข้อมูลไว้แล้ว (subtype, สี่เหลี่ยม, สี, เนื้อหา, ผู้เขียน) แล้วผูกมันเข้ากับหน้าปัจจุบัน โน้ตกาว subtype anText คือกรณีที่ง่าย: ตั้งตำแหน่ง เนื้อหา และผู้เขียน แล้วก็เสร็จเลย annotation แบบ ink คือจุดที่คนมักติดกับดัก สี่เหลี่ยมของ record นั้นแค่กำหนดขอบเขตของภาพวาดเท่านั้น ตัวเส้นเองเป็นอาเรย์ของจุดที่ต้องแนบแยกต่างหากผ่านการเรียก ink-stroke ของเอนจิ้น คือ FPDFAnnot_AddInkStroke ที่ป้อนข้อมูล FS_POINTF ซึ่งจับมาจาก input ของเมาส์หรือปากกาทีละเส้น สร้าง ink annotation จากแค่สี่เหลี่ยมอย่างเดียวโดยไม่มีอะไรอื่นเลย แล้วคุณจะได้รอยขีดเขียนว่างเปล่าที่ render ออกมาเป็นพื้นที่ว่าง ซึ่งดูเหมือนบั๊กในเอนจิ้น แต่จริง ๆ แล้วคือ annotation ที่สร้างไว้ครึ่ง ๆ กลาง ๆ

กำหนดนโยบายเรื่องผู้เขียนไปพร้อมกันเลย ทุกรอยที่ UI ของคุณสร้างขึ้นควรพก AuthorText ที่สม่ำเสมอ เพราะตัวกรองผู้ตรวจทานที่คุณจะสร้างเดือนหน้าจะดีได้เท่ากับชื่อที่คุณประทับลงบนความคิดเห็นวันนี้เท่านั้น สตริงผู้เขียนที่ว่างเปล่าหรือไม่สม่ำเสมอไม่มีทางซ่อมย้อนหลังได้เลยโดยไม่ต้องเปิดไฟล์ทุกไฟล์ใหม่

เอาผลการตรวจทานออกจาก viewer

ข้อมูลการตรวจทานจะคุ้มค่าก็ต่อเมื่อมันออกจาก viewer ได้ ไม่ว่าจะเป็นสรุปที่หัวหน้าโปรเจกต์อ่านได้โดยไม่ต้องเปิดไฟล์ หรือ CSV ที่ป้อนเข้าชีตติดตามงาน export จาก index ที่คุณสร้างไว้แล้วเสมอ อย่า export จากการ parse ใหม่เด็ดขาด แล้วเลือกวิธีที่มั่นคงในการอ้างอิงกลับไปยังแต่ละรอย หมายเลขหน้าคู่กับสี่เหลี่ยมของ annotation รอดจาก round-trip ที่ array index รอดไม่ได้ เพราะการลบครั้งถัดไปจะเปลี่ยนหมายเลข index แบบเงียบ ๆ แล้ว CSV ของคุณก็จะเริ่มชี้ไปยังความคิดเห็นผิดตัว

แถวที่ควรเก็บไว้พกหน้า subtype ผู้เขียน creation timestamp เมื่อไฟล์บันทึกไว้ ข้อความเนื้อหา และคอลัมน์สถานะที่คุณเป็นเจ้าของเอง ไม่ใช่คอลัมน์ที่ PDF ให้มา รอบการจัดทำ index แบบเดียวกันนี้มีประโยชน์ตั้งแต่ก่อนหน้านั้นด้วย คือตอนรับข้อมูลเข้า เมื่อเอกสารมาจากนอกทีมและคุณอยากรู้ว่าข้างในมีอะไรบ้างก่อนที่ใครจะตรวจทานมัน บทความเรื่อง PDF intake workbench พาไล่ผ่านการคัดกรองนั้น และ การนำทาง form field ครอบคลุมปัญหาภาพสะท้อนกลับ: การตรวจทานเอกสารที่สร้างขึ้นมาเพื่อเก็บข้อมูล ไม่ใช่ความคิดเห็น

กรณีหนึ่งที่อาเรย์จะไม่แสดงให้คุณเห็น

โหมดความล้มเหลวหนึ่งอย่างสมควรได้รับการทำเครื่องหมายไว้ เพราะมันดูเหมือนข้อบกพร่องในโค้ดของคุณ แต่จริง ๆ แล้วไม่ใช่ ลูกค้ารายงานว่าเห็น highlight เต็มไปทั้งหน้า แต่ panel ของคุณไม่แสดงอะไรเลย และ AnnotationCount คืนค่าศูนย์กลับมา คำอธิบายที่พบบ่อยคือรอยเหล่านั้นถูก flatten ไปที่ไหนสักแห่งต้นทาง การ flatten อบ appearance ของ annotation เข้าไปเป็นเนื้อหาหน้าธรรมดา ดังนั้น highlight จึงกลายเป็นส่วนหนึ่งของกราฟิกหน้ากระดาษ และเลิกมีอยู่ในฐานะอ็อบเจ็กต์ annotation ไปโดยสิ้นเชิง ไม่มีอะไรเหลือให้ annotation API แจกแจง เปลี่ยนสี หรือลบได้เลย เมื่อคุณเห็น markup ที่ถูกวาดไว้แต่จำนวนนับเป็นศูนย์ ให้หยุดมองหาบั๊กใน loop การแจกแจงของคุณ แล้วถามแทนว่าไฟล์นี้ถูกสร้างขึ้นมาอย่างไร

surface ของ annotation ที่ใช้ในบทความนี้ ตั้งแต่การแจกแจงและการสร้าง ไปจนถึงการเปลี่ยนสี การลบ และตัวเลือก render ที่ทำให้การแสดงผลตรงกับความจริง มาพร้อมกับ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus/FPC