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

สร้าง PDF Viewer แบบโต้ตอบได้ใน Delphi ด้วย TPDFlibViewer

การฝังแผงอ่าน PDF ในแอปพลิเคชันธุรกิจของ Delphi ไม่จำเป็นต้องเขียนวงรอบการเรนเดอร์ (rendering loop), กลไกการเลื่อนหน้า (scroll engine) หรือโมเดลการซูม (zoom model) เอง losLab PDF Library มาพร้อมกับ TPDFlibViewer ซึ่งเป็นตัวควบคุม VCL ที่เปิดเอกสารได้ด้วยการเรียกเพียงครั้งเดียว และมีฟังก์ชันการเลือกข้อความ, การแพนหน้าจอ, การซูมแบบเลือกพื้นที่ (marquee zoom), การจับภาพหน้าจอ (snapshots), การเน้นพื้นที่ (region highlighting), การเน้นผลการค้นหาแบบเต็มข้อความ, การโต้ตอบกับคำอธิบายประกอบ และการพิมพ์เป็นพฤติกรรมในตัว บทความนี้จะนำเสนอ 5 ขั้นตอนในการเปลี่ยนฟอร์มว่างๆ ให้กลายเป็นโปรแกรมดูเอกสารที่ใช้งานได้จริง ได้แก่ การวางตัวควบคุม, การเชื่อมต่อแอ็กชันสำเร็จรูป, การเพิ่มการเน้นข้อความค้นหา, การจัดการการคลิกคำอธิบายประกอบ และการพิมพ์

ทำไมต้องใช้ตัวควบคุม Viewer แทนการเขียน Rendering Loop?

ประเด็นเรื่องการแยกเลเยอร์ (layering) ต้องมาก่อน เนื่องจากจะอธิบายถึงสิ่งที่คุณได้รับจริงๆ การเรนเดอร์หน้าเอกสารเป็นบิตแมปเป็นเพียงส่วนที่ง่าย 20 เปอร์เซ็นต์ ส่วนอีก 80 เปอร์เซ็นต์คือทุกอย่างที่ล้อมรอบมัน เช่น แคชบิตแมปของหน้าเอกสารที่มีงบประมาณการลบข้อมูล (eviction budget), เลย์เอาต์การเลื่อนหน้าในหน้ากระดาษที่มีขนาดต่างกัน, โหมดการซูมที่คำนวณใหม่เมื่อเปลี่ยนขนาด, การทดสอบการชน (hit-testing) ที่จับคู่พิกเซลของเมาส์กลับไปยังพิกัดของหน้าเอกสารที่ระดับการซูมและการหมุนใดๆ และการกำหนดตารางเวลาการวาดใหม่ (repaint scheduling) ที่ไม่กะพริบ TPDFlibViewer ครอบคลุมทั้งหมดนี้และส่งมอบงานแรสเตอร์ให้กับเอนจินเดียวกันที่อธิบายไว้ในบทความเกี่ยวกับการเรนเดอร์แบบหลายเอนจิน ดังนั้นเลเยอร์การควบคุมและเลเยอร์การเรนเดอร์จึงสามารถเปลี่ยนแยกจากกันได้ โค้ดฟอร์มของคุณจะติดต่อกับตัวควบคุม Windows ที่เลื่อนได้ ไม่ใช่ติดต่อกับตัวเรนเดอร์โดยตรง

จะแทรก PDF Viewer ในแอปพลิเคชัน Delphi ได้อย่างไร?

TPDFlibViewer.LoadFromFile จะเปิดเอกสารผ่านอินสแตนซ์ TPDFlib ภายในของตัวควบคุมเอง และส่งกลับค่า 1 เมื่อสำเร็จ ดังนั้นโปรแกรมดูเอกสารขั้นต่ำสุดจึงมีเพียงการเรียกคอนสตรักเตอร์, การกำหนด Parent และการโหลดหนึ่งครั้ง หากคุณมีอินสแตนซ์ TPDFlib อยู่แล้วในส่วนอื่นของแอปพลิเคชัน AttachLibrary จะแสดงเอกสารที่เลือกแทน โดยที่ความเป็นเจ้าของยังคงอยู่ที่ฝั่งของคุณ

uses PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  if FViewer.LoadFromFile('quarterly-report.pdf', '') = 1 then
    FViewer.ViewerMode := vmHand;  // pan by dragging; a plain click still follows links
end;

คุณสมบัติ ViewerMode จะเลือกการทำงานของเมาส์จาก 5 ค่า ได้แก่: vmSelect เป็นเครื่องมือเลือกข้อความเริ่มต้น, vmHand แพนหน้าจอโดยการลากด้วยเคอร์เซอร์รูปมือในขณะที่การคลิกธรรมดายังคงลิงก์ตามปกติ, vmZoom เป็นการซูมแบบเลือกพื้นที่ซึ่งจะขยายแถบที่ลากให้เต็มช่องมองภาพ, vmSnapshot จะจับภาพพื้นที่ที่ลากไปยังคลิปบอร์ด (พร้อมเหตุการณ์ OnSnapshot และสวิตช์ SnapshotToClipboard) และ vmHighlight จะระบายสีเน้นพื้นที่กึ่งโปร่งใสแบบถาวร เครื่องมือเน้นข้อความนี้มีจุดที่ควรสังเกตเกี่ยวกับพิกัด: พื้นที่ต่างๆ จะถูกจัดเก็บไว้ในพื้นที่ผู้ใช้ของหน้าเอกสาร ไม่ใช่พิกเซลของไคลเอ็นต์ ดังนั้นเครื่องหมายของ AddHighlightRegion จะคงอยู่รอดจากการซูม การหมุนมุมมอง และการเปลี่ยนหน้าโดยที่คุณไม่ต้องบันทึกข้อมูลใดๆ เพิ่มเติม ในมุมมองที่หมุน ทิศทางการลากอาจวิ่งสวนทางกับแกนของหน้ากระดาษ ซึ่งทำให้ได้ความกว้างหรือความสูงที่เป็นลบหลังจากการจับคู่การหมุนผกผัน แต่ตัวควบคุมจะปรับสี่เหลี่ยมผืนผ้าให้เป็นมาตรฐานสำหรับคุณเอง

จะเชื่อมต่อ PDF Toolbar ใน Delphi โดยไม่ต้องเขียนโค้ดประสาน (Glue Code) ได้อย่างไร?

ยูนิต PDFlibViewerActions จะช่วยกำจัดโค้ดประสานของแถบเครื่องมือออกไปทั้งหมด มันมีคลาสสืบทอด TAction สำเร็จรูป 20 แบบ ครอบคลุมการนำทางหน้า (TPDFlibViewerFirstPage, TPDFlibViewerPriorPage, TPDFlibViewerNextPage, TPDFlibViewerLastPage), การซูม (TPDFlibViewerZoomIn, TPDFlibViewerZoomOut, TPDFlibViewerFitWidth, TPDFlibViewerFitPage, TPDFlibViewerActualSize), การหมุนมุมมอง (TPDFlibViewerRotateClockwise, TPDFlibViewerRotateAntiClockwise), การพิมพ์, ไดอะล็อกเปิดไฟล์และบันทึกเป็น, การคัดลอกข้อความที่เลือก และแอ็กชันสลับโหมดหนึ่งรายการต่อเครื่องมือเมาส์แต่ละชิ้น (TPDFlibViewerSelectMode, TPDFlibViewerHandMode, TPDFlibViewerMarqueeZoomMode, TPDFlibViewerSnapshotMode, TPDFlibViewerHighlightMode)แต่ละแอ็กชันจะชี้ไปยังตัวควบคุมผ่านคุณสมบัติ Viewer ที่เผยแพร่ไว้และรักษาพิกัดสถานะของตนเอง: ทุกแอ็กชันจะปิดการทำงานเมื่อไม่มีการเปิดเอกสาร, แอ็กชันนำทางจะติดตามตำแหน่งหน้า, แอ็กชันคัดลอกจะติดตามว่ามีการเลือกข้อความหรือไม่ และแอ็กชันโหมดและขนาดพอดีจะซิงค์สถานะที่เลือก (checked) ให้ตรงกับเครื่องมือและโหมดการซูมที่ใช้งานอยู่ ในเวลาออกแบบ (design time) ทั้งหมดจะลงทะเบียนในหมวดหมู่ losLab PDF ของตัวแก้ไข Action List ส่วนในเวลาทำงาน (runtime) คลาสเหล่านี้ใช้โค้ดเพียงสามบรรทัดเท่านั้น

uses System.Actions, Vcl.ActnList, PDFlibViewer, PDFlibViewerActions;

procedure TMainForm.BuildToolbar;
var
  NextPage: TPDFlibViewerNextPage;
  FitWidth: TPDFlibViewerFitWidth;
  HandMode: TPDFlibViewerHandMode;
begin
  NextPage := TPDFlibViewerNextPage.Create(Self);
  NextPage.ActionList := ActionList1;
  NextPage.Viewer := FViewer;
  btnNext.Action := NextPage;

  FitWidth := TPDFlibViewerFitWidth.Create(Self);
  FitWidth.ActionList := ActionList1;
  FitWidth.Viewer := FViewer;
  btnFitWidth.Action := FitWidth;

  HandMode := TPDFlibViewerHandMode.Create(Self);
  HandMode.ActionList := ActionList1;
  HandMode.Viewer := FViewer;
  btnHand.Action := HandMode;
end;

มีกับดักในขอบเขตยูนิตอย่างหนึ่งที่ควรทราบ หากคุณสร้างการลงทะเบียนแอ็กชันของคุณเองทับรูปแบบนี้ RegisterActions ถูกประกาศใน System.Actions ไม่ใช่ใน Vcl.ActnList และข้อผิดพลาดของคอมไพเลอร์ที่คุณได้รับเมื่อมีเพียง Vcl.ActnList ในส่วน uses นั้นทำให้เข้าใจผิดอย่างมาก: คลาสอาร์เรย์ลิเทอรัล [TAction1, TAction2, ...] จะถูกแยกวิเคราะห์กลับไปเป็นเซต ทำให้เกิดข้อผิดพลาด E2010 Incompatible types: 'Integer' and 'class of ...' ต่อเนื่องกัน ซึ่งดูเหมือนประเภทข้อมูลไม่ตรงกันในคลาสแอ็กชันของคุณ วิธีแก้ไขคือเพิ่มรายการ uses เพียงจุดเดียว แต่การวินิจฉัยข้อผิดพลาดชี้ไปทุกที่ยกเว้นจุดนั้น สำหรับแถบเครื่องมือซูม TPDFlibViewer.EffectiveZoomPercent จะรายงานระดับการซูมที่ส่งผลจริง — รวมถึงปัจจัยที่คำนวณในโหมดความกว้างพอดีและหน้าพอดี — ดังนั้นขั้นตอนการซูมเข้าจึงสามารถสร้างขึ้นจากการขยายจริงในปัจจุบัน แทนที่จะเป็นค่าสุดท้ายที่คุณกำหนด

การเน้นผลการค้นหาและการกลับค่าพิกัดที่อยู่เบื้องหลัง

HighlightSearchHits จะค้นหาเอกสารทั้งหมดสำหรับคำถามและทาสีแถบโปร่งแสงไว้เหนือทุกการจับคู่ ส่งคืนจำนวนผลลัพธ์; ClearSearchHighlights, SearchHighlightCount และคุณสมบัติ SearchHighlightColor จะทำหน้าที่ให้ครบวงจร เนื่องจากสี่เหลี่ยมผลลัพธ์การค้นหาจะถูกจัดเก็บไว้ในพื้นที่หน้ากระดาษและถูกแปลงพิกเซลอีกครั้งเมื่อมีการวาด แถบเน้นจึงคงอยู่ติดกับข้อความในขณะที่ผู้ใช้งานเลื่อนหน้า ซูม หรือหมุนมุมมอง หากคุณใช้งาน TPDFlibSearchPanel ร่วมด้วย ฟังก์ชัน RunSearch จะแสดงแถบเน้นในลักษณะเดียวกันใน Viewer ที่แนบอยู่โดยอัตโนมัติผ่านคุณสมบัติ HighlightInView ซึ่งเปิดใช้งานเป็นค่าเริ่มต้น

procedure TMainForm.btnSearchClick(Sender: TObject);
var
  Hits: Integer;
begin
  FViewer.SearchHighlightColor := clYellow;
  Hits := FViewer.HighlightSearchHits(edtQuery.Text, False);  // case-insensitive
  StatusBar1.SimpleText := Format('%d matches', [Hits]);
end;

ส่วนที่ละเอียดอ่อน และเป็นเหตุผลว่าทำไมจึงควรเลือกใช้ฟังก์ชันในตัวแทนที่จะสร้างเองจากผลการค้นหาดิบ คือการแปลงระบบพิกัด เอนจินข้อความจะรายงานสี่เหลี่ยมผืนผ้าที่ตรงกันในพิกัดที่ขึ้นอยู่กับการตั้งค่า Origin ของไลบรารี: โดย Origin 0 หรือ 3 แกน Y จะวิ่งจากล่างขึ้นบนเหมือนในพื้นที่ผู้ใช้ PDF ดั้งเดิม และ Origin 2 หรือ 3 แกน X จะถูกวัดจากขอบขวา เลเยอร์ภาพซ้อนทับของ Viewer จะทำงานในพื้นที่หน้ากระดาษแบบจากบนลงล่างและอิงซ้าย ดังนั้นสี่เหลี่ยมที่ตรงกันแต่ละรูปจะต้องถูกพลิก (Y = PageHeight - Bottom และในกรณีที่เกี่ยวข้อง X = PageWidth - Right) ก่อนจัดเก็บ — การนำค่าขอบบนที่รายงานตามระดับเริ่มต้นมาใช้ตรงๆ ภายใต้จุดเริ่มต้นเริ่มต้นจะทำให้แถบเน้นไปแสดงในตำแหน่งที่สะท้อนในแนวตั้งบนหน้ากระดาษ TPDFlibViewer จะทำการแปลงนี้ตามจุดเริ่มต้นที่ใช้งานอยู่เมื่อบันทึกข้อมูลที่ตรงกันแต่ละรายการ จากนั้นใช้การจับคู่การหมุนไปข้างหน้าในเวลาที่วาด ซึ่งเป็นสาเหตุที่แถบเน้นวางลงได้อย่างถูกต้องในทุกมุมมอง สำหรับความกึ่งโปร่งใสนั้นใช้ Windows AlphaBlend API ร่วมกับค่าอัลฟาต้นทางคงที่ เนื่องจาก VCL แคนวาสไม่มีความสามารถดั้งเดิมในการผสมผสานค่าอัลฟา ส่วนการค้นหาข้อความเบื้องหลังและ API ทางเรขาคณิตนั้นได้รับการครอบคลุมอย่างละเอียดในบทความการค้นหาข้อความและการแจกแจงองค์ประกอบของหน้า

การคลิกคำอธิบายประกอบ, คำแนะนำ และการส่งออกไฟล์แนบ

TPDFlibViewer ปฏิบัติต่อคำอธิบายประกอบเสมือนเป็นวัตถุโต้ตอบระดับเฟิร์สคลาส การคลิกคำอธิบายประกอบที่ไม่ใช่ลิงก์จะทริกเกอร์ OnAnnotClick พร้อมหมายเลขหน้า, ดัชนีคำอธิบายประกอบแบบอิง 1 และสตริงประเภทสาขาย่อย; AnnotAtPagePoint จะแสดงผลการทดสอบการชนแบบเดียวกันนี้ด้วยการเขียนโปรแกรม และข้ามคำอธิบายประกอบป๊อปอัปที่เป็นคู่กัน เพื่อให้คุณได้รับคำอธิบายประกอบที่ผู้ใช้เห็นจริงๆ เสมอ การวางเมาส์เหนือคำอธิบายประกอบจะแสดงข้อความ Contents เป็นคำแนะนำของตัวควบคุมผ่านสวิตช์ AnnotHints ซึ่งเปิดใช้งานเป็นค่าเริ่มต้นและทำงานร่วมกับคุณสมบัติ ShowHint มาตรฐานของ VCL สำหรับคำอธิบายประกอบ FileAttachment ฟังก์ชัน SaveAnnotAttachmentToFile จะเขียนไฟล์ที่ฝังไว้ลงในดิสก์และส่งกลับค่า 1 เมื่อสำเร็จ ซึ่งทำให้กระบวนการคลิกเพื่อบันทึกเสร็จสมบูรณ์ได้ในโค้ดเพียงไม่กี่บรรทัด

procedure TMainForm.ViewerAnnotClick(Sender: TObject; APage, AnnotIndex: Integer;
  const Subtype: WideString);
begin
  if Subtype = 'FileAttachment' then
    if FViewer.SaveAnnotAttachmentToFile(APage, AnnotIndex,
        'C:\Temp\attached-invoice.xml') = 1 then
      StatusBar1.SimpleText := 'Attachment saved';
end;

การพิมพ์และขอบเขตที่คุณควรวางแผนรับมือ

การพิมพ์ไม่จำเป็นต้องรวมระบบแยกต่างหาก PrintDoc จะแสดงกล่องโต้ตอบการพิมพ์มาตรฐานของ Windows — การเลือกเครื่องพิมพ์, ช่วงหน้า, จำนวนสำเนา, การเรียงลำดับหน้า — และส่งมอบงานไปยังเอนจินการพิมพ์ของไลบรารี ในขณะที่ PrintDoc(False) จะส่งเอกสารทั้งหมดไปยังเครื่องพิมพ์เริ่มต้นโดยไม่มี UI ใดๆ แสดงเลย PrintDocRange ทำงานผ่านการเขียนโปรแกรมอย่างสมณ์โดยระบุชื่อเครื่องพิมพ์, หน้าเริ่มต้นและสิ้นสุด, จำนวนสำเนา และแฟล็กการเรียงลำดับหน้า ซึ่งเป็นจุดเข้าใช้งานที่เหมาะสมสำหรับงานพิมพ์เป็นชุดหรืองานพิมพ์ที่ทริกเกอร์จากเซิร์ฟเวอร์โดยขับเคลื่อนจากอินสแตนซ์ Viewer เดียวกันนั้น

มีข้อจำกัดสองประการที่สมควรได้รับการกล่าวถึงอย่างตรงไปตรงมา ประการแรก TPDFlibViewer เป็นตัวควบคุม Windows VCL; เลเยอร์ Viewer จะถูกแยกออกจากโครงสร้างแบบ NOVCL ดังนั้นบริการคอนโซลและเป้าหมายที่ไม่ใช่ VCL ควรใช้ API การเรนเดอร์ของ TPDFlib ที่อยู่เบื้องหลังโดยตรง ประการที่สอง การใช้งานแบบไม่มีส่วนติดต่อผู้ใช้ (headless) ได้รับการสนับสนุนโดยเจตนาแต่มีขอบเขตจำกัด: CaptureRegion จะเรนเดอร์พื้นที่ไคลเอ็นต์ใดๆ — หน้าเอกสารพร้อมกับแถบเน้น — ลงในบิตแมปที่เป็นของฝั่งผู้เรียก และทำงานบนตัวควบคุมที่ไม่มี Parent โดยไม่ต้องมี Window Handle เนื่องจากเส้นทางการวาดหลีกเลี่ยงการเรียก VCL ที่จะบังคับให้สร้าง Handle สิ่งนี้ทำให้การตรวจสอบระดับพิกเซลในการทดสอบอัตโนมัติเป็นจริงได้ แต่สิ่งใดก็ตามที่จำเป็นต้องใช้วงรอบข้อความ (message loop) อย่างแท้จริง เช่น เครื่องมือเมาส์แบบโต้ตอบ ยังคงจำเป็นต้องใช้ในเซสชัน UI จริง ในด้านประสิทธิภาพ ตัวควบคุมจะเก็บแคชหน้าความจำไว้โดยสามารถกำหนดงบประมาณได้ผ่าน SetCacheLimit และสามารถเก็บหน้าเอกสารที่เรนเดอร์ไว้ข้ามเซสชันได้; บทความแคชหน้าเอกสารบนดิสก์ของ Viewer ครอบคลุมถึงเลเยอร์นั้นและพฤติกรรม DPI ต่อจอภาพโดยละเอียด

สรุปเป็น 5 ขั้นตอน ได้แก่: วางตัวควบคุมและเรียก LoadFromFile, แนบแอ็กชันสำเร็จรูปจาก PDFlibViewerActions, เรียก HighlightSearchHits สำหรับการค้นหาในหน้าเว็บ, จัดการ OnAnnotClick สำหรับกระบวนการทำงานของคำอธิบายประกอบ และเปิดเผย PrintDoc ทุกส่วนที่แสดงที่นี่มีมาให้ใน losLab PDF Library มาตรฐานสำหรับ Delphi ซึ่งตัวควบคุม Viewer, คลาสแอ็กชัน และเอนจินการเรนเดอร์จะถูกติดตั้งร่วมกัน