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

การไฮไลต์ PDF แบบไม่ทำลายข้อมูลใน Delphi: ชั้น Review ของ HotPDF

สี่เหลี่ยมที่วาดล้อมรอบย่อหน้าระหว่างการ review ไม่จำเป็นต้องกลายเป็นเครื่องหมายภายใน PDF THPDFViewerModel ของ HotPDF เปิด AddHighlightRegion ซึ่งเป็น method ที่เก็บทุกไฮไลต์ไว้เป็น record ในหน่วยความจำ แทนที่จะเป็นการเปลี่ยนแปลงต่อเอกสารที่โหลดอยู่ ทำให้ผู้ review สามารถทำเครื่องหมายได้หลายสิบหน้า ในขณะที่ไฟล์บนดิสก์ยังคงเหมือนเดิมทุกไบต์ ซูมเข้าไปที่ 6400% หมุนหน้า 90 องศา สลับจาก Fit Width เป็น Fit Page สี่เหลี่ยมเดิมก็ยังคงอยู่ตรงย่อหน้าเดิม เพราะการคำนวณพิกัดวิ่งผ่าน geometry การ render จริง ณ ขณะที่เครื่องหมายนั้นถูกวาด

เครื่องมือ review ที่สร้างรอบ PDF viewer มักเจอปัญหานี้อยู่เสมอ หน้าจอ redlining, การตรวจ QA บนใบแจ้งหนี้ที่สร้างอัตโนมัติ, workflow การอนุมัติภายในองค์กร ทั้งหมดนี้ต้องการให้ใครสักคนดึงความสนใจไปยังพื้นที่หนึ่งของหน้า โดยไม่ให้ทุกเครื่องหมายฉบับร่างกลายเป็นการเปลี่ยนแปลงถาวรต่อไฟล์ และโดยไม่ต้องพึ่ง annotation subsystem เต็มรูปแบบเพียงเพื่อแสดงกล่องสีในขณะที่ยังตัดสินใจไม่ได้ว่าเครื่องหมายนั้นควรอยู่หรือไม่ HotPDF ตอบโจทย์นี้ด้วยชั้นไฮไลต์เฉพาะที่อยู่ทั้งหมดในฝั่ง Model ของการแยกที่อธิบายไว้ในการสร้าง PDF viewer แบบกำหนดเองด้วยสถาปัตยกรรม MVC ใน Delphi ซึ่งเป็นเหตุผลเดียวกันที่ทำให้รายการไฮไลต์ชุดเดียวกันนี้ขับเคลื่อนได้จาก unit test โดยไม่มี window handle ให้เห็นเลย

AddHighlightRegion ของ HotPDF เก็บอะไรไว้จริงๆ

AddHighlightRegion เก็บสิ่งเดียวกันสามอย่างต่อเครื่องหมายหนึ่งอัน คือดัชนีหน้าเริ่มที่ศูนย์ THPDFRectangle ในพิกัด PDF user-space และ TColor ทั้งหมดถูกห่อเป็น record THPDFViewerHighlight ภายใน THPDFViewerModel การเรียก Viewer.HighlightRegion(PageIndex, PageRect, clYellow) หรือ Model.AddHighlightRegion ที่เทียบเท่ากัน จะเพิ่ม record หนึ่งตัวเข้าไปใน array ส่วนตัวและคืนดัชนีของมันกลับมา และดัชนีนั้นเป็นสิ่งเดียวที่ผู้เรียกได้รับกลับมา ไม่มี object แยกต่างหาก ไม่มี reference-counted interface ไม่มีอะไรต้อง free ความสามารถอื่นทุกอย่างในบทความนี้ ไม่ว่าจะเป็นการวาดเครื่องหมาย, การแปลงพิกัดใหม่หลัง zoom เปลี่ยน, การลบมัน ล้วนสร้างขึ้นบน record เล็กๆ ตัวนั้นตัวเดียว

ทุกสี่เหลี่ยมจะถูก normalize และ clip ก่อนที่จะถูกรับเข้า AddHighlightRegion สลับขอบซ้ายกับขวาถ้าผู้ review ลากจากขวาไปซ้าย สลับบนกับล่างสำหรับการลากขึ้นด้านบน แล้วจึง clip ผลลัพธ์กับ MediaBox ของหน้าที่ดึงมาผ่าน GetLoadedPageBox สี่เหลี่ยมที่ลงเอยด้วยความกว้างเป็นศูนย์ ความสูงเป็นศูนย์ หรืออยู่นอกหน้าทั้งหมด จะถูกปฏิเสธทันที method จะคืนค่า -1 และไม่มีอะไรถูกเพิ่มเข้าไปในรายการ ค่าที่คืนกลับมานี้ไม่ใช่แค่ของตกแต่ง ชุดไฮไลต์ที่สร้างขึ้นใหม่จากไฟล์ review ภายนอก หรือจากพิกัดเก่าหลังจากหน้าถูกแทนที่ อาจสูญเสีย entry ไปเงียบๆ ถ้าผู้เรียกไม่ตรวจสอบค่านี้

ไฮไลต์คงตำแหน่งตรงหลัง zoom หรือหมุนได้อย่างไร

ไฮไลต์คงตำแหน่งตรงได้เพราะ HotPDF เก็บมันไว้ในพื้นที่หน้า PDF แล้ว project กลับเข้าสู่พื้นที่หน้าจอในทุกครั้งที่ repaint แทนที่จะเก็บสี่เหลี่ยมบนหน้าจอซึ่งจะล้าสมัยทันทีที่ระดับ zoom เปลี่ยน THPDFViewerModel.PagePointToView และค่าผกผันของมัน ViewPointToPage ทำการ project นี้เป็นสองขั้นตอน ขั้นแรกคือ entry /Rotate ของหน้าเอง จากนั้นคือ ViewRotation อิสระของ Viewer ซึ่งไม่เคยถูกเขียนกลับเข้า PDF และมีผลแค่กับสิ่งที่ Viewer แสดงเท่านั้น การยกเลิก transform ตอนปล่อยเมาส์วิ่งผ่านสองขั้นตอนเดียวกันนี้ในทิศทางย้อนกลับ ซึ่งเป็นสิ่งที่ทำให้ไฮไลต์ที่วาดที่ zoom สูงบนหน้าที่หมุน 270 องศา ลงเอยที่ตำแหน่งที่ถูกต้องเป๊ะหลังจากผู้ review รีเซ็ต view กลับเป็น Fit Page

DPI ที่ใช้ในการ project นั้นสำคัญพอๆ กับการหมุน Viewer ของ HotPDF จับ DPI ที่แน่นอนของบิตแมปที่อยู่บนหน้าจอตอนนั้นเก็บไว้ใน FRenderedDPI ทันทีหลังการ render แต่ละครั้ง และ ImageMouseUp ส่งค่าเดียวกันนั้นเข้าไปยัง ViewPointToPage ดังนั้นพิกัดเมาส์จึงถูกแปลงโดยใช้ความละเอียดที่มันถูกวาดจริงๆ เสมอ ไม่ใช่ความละเอียดที่คำนวณใหม่จาก property zoom ปัจจุบัน CreatePageSnapshot และญาติของมันจำกัด DPI ไว้ในช่วง 12 ถึง 2400 แต่เส้นทางการ render แบบ interactive ไม่มีเพดานแบบนั้นเลย บันไดระดับ zoom มาตรฐานสูงสุดอยู่ที่ 6400% ซึ่งคำนวณออกมาเกิน 2400 DPI ไปมากที่ baseline 96 DPI เริ่มต้น ดังนั้นการใช้ขีดจำกัดแบบ snapshot ซ้ำสำหรับการแม็ปพิกัดจะทำให้ทุกไฮไลต์เลื่อนไปหลายพิกเซลที่ปลายสุดของช่วง zoom ค่าเริ่มต้นเล็กๆ อีกสองอย่างเสริมการโต้ตอบนี้ให้สมบูรณ์ การลากที่สั้นกว่าสองพิกเซลในแกนใดแกนหนึ่งจะถือเป็นการคลิกและไม่สร้างไฮไลต์ และการไฮไลต์จะเริ่มไม่ได้จนกว่าอย่างน้อยหนึ่งหน้าจะถูก render จริงๆ ไปแล้ว เพราะ FRenderedDPI เริ่มต้นที่ศูนย์

การเชื่อมการไฮไลต์แบบ interactive เข้ากับหน้าจอ review

การเปิดใช้การไฮไลต์แบบ interactive เป็นงานตั้งค่าสาม property บนคอนโทรล THPDFViewer เอง ตั้ง InteractionMode เป็น vimHighlight แทนค่าเริ่มต้น vimBrowse เลือก HighlightColor ซึ่งค่าเริ่มต้นคือ clYellow และจัดการ OnMarqueeSelect เพื่อรู้ว่าผู้ review เพิ่งวาดอะไรไป ส่วนที่เหลือทั้งหมด การจับเมาส์, การวาดสี่เหลี่ยมเลือกแบบเส้นประขณะผู้ review ลาก, การแปลงจุดปล่อยกลับเป็นพื้นที่หน้า, การเรียก AddHighlightRegion เกิดขึ้นภายในคอนโทรลก่อนที่ event นั้นจะยิง

type
  TReviewForm = class(TForm)
    Viewer: THPDFViewer;
    ReviewLog: TMemo;
    procedure FormCreate(Sender: TObject);
  private
    procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
      PageIndex: Integer; const PageRect: THPDFRectangle;
      HighlightIndex: Integer);
  end;

// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
  Viewer.PDFDocument := PdfDoc;
  Viewer.InteractionMode := vimHighlight;
  Viewer.HighlightColor := clLime;
  Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;

procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
  PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
  ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
    [PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
     PageRect.Right, PageRect.Top]));
end;

OnMarqueeSelect จะยิงก็ต่อเมื่อการลากนั้นสร้างไฮไลต์ขึ้นมาจริงๆ เท่านั้น การคลิกที่เล็กเกินกว่าจะนับเป็นการลากจะเคลียร์ overlay การเลือกทันที และการลากที่ตกอยู่นอกหน้าทั้งหมดจะไปถึง AddHighlightRegion แต่ถูกปฏิเสธตรงนั้นเหมือนกับการเรียกผ่านโค้ดโดยตรง ดังนั้น event จะเงียบทั้งสองกรณี รายละเอียดการ implement หนึ่งอย่างที่ควรรู้ไว้หากการไฮไลต์ดูเหมือนจะหยุดตอบสนองที่ขอบของคอนโทรล การจับเมาส์เป็นของ THPDFViewer เอง ซึ่งสืบทอดจาก TScrollBox ไม่ใช่ของ TImage ภายในที่แสดงบิตแมปของหน้า ซึ่งเป็นสิ่งที่ทำให้ผู้ review ลากเลยขอบของหน้าที่ render ออกไปได้และยังคงปล่อยเมาส์ได้อย่างถูกต้อง

การเพิ่ม ลบ และอ่านไฮไลต์กลับจากโค้ด

ไฮไลต์ไม่จำเป็นต้องมาจากการลากเมาส์เลยก็ได้ Viewer.HighlightRegion(PageIndex, PageRect, Color) ซึ่งส่งเข้าไปยัง Model.AddHighlightRegion ตัวเดียวกับที่การลากแบบ interactive เรียกภายใน เป็น public โดยเฉพาะเพื่อให้หน้าจอ review สามารถสร้างไฮไลต์ขึ้นใหม่จากข้อมูลที่มีอยู่แล้วได้ เช่น ความคิดเห็นที่โหลดจากฐานข้อมูล ผลจากการค้นหาข้อความ หรือเครื่องหมายที่กู้คืนจาก session ก่อนหน้า เพราะพิกัดเป็นแค่ตัวเลข PDF user-space ธรรมดา ไม่มีอะไรในเส้นทางนี้ที่ต้องพึ่งการที่หน้าถูก render มาก่อน ต่างจากการลากแบบ interactive ที่ต้องการให้ FRenderedDPI มีค่าจริงอยู่แล้ว

var
  I: Integer;
  Item: TPriorComment;    // your own record: PageIndex + PageRect
  NewIndex: Integer;
begin
  for I := 0 to PriorComments.Count - 1 do
  begin
    Item := TPriorComment(PriorComments[I]);
    NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
    if NewIndex < 0 then
      LogWarning('comment %d fell outside the page and was dropped', [I]);
  end;
end;

การลบไฮไลต์หนึ่งอันคือจุดที่การเก็บข้อมูลแบบ array แสดงตัวออกมาชัดเจน RemoveHighlightRegion ลบ record หนึ่งตัวและเลื่อนทุก record ที่ตามมาลงมาหนึ่งตำแหน่งเพื่อปิดช่องว่าง ซึ่งหมายความว่าดัชนีใดก็ตามที่จับไว้ก่อนหน้านี้ ไม่ว่าจะจาก event OnMarqueeSelect หรือจากการแจงรายการครั้งก่อน จะไม่น่าเชื่อถืออีกต่อไปทันทีที่มีบางอย่างข้างหน้ามันในรายการถูกลบไป OnHighlightChange ยิงในทุกการเพิ่ม การลบ และการเรียก ClearHighlightRegions แต่ไม่ได้พกข้อมูลว่าอะไรเปลี่ยนไปมาด้วย ดังนั้นรูปแบบที่ปลอดภัยคือปฏิบัติต่อมันเป็นสัญญาณให้สร้างรายการที่แผง review กำลังแสดงขึ้นใหม่จาก HighlightCount และ TryGetHighlightRegion แทนที่จะแก้ดัชนีที่ cache ไว้ตรงจุด

procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
  I: Integer;
  Mark: THPDFViewerHighlight;
begin
  MarkList.Items.Clear;
  for I := 0 to Viewer.Model.HighlightCount - 1 do
    if Viewer.Model.TryGetHighlightRegion(I, Mark) then
      MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
        TObject(I));
end;

เมื่อไหร่ที่เครื่องหมายควรกลายเป็น Highlight annotation จริง

พื้นที่ไฮไลต์ควรกลายเป็น annotation จริงทันทีที่มันต้องอยู่รอดนอกเหนือ instance THPDFViewer ตัวนั้นตัวเดียว HotPDF ยังเปิด AddHighlightAnnotation สำหรับหน้าใหม่ และ AddLoadedHighlightAnnotation สำหรับเอกสารที่โหลดอยู่แล้ว และแม้จะมีชื่อคล้ายกันเกือบสนิท นี่เป็นกลไกที่ต่างกันโดยสิ้นเชิง ทั้งคู่เขียน text-markup annotation ตาม ISO 32000-1 §12.5.6.10 จริงๆ คือ PDF /Subtype /Highlight เข้าไปใน array /Annots ของหน้า พร้อม /QuadPoints ที่ทำเครื่องหมาย glyph run ที่แน่นอน และ PDF viewer ที่เป็นไปตามมาตรฐานตัวใดก็ตามจะ render มันได้ทันทีที่ไฟล์ถูกบันทึก ไม่ใช่แค่ของ HotPDF เอง ขอบเขตของกลไกเดียวกันนี้ยังตัดสินว่าเครื่องหมายจะไปกลับผ่าน XFDF ได้หรือไม่ annotation ที่สร้างด้วย AddLoadedHighlightAnnotation เป็น PDF object ปกติที่ ExportLoadedAnnotationsToXFDF รับไปและส่งให้ Acrobat หรือเครื่องมือ review อื่นในรูปแบบ markup ตาม ISO 19444-1 ซึ่งครอบคลุมในการนำเข้าและส่งออก annotation ของ PDF เป็น XFDF ใน Delphi ในขณะที่พื้นที่ที่เพิ่มผ่าน AddHighlightRegion จะมองไม่เห็นสำหรับการ export นั้นเลย เพราะมันไม่เคยถูกเขียนเข้า object graph เลย มันมีอยู่ได้แค่ตราบเท่าที่ THPDFViewerModel ที่สร้างมันยังอยู่เท่านั้น ตระกูลเต็มของประเภท annotation แบบ markup และแบบ geometric ที่มีให้ใช้บนหน้ากระดาษ และวิธีที่สี่เหลี่ยมวางแต่ละแบบ ครอบคลุมในบทความเรื่อง PDF annotation ใน Delphi ด้วย HotPDF และกฎเชิงปฏิบัติก็ง่ายมาก ให้เครื่องหมายเป็นแบบใช้แล้วทิ้งได้ตลอดเวลาที่เอกสารยังอยู่ระหว่างการพูดคุย แล้วค่อยยืนยันมันเป็น annotation เมื่อการตัดสินใจสิ้นสุดแล้ว

ชั้นไฮไลต์หยุดอยู่ตรงไหน

ชั้นไฮไลต์เองไม่ได้พยายามทำตัวให้เหมือนปากกาไฮไลต์แบบโปร่งแสงเลย RefreshDocument วาดทุกพื้นที่เป็นสี่เหลี่ยมเส้นขอบสองพิกเซลด้วยสีของตัวเองซ้อนทับบนบิตแมปหน้าที่ cache ไว้ เหมือนกับที่มันวาดผลการค้นหา แทนที่จะ blend สีเติมทับข้อความข้างใต้ ดังนั้นลุคสีเหลืองแบบคลาสสิกต้องถูกวาดในโค้ดของแอปพลิเคชันเอง หรือรอไว้ให้ appearance stream ของ annotation ที่ยืนยันแล้วจัดการ ความสามารถหนึ่งที่ควรนำมาใช้ซ้ำเมื่อมีพื้นที่อยู่แล้วคือ CreateCurrentPageRegionSnapshot ซึ่งรับ THPDFRectangle ตัวเดียวกับที่ไฮไลต์ถืออยู่แล้ว และ render เฉพาะพื้นที่นั้นเป็นบิตแมป มีประโยชน์สำหรับการแนบภาพตัวอย่างเล็กๆ เข้ากับความคิดเห็น review โดยไม่ต้อง export ทั้งหน้า การสร้างระบบ review ไม่จำเป็นต้องเลือกระหว่างสองกลไกนี้ล่วงหน้า ตั้งค่าเริ่มต้นให้ทุกเครื่องหมายใหม่เป็นพื้นที่ THPDFViewerHighlight แบบใช้แล้วทิ้งตลอดเวลาที่กระทู้ความคิดเห็นยังเปิดอยู่ แล้วเรียก AddLoadedHighlightAnnotation ก็ต่อเมื่อผู้ review แก้ไขปัญหานั้นเสร็จแล้ว ซึ่งทำให้ PDF ที่โหลดอยู่ไม่ถูกแตะต้องระหว่างการพูดคุยไปมาที่สร้างการเปลี่ยนแปลงมากที่สุด คอนโทรล viewer ที่อธิบายในบทความนี้เป็นส่วนหนึ่งของHotPDF Componentรุ่นมาตรฐานสำหรับ Delphi และ C++Builder ควบคู่ไปกับ API ด้าน annotation และฟอร์มอื่นๆ ที่อ้างอิงไว้ข้างต้น