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

PDF Viewer แบบกำหนดเองใน Delphi ด้วย HotPDF: สถาปัตยกรรม MVC

HotPDF แยก PDF viewer ของ Delphi ออกเป็นสองส่วน คือ THPDFViewerModel คลาสธรรมดาที่ถือ state ของ zoom, การหมุน, การค้นหา, ไฮไลต์ และการนำทาง โดยไม่พึ่งพา window-handle ใดๆ เลย กับ THPDFViewer คอนโทรลที่อิงจาก TScrollBox ซึ่งแปลง state นั้นให้เป็นพิกเซล การแยกนี้เองที่ทำให้ logic ของ viewer ทำงานได้และถูกทดสอบได้ โดยไม่ต้องสร้างฟอร์มเลยสักครั้ง

คอนโทรล viewer แบบกำหนดเองส่วนใหญ่ไม่ได้หน้าตาแบบนี้ ระดับ zoom อยู่ใน field ส่วนตัวของคอนโทรล การนำทางหน้าจำกัดขอบเขตของมันอยู่ใน handler OnClick ของปุ่ม และวิธีเดียวที่จะรู้ว่า Ctrl+scroll เคารพเพดาน zoom หรือไม่คือต้องรันแอป คลิก แล้วดูเอา คอนโทรลที่สร้างแบบนี้ทำงานได้ดีจนกว่าจะต้องมี regression suite หรือมี host ตัวที่สอง เช่น กล่องโต้ตอบ print-preview, แถบภาพ thumbnail, ตัวรีวิวแบบ batch ที่ไม่มีหน้าต่างให้เห็นเลย แล้วก็จะพบว่า state ที่ต้องการนั้นถูกเชื่อมติดกับ TWinControl ที่ยืนกรานต้องมี handle จริงก่อนจะทำอะไรได้

ทำไมคอนโทรล PDF viewer ถึงต้องแยกแบบ MVC เลย

PDF viewer ต้องการการแยกแบบนี้เพราะ state กับการนำเสนอของมันเปลี่ยนแปลงด้วยเหตุผลและอัตราที่ต่างกัน ดัชนีหน้า, zoom, การหมุนมุมมอง, ผลการค้นหา และพื้นที่ไฮไลต์ล้วนเป็น business state ที่คำนวณ ตรวจสอบ และ serialize ได้โดยไม่ต้องมีพิกเซลใดๆ บนหน้าจอเลย ส่วนการวาดบิตแมป การจับเมาส์ และการวาดสี่เหลี่ยม marquee-selection เป็นเรื่องของการนำเสนอที่จะมีความหมายก็ต่อเมื่อมีคอนโทรลอยู่จริงเท่านั้น HotPDF เก็บกลุ่มแรกไว้ใน THPDFViewerModel คลาสที่ไม่มีบรรพบุรุษด้าน windowing ของ VCL เลย และเก็บกลุ่มที่สองไว้ใน THPDFViewer ซึ่งถือ instance ของ model ไว้และตอบสนองต่อมัน ใกล้เคียงกับคู่ Model-View มากกว่า MVC สามชั้นแบบตำรา เพราะไม่มีคลาส Controller แยกต่างหาก และ THPDFViewer เองเป็นผู้แปลงเหตุการณ์คีย์บอร์ดและเมาส์ดิบให้เป็นการเรียก model สิ่งที่สำคัญกว่าชื่อเรียกคือทิศทางของ dependency ไม่มีอะไรใน THPDFViewerModel ที่ต้องการ Handle, message loop หรือ desktop ที่มองเห็นได้เลย ซึ่งเป็นสิ่งที่ทำให้ test suite ของ HotPDF เองสามารถขับเคลื่อนการเปลี่ยนหน้า, การจำกัด zoom, คำสั่งคีย์บอร์ด และการแปลงพิกัดไปกลับผ่าน DUnitX ได้โดยไม่ต้องเปิดหน้าต่างใดๆ เลย

แผนภาพสถาปัตยกรรมโปรแกรมดู PDF สั่งทำเองบน HotPDF ใน Delphi แสดง THPDFViewerModel แบบ headless ป้อนคอนโทรล THPDFViewer, action ของ TActionList และชุดทดสอบ DUnitX
viewer HotPDF สำหรับ Delphi แยกตัวออกเป็นเครื่องจักรสถานะไร้หัวหนึ่งชุดได้อย่างไร: คอนโทรล action ของ TActionList และ fixture ของ DUnitX ล้วนมาบรรจบกันที่ THPDFViewerModel ตัวเดียวกัน ซึ่งไม่เคยต้องการ handle หรือ message loop
uses
  DUnitX.TestFramework,
  HPDFDoc, HPDFViewerModel;

type
  [TestFixture]
  TViewerModelTests = class
  public
    [Test]
    procedure ZoomInStopsAtTheTopPresetLevel;
  end;

procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
  Doc: THotPDF;
  Model: THPDFViewerModel;
begin
  Doc := THotPDF.Create(nil);
  Model := THPDFViewerModel.Create;
  try
    Doc.LoadFromFile('sample.pdf');
    Model.Document := Doc;
    Model.Zoom := 64.0;          // ค่าสูงสุดของตารางพรีเซ็ต (6400%)
    Model.ZoomIn;                // อยู่ที่เพดานแล้ว
    Assert.AreEqual(64.0, Model.Zoom, 0.0001);
  finally
    Model.Free;
    Doc.Free;
  end;
end;

THPDFViewerModel ถืออะไรไว้บ้างจริงๆ

THPDFViewerModel ถือทุกอย่างที่ viewer ต้องใช้เพื่อตอบว่าตอนนี้ควรแสดงอะไรบนหน้าจอ โดยไม่ต้องรู้ว่าจะวาดมันอย่างไร PageIndex, PageNumber และ PageCount ติดตามตำแหน่ง ส่วน Zoom และ ZoomMode (vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom) ติดตามสเกล ViewRotation ติดตามการหมุนบนหน้าจอแบบไม่ทำลายข้อมูล ซึ่งไม่แตะต้อง entry /Rotate ของหน้ากระดาษเองเลย method การนำทาง อย่าง FirstPage, PriorPage, NextPage, LastPage และ method zoom อย่าง ZoomIn, ZoomOut ซึ่งเดินผ่านตารางระดับพรีเซ็ตคงที่สิบเก้าระดับตั้งแต่ 5% ถึง 6400% ก็อยู่ตรงนี้เช่นกัน พร้อมกับ FindAll/FindNext/FindPrevious สำหรับการค้นหาข้อความ และ AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions สำหรับ annotation หน้ากระดาษแบบถาวรที่ผู้เรียกต้องการเก็บไว้ระหว่างการ render model ยังถือเอาต์พุตไว้ด้วยเช่นเดียวกับอินพุต CreateCurrentPageSnapshot และ CreateCurrentPageMetafile export หน้าปัจจุบันบนหน้าจอออกมาตรงตัว และ PrintCurrentView ส่ง view ปัจจุบันชุดเดียวกันนั้น หน้าปัจจุบัน, DPI ที่มาจาก zoom ปัจจุบัน, การหมุนปัจจุบัน ไปยัง TPrinter ซึ่งเป็นงานที่แคบกว่าและจำกัดอยู่แค่ view เมื่อเทียบกับ pipeline การพิมพ์ทั้งเอกสารที่ครอบคลุมในคู่มือการพิมพ์ด้วย TPrinter ของ HotPDF ทุกการเปลี่ยนแปลงที่สำคัญยังยก event ที่ตรงกันด้วย ได้แก่ OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange ดังนั้นผู้ subscribe จะรู้ว่าอะไรเปลี่ยนไปโดยไม่ต้อง poll เลย

THPDFViewer รู้ได้อย่างไรว่าเมื่อไหร่ควร repaint

THPDFViewer รู้ว่าเมื่อไหร่ควร repaint เพราะมัน subscribe เข้ากับ model แทนที่จะเดา constructor ของ THPDFViewer สร้าง THPDFViewerModel ส่วนตัวขึ้นมา แล้วต่อสาย notification event ของมันทุกตัว ได้แก่ OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange เข้ากับ handler ส่วนตัวที่ตรงกัน งานของแต่ละ handler มีขนาดเล็ก คือเรียก RefreshDocument ซึ่งเป็น method ที่ทำการ rasterize หน้าปัจจุบันจริงๆ ผ่าน page renderer ที่ cache ไว้ตัวเดียวกับที่อธิบายในกลไกภายในการ render หน้าเป็นบิตแมปของ HotPDF แล้วประกอบกล่องไฮไลต์และผลการค้นหาซ้อนทับด้านบน และใช้การหมุน view ปัจจุบัน property แบบ published อย่าง PageIndex, Zoom, ZoomMode และ ViewRotation เป็นเพียงตัวส่งต่อบางๆ ตัว getter อ่าน FModel.PageIndex ตัว setter เขียน FModel.PageIndex ดังนั้นไม่ว่าจะมองจาก Object Inspector หรือจากโค้ด คอนโทรลก็ดูเหมือนถือ state ไว้เองโดยตรง ทั้งที่ THPDFViewerModel เป็นที่เดียวที่ state นั้นอยู่จริงๆ ผู้เรียกก็ไม่ได้ถูกจำกัดแค่ชุดที่ถูกส่งต่อเท่านั้น THPDFViewer เปิด model เองผ่าน property แบบอ่านอย่างเดียว Model: THPDFViewerModel ดังนั้นโค้ดที่ต้องการ FindFormFieldAt หรือ PrefetchCurrentPageSnapshots ซึ่งทั้งคู่คอนโทรลไม่ได้เปิดซ้ำให้ ก็สามารถข้าม wrapper ไปเรียก model ได้โดยตรง

ไปป์ไลน์ repaint ของโปรแกรมดู PDF ด้วย HotPDF ใน Delphi: อินพุตผู้ใช้กลายเป็นการเรียกโมเดลที่เหตุการณ์เปลี่ยนแปลงของมันผ่านด่านรวมชุดก่อนเรนเดอร์ RefreshDocument หนึ่งครั้ง จัดองค์ประกอบ แล้ววาด
จากการคลิกสู่พิกเซล: เหตุการณ์เปลี่ยนแปลงของ model ผ่านประตูความลึก BeginUpdate และทั้งสองเส้นทางยุบรวมเป็นการ render-and-paint หนึ่งรอบพอดีต่อการเปลี่ยนแปลงเชิงตรรกะหนึ่งครั้ง
procedure THPDFViewer.RefreshDocument;
var
  Bitmap: TBitmap;
  DPI: Integer;
begin
  // แบบย่อ: method จริงยังต้อง resolve DPI ของโหมด fit ด้วย
  // และประกอบสี่เหลี่ยมไฮไลต์กับผลการค้นหาซ้อนก่อน
  if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
  DPI := Round(96 * FModel.Zoom);
  Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
  try
    FModel.ApplyViewRotation(Bitmap);
    FImage.Picture.Bitmap.Assign(Bitmap);
  finally
    Bitmap.Free;
  end;
end;

BeginUpdate กับ EndUpdate หยุดพายุการ redraw

BeginUpdate กับ EndUpdate มีอยู่เพราะการเปลี่ยนแปลงเชิง logic ครั้งเดียวมักแตะ state หลายส่วนพร้อมกัน และการ repaint หลังแต่ละส่วนจะสิ้นเปลืองและรบกวนสายตาโดยไม่จำเป็น การสลับเอกสารที่โหลดอยู่คือตัวอย่างที่ชัดที่สุด การกำหนดค่า THPDFViewerModel.Document จะรีเซ็ตการหมุน view, เคลียร์ผลการค้นหา, เคลียร์พื้นที่ไฮไลต์ และกระโดดไปหน้าแรก และแต่ละขั้นตอนเหล่านั้นตามปกติจะยิง change event ของตัวเอง THPDFViewerModel ห่อลำดับนั้นไว้ใน BeginUpdate/EndUpdate ซึ่งเป็นคู่แบบนับ reference โดยการเรียกซ้อนกันจะยิง OnBeginUpdate เฉพาะตอนเปลี่ยนเข้าสู่การเรียกชั้นนอกสุด และยิง OnEndUpdate ตอนเปลี่ยนกลับออกมาเท่านั้น THPDFViewer ติดตามความลึกเดียวกันนี้ในฝั่งของตัวเอง และข้าม RefreshDocument สำหรับทุก event ย่อยขณะที่ตัวนับยังมากกว่าศูนย์ แล้วค่อย repaint เพียงครั้งเดียวเมื่อ batch ปิดลง event ย่อยยังคงยิงระหว่าง batch อยู่ดี ดังนั้นผู้ subscribe ที่สนใจแค่ OnSearchChange ก็ยังได้ยินมันอยู่ มีแค่การ repaint ของคอนโทรลเองเท่านั้นที่ถูกยุบเหลือการเรียกครั้งเดียวแทนที่จะเป็นสี่ครั้ง

การไฮไลต์แบบ marquee แปลงการลากเมาส์กลับเป็นพิกัด PDF ได้อย่างไร

การไฮไลต์แบบ marquee แปลงการลากเมาส์กลับเป็นพิกัด PDF ผ่าน method คู่หนึ่งของ model ที่สร้างมาเพื่อการไปกลับนี้โดยเฉพาะ คือ PagePointToView และ ViewPointToPage ทั้งคู่รับดัชนีหน้า, DPI และจุดพิกัด และทั้งคู่แก้ transform เป็นสองขั้นตอน ขั้นแรกคือ entry /Rotate ของหน้าเองและจุดกำเนิด PDF ที่มุมล่างซ้าย จากนั้นคือ ViewRotation ที่แยกออกมาต่างหากและไม่ทำลายข้อมูลของ view กับจุดกำเนิดอุปกรณ์ที่มุมบนซ้ายของ viewer โดยเฉพาะเพื่อให้ทิศทางย้อนกลับสามารถยกเลิกสองขั้นตอนนั้นในลำดับย้อนกลับที่เคร่งครัด และไปกลับได้ถูกต้องครบทั้งสิบหกชุดผสมของการหมุนหน้าและการหมุน view THPDFViewer เรียก ViewPointToPage เมื่อผู้ใช้ปล่อยเมาส์หลังจากลากสี่เหลี่ยมในโหมด interaction vimHighlight แปลงจุดอุปกรณ์สองจุดให้เป็น THPDFRectangle ในพื้นที่หน้ากระดาษ แล้วส่งให้ Model.AddHighlightRegion รายละเอียดหนึ่งอย่างที่ควรรู้ถ้าคุณจะสร้างอะไรคล้ายกันนี้ การจับเมาส์ (mouse capture) เป็นของ viewer ที่สืบทอดจาก TScrollBox ไม่ใช่ของ TImage ลูกที่บิตแมปถูกวาดลงไป เพราะ TControl.MouseCapture เป็น protected และมีแค่คอนโทรลแม่เท่านั้นที่จะขอมันได้ ดังนั้นการลากที่ออกนอกขอบเขตของภาพก่อนที่ปุ่มจะถูกปล่อย ก็ยังคงแก้ผ่าน MouseMove/MouseUp ที่ override ของ viewer เอง แทนที่จะถูกคอนโทรลลูกทิ้งไปเงียบๆ

การเดินทางพิกัดไปกลับสองด่านสำหรับการไฮไลต์แบบ marquee ของ HotPDF ใน Delphi: สี่เหลี่ยมอุปกรณ์ที่ลากผ่านการย้อน view transform และย้อน page transform เข้าสู่พื้นที่ผู้ใช้ PDF ครบทั้งสิบหกชุดการหมุน
การลาก marquee อาศัย inverse transform สองขั้น — ยกเลิก view transform ก่อน แล้วจึง page transform — สี่เหลี่ยมไฮไลต์จึงลงจุดถูกต้องใน PDF user space ไม่ว่าคู่การหมุนจะเป็นแบบใด
var
  ViewPt, PagePt: THPDFViewerPoint;
  Rect: THPDFRectangle;
begin
  ViewPt.X := 240;   // พิกเซลอุปกรณ์ภายในภาพที่เรนเดอร์ไว้
  ViewPt.Y := 96;
  if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
     RenderedDPI) then                 // DPI ที่คุณเรนเดอร์ครั้งล่าสุด
  begin
    Rect.Left := PagePt.X - 40;  Rect.Bottom := PagePt.Y - 10;
    Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
    Model.AddHighlightRegion(Model.PageIndex, Rect);
  end;
end;

สิ่งที่การแยกนี้ให้มากกว่า test suite สีเขียว

ผลตอบแทนไม่ได้จำกัดอยู่แค่การทดสอบผ่านใน CI job ที่ไม่มี desktop session เพราะ THPDFViewer ส่งต่อไปยัง THPDFViewerModel แทนที่จะทำ logic ซ้ำ HotPDF จึงสามารถเพิ่มผู้ใช้งานรายที่สาม คือ THPDFViewerAction และคลาสลูกที่เป็นรูปธรรมอย่าง THPDFZoomInAction และ THPDFFindNextAction ซึ่งเสียบการนำทาง, zoom, การค้นหา และการหมุนเข้ากับ TActionList มาตรฐานของ Delphi ได้ ทำให้ปุ่มบน toolbar หรือรายการเมนูขับเคลื่อน viewer แบบ declarative ได้ โดยเปิดใช้งานตัวเองอัตโนมัติตามว่ามี viewer ที่ resolve เป็นเป้าหมายของ action นั้นอยู่หรือไม่ ชั้นนั้นทั้งหมดไม่จำเป็นต้องรู้อะไรเกี่ยวกับบิตแมปหรือ GDI เลย มันแค่เรียก Viewer.NextPage หรือ Viewer.Model.FindNext แล้ว event chain ที่มีอยู่แล้วก็จัดการเรื่อง repaint ให้เอง และเพราะไม่มีอะไรใน THPDFViewerModel ที่อ้างอิงถึง TScrollBox, TImage หรือ window handle เลย state machine ข้างใต้ก็ไม่ได้ถูกเชื่อมติดกับคอนโทรลตัวนั้นตัวเดียวเช่นกัน model ตัวเดียวกันนี้อาจอยู่หลังพื้นผิว rendering ที่ต่างออกไปได้โดยไม่ต้องแตะ logic การนำทาง, zoom หรือการค้นหาแม้แต่บรรทัดเดียว

render cache ช่วยตรงไหน และไม่ช่วยตรงไหน

render cache ของ THPDFViewerModel ช่วยได้ภายในเอกสารที่โหลดอยู่แล้ว แต่ไม่ได้เปลี่ยนต้นทุนของการโหลดเอกสารนั้นตั้งแต่แรกเลย CreatePageSnapshot, CreateCurrentPageSnapshot และ method prefetch อย่าง PrefetchPageSnapshots/PrefetchCurrentPageSnapshots ล้วนวิ่งผ่าน renderer ที่ cache ไว้ตัวเดียวกันซึ่ง key ด้วยหน้าและ DPI ดังนั้นการเปลี่ยนกลับไปหน้าที่เคยดูแล้วที่ระดับ zoom เดียวกันจะเป็น cache hit แทนที่จะ render ใหม่ และการ prefetch หน้าใกล้เคียงในรัศมีเล็กๆ ช่วยให้กรณีทั่วไปของผู้อ่านที่เปลี่ยนหน้าไปข้างหน้าทีละหน้าราบรื่นขึ้น แต่ทั้งหมดนี้ไม่แตะต้นทุนของการเรียก LoadFromFile ครั้งแรกเลย และ viewer ที่สร้างมาเพื่อเปิดอะไรก็ตามที่ผู้ใช้ลากมาวาง ในที่สุดก็จะเจอไฟล์ที่ใหญ่พอจะทำให้การเรียกนั้นกลายเป็นคอขวดจริงๆ สำหรับทางเลือกแบบ tiered ที่อิง handle แทนการโหลดเต็มรูปแบบ ซึ่งควรรู้ไว้ก่อนวันนั้นจะมาถึง ดูได้ที่บทความคู่กันเรื่อง Direct File API สำหรับ PDF ขนาดใหญ่

คลาส Model และ View ที่อธิบายในบทความนี้เป็นอีกสองชิ้นส่วนของพื้นผิวเอกสารที่โหลดอยู่แบบเดียวกับที่ใช้ทั่วทั้งHotPDF Delphi Componentสำหรับ Delphi และ C++Builder ซึ่งสร้างมาให้ขับเคลื่อนได้จากฟอร์ม จาก TActionList หรือไม่ต้องพึ่งอะไรเลยก็ได้