Một trang A4 duy nhất được hiển thị ở mức thu phóng đọc dễ chịu chiếm khoảng vài megabyte bitmap 32-bit. Nhân con số đó với một hợp đồng 400 trang và phép tính không còn là trừu tượng nữa: việc hiển thị trước mọi trang đồng nghĩa với việc bạn đang yêu cầu Windows cấp hơn một gigabyte bitmap mà người dùng sẽ chỉ xem từng màn hình một tại một thời điểm. Ứng dụng sẽ hết không gian địa chỉ trên bản dựng 32-bit hoặc mất vài giây đầu tiên bị đóng băng trong khi GPU và bộ phân tích cú pháp trang xử lý những trang mà chưa ai cuộn tới. Trình đọc cuộn liên tục phải mang lại cảm giác như một dải trang dài, nhưng thực tế nó không thể giữ tất cả chúng trong bộ nhớ cùng một lúc
Sự mâu thuẫn đó chính là toàn bộ vấn đề ở đây. PDFium Component giải quyết vấn đề này bên trong TPdfView, vì vậy hầu hết công việc là chọn chế độ hiển thị phù hợp và hiểu những gì thành phần này đang thực hiện thay bạn. Những phần mà nó không tự động làm cho bạn, chẳng hạn như định kích thước các trang cho luồng đọc và giữ cho việc cuộn nhanh phản hồi nhạy, là nơi một đoạn mã nhỏ phát huy tác dụng. Nếu bạn vẫn đang xây dựng các phần giao diện xung quanh (thanh công cụ, ảnh thu nhỏ, hộp tìm kiếm), bài viết hướng dẫn xây dựng trình xem phong phú tính năng sẽ đề cập đến phần đó; ở đây chủ đề sẽ tập trung vào chính thao tác cuộn
Bố cục là một chế độ hiển thị, không phải là một bảng chứa các bitmap
Bản năng khi làm việc với biểu mẫu VCL là tìm đến hộp cuộn (scroll box) và xếp chồng các điều khiển hình ảnh (image control) bên trong đó, mỗi trang một điều khiển. Hãy tránh làm điều này. Thiết kế đó buộc bạn phải tự xử lý việc định vị trang, tính toán cuộn và vấn đề bộ nhớ cùng một lúc, và bạn sẽ tái phát minh lại tất cả các phần đó một cách tồi tệ. TPdfView đã mô hình hóa tài liệu dưới dạng một chuỗi các trang liên tục và hiển thị bố cục đó thông qua thuộc tính DisplayMode của nó
Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;
PdfView.DisplayMode := dmSingleContinuous; // rộng một trang, cuộn theo chiều dọc
Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
ShowMessage('Không thể mở tài liệu');
Đó là toàn bộ quá trình thiết lập cuộn liên tục. Chế độ dmSingleContinuous sắp xếp các trang thành một cột dọc duy nhất với các khoảng cách giữa chúng được xử lý nội bộ, và khung nhìn cuộn qua cột đó như một bề mặt duy nhất. Không có điều khiển riêng cho từng trang cần liên kết và không cần viết trình xử lý cuộn cho điều hướng thông thường. Lưu ý việc kiểm tra thuộc tính Pdf.Active sau khi gán: việc mở một tài liệu không bao giờ kích hoạt ngoại lệ, vì vậy một tệp bị hỏng hoặc được bảo vệ bằng mật khẩu sẽ khiến Active ở trạng thái False mà không có ngoại lệ nào để bắt giữ, và một trình xem bỏ qua bước kiểm tra này sẽ chỉ hiển thị một bảng trống và tự báo lỗi
Cùng một thuộc tính đó cũng hỗ trợ các chế độ trang đôi (spread mode). Chế độ dmTwoPageContinuous đặt các trang cạnh nhau, hai trang một hàng, để đọc theo kiểu sách mà một số tài liệu yêu cầu; chế độ dmTwoPageContinuousWithCover cũng làm tương tự nhưng để trang đầu tiên đứng riêng lẻ làm bìa để các trang đôi còn lại rơi vào ranh giới chẵn-lẻ tự nhiên. Cả ba chế độ đều cuộn liên tục. Việc chuyển đổi giữa các chế độ chỉ là một phép gán đơn giản, giúp việc thêm hộp lựa chọn chế độ hiển thị sau này trở nên cực kỳ dễ dàng
Chỉ những trang hiển thị mới được số hóa (rasterize)
Lý do phương pháp này có thể mở rộng cho tệp 400 trang là vì cột này là ảo. TPdfView biết chiều cao của từng trang từ cây trang của tài liệu, do đó nó có thể tính toán toàn bộ phạm vi cuộn và vị trí của từng trang mà không cần số hóa bất kỳ thứ gì. Quá trình số hóa (rasterization), bước tốn kém chuyển đổi luồng nội dung của trang thành các điểm ảnh (pixel), chỉ xảy ra đối với các trang hiện đang cắt ngang qua khung nhìn (viewport), cộng thêm một phần biên nhỏ để trang sẵn sàng khi nó được cuộn vào tầm nhìn. Khi bạn cuộn xuống, các trang đi vào khung nhìn sẽ được kết xuất và các trang rời khỏi đó sẽ được giải phóng bitmap. Bộ nhớ luôn tỷ lệ thuận với những gì hiển thị trên màn hình, chứ không phải chiều dài của tài liệu
Đây là điều rất đáng ghi nhớ vì nó thay đổi cách bạn đánh giá về chi phí tài nguyên. Việc mở một tài liệu 400 trang rất nhanh chóng: nó chỉ phân tích cú pháp cấu trúc chứ không phải nội dung. Chi phí kết xuất được tính trên từng trang và được xử lý trì hoãn (lazily), ngay tại thời điểm trang được cuộn đến gần. Một trình xem mang lại cảm giác tức thì khi mở và mượt mà khi cuộn không phải là làm ít việc hơn về tổng thể, mà là nó đang phân bổ công việc dọc theo lộ trình đọc thực tế của người dùng và loại bỏ những gì bị bỏ lại phía sau. Hệ quả thực tế là bạn hầu như không bao giờ muốn ép buộc kết xuất trước các trang nằm ngoài tầm nhìn của người dùng. Hãy để khung nhìn tự quyết định những gì hiển thị
Định kích thước trang theo chiều rộng, rồi giữ nguyên mức thu phóng
Một cột đọc yêu cầu các trang được định kích thước theo chiều rộng của bảng điều khiển, chứ không cố định vào một mức thu phóng tuyệt đối. Thuộc tính FitMode thực hiện điều này và duy trì nó khi cửa sổ thay đổi kích thước
PdfView.FitMode := pfmFitWidth; // mỗi trang lấp đầy chiều rộng cột; chiều cao thay đổi theo
Với pfmFitWidth, thành phần này sẽ tính toán lại mức thu phóng bất cứ khi nào khung nhìn thay đổi kích thước, do đó cột luôn lấp đầy chiều rộng khả dụng và chiều cao của trang, và do đó cả phạm vi cuộn, sẽ thay đổi theo. Có một bẫy thường gặp: việc gán trực tiếp thuộc tính Zoom sẽ đặt lại FitMode về pfmNone. Điều này là có chủ ý, vì việc thu phóng thủ công và tự động căn chỉnh là hai ý định trái ngược nhau, nhưng nó có nghĩa là một lệnh gán PdfView.Zoom := 1.0 vô tình ở đâu đó trong mã nguồn của bạn sẽ âm thầm tắt chế độ tự động vừa chiều rộng và lần thay đổi kích thước tiếp theo sẽ ngừng bố cục lại. Nếu bạn cung cấp cả thanh điều khiển thu phóng và nút căn chỉnh, hãy xử lý chúng như một công tắc chuyển đổi chế độ: thiết lập cái này sẽ xóa cái kia, và bạn quyết định cái nào sẽ được ưu tiên
Đối với các điều khiển thu phóng tuyệt đối, khung nhìn hiển thị các giá trị thu phóng căn chỉnh để bạn áp dụng hoặc hiển thị: PageWidthZoom[PageNumber] trả về mức thu phóng giúp trang vừa với chiều rộng, và thuộc tính PageZoom tương ứng giúp vừa toàn bộ trang. Việc đọc các thuộc tính này là cách bạn đưa dữ liệu vào menu "Vừa chiều rộng" / "Vừa trang" mà không cần viết cứng các tỷ lệ phần trăm tùy ý vốn dễ bị sai lệch đối với các trang nằm ngang hoặc có kích thước quá khổ
Giữ cho việc cuộn nhanh nhạy bén bằng kết xuất lũy tiến
Đường dẫn kết xuất mặc định sẽ vẽ một trang hoàn chỉnh trước khi trả về kết quả. Đối với một trang đơn lẻ thì điều này rất ổn. Nhưng trong lúc cuộn nhanh qua một tài liệu phức tạp thì không: mỗi trang lướt qua sẽ kích hoạt một quá trình số hóa toàn bộ, và nếu người dùng cuộn nhanh hơn tốc độ kết xuất của các trang, các tác vụ kết xuất đó sẽ bị dồn ứ và bảng điều khiển sẽ bị giật cục vì hệ thống đang thực hiện công việc cho các trang đã nằm ngoài màn hình tại thời điểm kết xuất hoàn tất. Cách khắc phục là làm cho quá trình kết xuất có thể hủy bỏ được và từ bỏ nó ngay khi người dùng chuyển sang trang khác
Hàm RenderPageProgressive kết xuất theo từng đoạn và kiểm tra mã thông báo hủy (cancellation token) ở mỗi ranh giới đoạn, nhờ đó một tác vụ kết xuất đang chạy của một trang vừa bị cuộn đi có thể bị loại bỏ thay vì chạy tiếp cho đến hết
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
// Hủy bất kỳ tác vụ kết xuất nào đang chạy; mã thông báo cũ hiện đã được phát tín hiệu.
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 hoàn thành, vẽ nó lên màn hình
prsCancelled: Exit; // bị thay thế, loại bỏ kết quả này
prsFailed: ShowMessage('Kết xuất thất bại cho trang ' + IntToStr(PageNo));
end;
end;
Điều quan trọng cần lưu ý là giá trị trả về. prsDone nghĩa là bitmap đã được vẽ hoàn chỉnh và có thể hiển thị lên màn hình; prsCancelled nghĩa là một vị trí cuộn mới hơn đã thay thế trang này, vì vậy bạn loại bỏ kết quả kết xuất một phần thay vì hiển thị nó; prsFailed là một lỗi thực sự xảy ra trên trang đó. Việc hủy bỏ được thăm dò ở các ranh giới đoạn chứ không phải chặn trước, vì vậy hãy chuẩn bị cho độ trễ vài chục mili giây giữa việc gọi Cancel và quá trình kết xuất thực sự dừng lại. Điều đó vẫn rẻ hơn nhiều so với việc để một tác vụ kết xuất toàn bộ trang lỗi thời làm tắc nghẽn hàng đợi. Việc truyền nil làm mã thông báo sẽ kết xuất trực tiếp cho đến khi hoàn thành, đây là lựa chọn phù hợp cho việc kết xuất một lần duy nhất như xem trước khi in khi không có gì để hủy bỏ
Thay vào đó, khi bạn gọi dạng hàm của RenderPage, dạng trả về một đối tượng TBitmap mới, hãy nhớ rằng bên gọi sở hữu nó và phải gọi Free để giải phóng. Trong một vòng lặp cuộn cấp phát một bitmap cho mỗi trang, việc quên điều này sẽ dẫn đến rò rỉ bộ nhớ tăng dần theo mỗi trang người dùng đi qua, đây chính là lỗi tràn bộ nhớ không giới hạn mà thiết kế cuộn liên tục đáng lẽ phải tránh. Hãy kết xuất vào một bitmap được tái sử dụng bất cứ khi nào có thể
Những gì còn lại cho bạn
Trình đọc cuộn liên tục phần lớn do thành phần tự động xử lý. Bạn chọn dmSingleContinuous cho bố cục, đặt pfmFitWidth để cột tự động căn chỉnh lại theo cửa sổ, và kiểm tra Pdf.Active để tệp bị lỗi sẽ báo lỗi rõ ràng. Phần duy nhất đáng để tự viết là kết xuất có thể hủy bỏ, bởi vì một trình đọc được đánh giá dựa trên cách nó hoạt động khi ai đó kéo thanh cuộn xuống cuối một tài liệu dài và bảng điều khiển có bắt kịp hành động đó hay không. Mọi thứ sau đó, chẳng hạn như chọn văn bản qua các trang, làm nổi bật kết quả tìm kiếm, cây dấu trang, là công việc giao diện nằm trên bề mặt cuộn này chứ không phải bên trong nó
Các API TPdfView, DisplayMode, và RenderPageProgressive được trình bày ở đây là một phần của PDFium Component dành cho Delphi và Lazarus