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

โปรแกรมดู PDF แบบเลื่อนต่อเนื่องใน Delphi ด้วย PDFium Component

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

ความตึงเครียดดังกล่าวนั้นคือปัญหาทั้งหมดในที่นี้ PDFium Component แก้ไขปัญหานี้ภายในตัวควบคุม TPdfView ดังนั้นงานส่วนใหญ่จึงเป็นการเลือกโหมดการแสดงผลที่ถูกต้องและทำความเข้าใจสิ่งที่คอมโพเนนต์กำลังดำเนินการแทนคุณ ส่วนที่คอมโพเนนต์ไม่ได้ทำเพื่อคุณ เช่น การกำหนดขนาดหน้าเว็บสำหรับทิศทางการอ่าน และการรักษาความตอบสนองต่อการเลื่อนอย่างรวดเร็ว คือจุดที่โค้ดส่วนเล็ก ๆ จะมีบทบาทสำคัญ หากคุณยังคงประกอบโครงสร้างรอบข้าง (แถบเครื่องมือ, ภาพขนาดย่อ, กล่องค้นหา) บทช่วยสอนโปรแกรมดูเอกสารที่มีฟีเจอร์ครบครันจะครอบคลุมเนื้อหาเหล่านั้น; ส่วนหัวข้อในที่นี้คือการเลื่อนหน้ากระดาษนั่นเอง

เค้าโครงคือโหมดแสดงผล ไม่ใช่พาเนลของภาพบิตแมป

สัญชาตญาณจากการทำงานของฟอร์ม VCL คือการใช้กล่องเลื่อน (scroll box) และวางตัวควบคุมรูปภาพทับซ้อนกันภายในนั้น หนึ่งภาพต่อหนึ่งหน้า ขอให้หลีกเลี่ยง การออกแบบดังกล่าวบังคับให้คุณต้องจัดการตำแหน่งหน้ากระดาษ การคำนวณการเลื่อน และปัญหาหน่วยความจำทั้งหมดพร้อมกัน และคุณจะต้องสร้างองค์ประกอบเหล่านั้นขึ้นมาใหม่ในแบบที่ไม่ดีนัก TPdfView ได้สร้างแบบจำลองเอกสารให้เป็นหน้ากระดาษที่ต่อเนื่องกัน และเปิดเผยเค้าโครงผ่านพร็อพเพอร์ตี้ DisplayMode ของมัน

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

นั่นคือการตั้งค่าการเลื่อนต่อเนื่องทั้งหมด dmSingleContinuous จะจัดวางหน้ากระดาษในคอลัมน์แนวตั้งเดียวโดยจัดการช่องว่างระหว่างหน้าเป็นการภายใน และมุมมองจะเลื่อนผ่านคอลัมน์นั้นเสมือนเป็นพื้นผิวเดียว ไม่จำเป็นต้องเชื่อมต่อการควบคุมทีละหน้ากระดาษ และไม่มีตัวจัดการการเลื่อนที่ต้องเขียนสำหรับการนำทางปกติ โปรดสังเกตการตรวจสอบค่า Pdf.Active หลังการกำหนดค่า: การเปิดเอกสารจะไม่ทำให้เกิดข้อยกเว้น ดังนั้นไฟล์ที่เสียหายหรือได้รับการป้องกันด้วยรหัสผ่านจะทำให้ค่า Active เป็น False โดยไม่มีข้อยกเว้นให้ดักจับ และโปรแกรมดูที่ข้ามการตรวจสอบนี้จะแสดงผลพาเนลว่างเปล่าและคิดว่าเป็นความผิดพลาดของตัวเอง

พร็อพเพอร์ตี้เดียวกันนี้รองรับโหมดสเปรดด้วย dmTwoPageContinuous จะวางหน้าเคียงข้างกัน แถวละสองหน้า สำหรับการอ่านในสไตล์หนังสือที่เอกสารบางฉบับต้องการ; ส่วน dmTwoPageContinuousWithCover จะทำเช่นเดียวกันแต่ปล่อยให้หน้าแรกเป็นหน้าปกแยกต่างหาก เพื่อให้หน้าที่เหลือตกอยู่บนขอบเขตคู่-คี่ตามธรรมชาติ ทั้งสามโหมดนี้เลื่อนได้อย่างต่อเนื่อง การสลับระหว่างโหมดเหล่านี้คือการกำหนดค่าเพียงครั้งเดียว ซึ่งทำให้การเพิ่มกล่องคอมโบของโหมดแสดงผลในภายหลังเป็นเรื่องง่ายมาก

เฉพาะหน้ากระดาษที่มองเห็นเท่านั้นที่จะถูกแรสเตอร์ไรซ์

เหตุผลที่วิธีนี้รองรับไฟล์ขนาด 400 หน้าได้คือคอลัมน์เป็นแบบเสมือน (virtual) TPdfView ทราบความสูงของทุกหน้าจากโครงสร้างหน้าของเอกสาร ดังนั้นจึงสามารถคำนวณขอบเขตการเลื่อนทั้งหมดและตำแหน่งของแต่ละหน้าได้โดยไม่ต้องแปลงข้อมูลใด ๆ ขั้นตอนการแปลงเป็นแรสเตอร์ (Rasterization) ซึ่งเป็นขั้นตอนการทำงานที่มีค่าใช้จ่ายสูงในการเปลี่ยนกระแสเนื้อหาของหน้าเป็นพิกเซล จะเกิดขึ้นเฉพาะกับหน้ากระดาษที่ตัดกับพอร์ตมุมมอง (viewport) ในปัจจุบัน บวกกับระยะขอบเล็กน้อยเพื่อให้หน้ากระดาษพร้อมเมื่อเลื่อนเข้าสู่มุมมอง เมื่อคุณเลื่อนลง หน้ากระดาษที่เข้าสู่พอร์ตมุมมองจะถูกเรนเดอร์ และหน้าที่ออกไปจะถูกปลดปล่อยบิตแมป หน่วยความจำจะคงแปรผันตามสิ่งที่ปรากฏบนหน้าจอ ไม่ใช่ความยาวของเอกสาร

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

กำหนดขนาดหน้ากระดาษตามความกว้าง จากนั้นปล่อยให้การซูมเป็นไปตามธรรมชาติ

คอลัมน์การอ่านต้องการให้หน้ากระดาษปรับขนาดตามความกว้างของพาเนล ไม่ใช่ตรึงไว้ที่ระดับการซูมคงที่ FitMode ทำหน้าที่นี้และทำต่อไปเมื่อหน้าต่างปรับขนาด

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

ด้วย pfmFitWidth คอมโพเนนต์จะคำนวณการซูมใหม่ทุกครั้งที่มุมมองเปลี่ยนขนาด ดังนั้นคอลัมน์จะเติมความกว้างที่ใช้งานได้อยู่เสมอ และความสูงของหน้าเว็บรวมถึงขอบเขตการเลื่อนจะเป็นไปตามนั้น มีกับดักประการหนึ่งที่มักพบเจอ: การกำหนดค่า Zoom โดยตรงจะรีเซ็ต FitMode กลับเป็น pfmNone ซึ่งเป็นความตั้งใจที่จงใจตั้งไว้ เนื่องจากการซูมด้วยตนเองและการจัดขนาดอัตโนมัติเป็นเจตนาที่ขัดแย้งกัน แต่นั่นหมายความว่าหากมี PdfView.Zoom := 1.0 หลงเหลืออยู่ที่ใดที่หนึ่งในโค้ดของคุณ มันจะปิดฟีเจอร์การจัดพอดีกับความกว้างอย่างเงียบ ๆ และการเปลี่ยนขนาดครั้งต่อไปจะหยุดการปรับการจัดวาง หากคุณนำเสนอทั้งการควบคุมการซูมและปุ่มจัดพอดี ให้ถือว่าเป็นการเปลี่ยนโหมด: การตั้งค่าโหมดหนึ่งจะล้างอีกโหมดหนึ่ง และคุณเป็นผู้ตัดสินใจว่าโหมดใดจะชนะ

สำหรับการควบคุมการซูมแบบสัมบูรณ์ (absolute zoom controls) ที่อ่านได้อย่างเป็นธรรมชาติ มุมมองจะเปิดเผยอัตราซูมที่จัดพอดีเป็นค่าที่คุณสามารถนำไปใช้หรือแสดงผลได้: PageWidthZoom[PageNumber] จะส่งคืนอัตราซูมที่จะจัดพอดีหน้านั้นกับความกว้าง และ PageZoom ที่ตรงกันจะจัดพอดีทั้งหน้า การอ่านค่าเหล่านี้เป็นวิธีที่คุณใส่ข้อมูลลงในเมนู "Fit Width" / "Fit Page" โดยไม่ต้องเขียนรหัสเปอร์เซ็นต์วิเศษลงในโค้ดดิบ (hard-coding magic percentages) ซึ่งอาจผิดพลาดในหน้ากระดาษที่เป็นแนวนอนหรือมีขนาดใหญ่เกินไป

รักษาความตอบสนองต่อการเลื่อนอย่างรวดเร็วด้วยการเรนเดอร์แบบก้าวหน้า

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

ฟังก์ชัน RenderPageProgressive จะเรนเดอร์เป็นกลุ่มข้อมูล (chunks) และตรวจสอบโทเค็นการยกเลิกที่ขอบเขตของกลุ่มข้อมูลแต่ละกลุ่ม ดังนั้นการเรนเดอร์หน้าที่กำลังดำเนินอยู่ซึ่งเพิ่งถูกเลื่อนออกไปสามารถละทิ้งได้แทนที่จะต้องทำงานไปจนถึงจุดสิ้นสุด

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Cancel whatever was rendering; the old token is now signaled.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // bitmap is complete, paint it
    prsCancelled: Exit;                // superseded, discard this result
    prsFailed:    ShowMessage('Render failed for page ' + IntToStr(PageNo));
  end;
end;

ผลลัพธ์ที่สำคัญคือค่าส่งคืน prsDone หมายถึงบิตแมปได้รับการระบายสีอย่างสมบูรณ์และคุ้มค่าแก่การแสดงผลบนหน้าจอ; prsCancelled หมายถึงตำแหน่งการเลื่อนที่ใหม่กว่ามาแทนที่หน้านี้ ดังนั้นคุณจึงทิ้งผลลัพธ์บางส่วนแทนที่จะแสดงผล; ส่วน prsFailed คือข้อผิดพลาดที่แท้จริงบนหน้านั้น การยกเลิกจะถูกตรวจสอบที่ขอบเขตของกลุ่มข้อมูลแทนที่จะทำล่วงหน้า ดังนั้นโปรดคาดหวังความล่าช้าในระดับหลายสิบมิลลิวินาทีระหว่างการเรียก Cancel กับการที่การเรนเดอร์จะหยุดลงจริง ๆ ซึ่งนั่นยังคงประหยัดกว่าการปล่อยให้การเรนเดอร์แบบเต็มหน้ากระดาษที่ล้าสมัยมาขัดขวางคิว การส่งค่า nil เป็นโทเค็นจะเรนเดอร์ตรงไปจนเสร็จสิ้น ซึ่งเป็นตัวเลือกที่ถูกต้องสำหรับการเรนเดอร์ครั้งเดียว เช่น โปรแกรมแสดงตัวอย่างการพิมพ์ซึ่งไม่มีอะไรให้ยกเลิกขัดแย้งด้วย

เมื่อคุณเรียกใช้งานรูปแบบฟังก์ชันของ RenderPage แทน ซึ่งจะส่งคืน TBitmap ใหม่ โปรดจำไว้ว่าผู้เรียกเป็นเจ้าของและต้องสั่ง Free มัน ในลูปการเลื่อนที่จองหน่วยความจำบิตแมปในแต่ละหน้า การลืมจุดนี้จะเป็นปัญหาหน่วยความจำรั่วไหล (memory leak) ที่สะสมตัวมากขึ้นในทุกหน้าที่ผู้ใช้เลื่อนผ่าน ซึ่งนั่นคือความล้มเหลวของหน่วยความจำที่ไม่มีขอบเขตอย่างแน่นอนที่การออกแบบอย่างต่อเนื่องต้องการหลีกเลี่ยง โปรดเรนเดอร์ลงในบิตแมปที่นำกลับมาใช้ใหม่เมื่อคุณสามารถทำได้

สิ่งที่คุณได้รับในท้ายที่สุด

โปรแกรมดูแบบเลื่อนต่อเนื่องส่วนใหญ่เป็นหน้าที่ของคอมโพเนนต์ในการจัดหาให้ คุณเลือก dmSingleContinuous สำหรับเค้าโครง กำหนดค่า pfmFitWidth เพื่อให้คอลัมน์ปรับขนาดใหม่ตามหน้าต่าง และตรวจสอบ Pdf.Active เพื่อให้ไฟล์ที่ไม่ดีล้มเหลวในการทำงานอย่างชัดเจน ส่วนเดียวที่คุ้มค่าแก่การเขียนขึ้นมาเองคือการเรนเดอร์ที่ยกเลิกได้ เนื่องจากโปรแกรมดูจะได้รับการตัดสินจากพฤติกรรมเมื่อมีผู้ลากแถบเลื่อนไปยังส่วนล่างของเอกสารที่ยาว และพาเนลจะแสดงผลตามทันหรือไม่ ทุกอย่างหลังจากนั้น การเลือกข้อความในหน้ากระดาษต่าง ๆ การเน้นสีการค้นหา แผนผังบุ๊กมาร์ก คือส่วนงานอินเทอร์เฟซที่อยู่ด้านบนของพื้นผิวการเลื่อนนี้ แทนที่จะอยู่ภายในตัวมันเอง

API ของ TPdfView, DisplayMode, และ RenderPageProgressive ที่แสดงที่นี่เป็นส่วนหนึ่งของ PDFium Component สำหรับ Delphi และ Lazarus