Một tài liệu lưu trữ dạng quét có thể nặng tới vài gigabyte trong một tệp PDF duy nhất. Một trình xem mở tệp đó thường chỉ muốn hiển thị một trang, có thể là mục lục, hoặc một trang mà người dùng nhảy tới từ một dấu trang (bookmark). Việc đọc toàn bộ tệp vào bộ nhớ chỉ để hiển thị hai trang là vô cùng lãng phí trên mọi phương diện: nó tiêu tốn không gian địa chỉ, làm người dùng phải chờ đợi sau một lượt đọc ban đầu kéo dài, và trên một tiến trình Delphi 32-bit, nó có thể thất bại ngay lập tức trước khi một trang duy nhất kịp xuất hiện. PDFium được xây dựng để giải quyết vấn đề này. Nó có thể tải một tài liệu thông qua một callback yêu cầu các phạm vi byte cụ thể mà nó cần, tại thời điểm nó cần, và không bao giờ yêu cầu toàn bộ tệp cùng một lúc.
Thành phần này cung cấp đường dẫn đó thông qua một bộ chuyển đổi luồng (stream adapter). Bạn truyền cho nó bất kỳ đối tượng TStream nào, và PDFium sẽ lấy các khối dữ liệu từ luồng đó theo yêu cầu. Tệp tin có thể nằm trên đĩa, trong một trường dữ liệu blob của cơ sở dữ liệu, hoặc đằng sau bất kỳ lớp dẫn xuất nào khác của TStream, và không có phần nào của nó được sao chép vào bộ nhớ trước tiên.
Cách PDFium yêu cầu các byte dữ liệu
C API của PDFium tải một tài liệu từ một đối tượng do bên gọi cung cấp được mô tả bởi cấu trúc FPDF_FILEACCESS. Cấu trúc này có ba phần quan trọng ở đây: một trường độ dài, một callback đọc, và một tham số người dùng mờ (opaque user parameter). Điểm vào tiêu thụ nó là FPDF_LoadCustomDocument. Một khi PDFium nắm giữ cấu trúc đó, nó sẽ phân tích cú pháp phần đuôi, định vị bảng tham chiếu chéo, và từ đó trở đi chỉ đọc những gì một thao tác cụ thể yêu cầu. Việc mở tài liệu chỉ chạm đến phần đuôi của tệp và một số ít đối tượng danh mục (catalog objects). Việc hiển thị trang 400 chỉ đọc các luồng nội dung và tài nguyên cho trang đó và không đọc thêm gì khác.
Đây là sự khác biệt giữa tải có đệm (buffered load) và tải truyền luồng (streaming load). Một lượt tải có đệm đọc tệp từ đầu đến cuối trước khi PDFium nhìn thấy byte số không. Một lượt tải truyền luồng đảo ngược mối quan hệ này: PDFium điều khiển các lượt đọc, và các byte không bao giờ được chạm tới sẽ không bao giờ được đọc. Đối với một tệp nặng nhiều gigabyte được xem từng trang một, đó là khoảng cách giữa một lượt tải không khả thi và một lượt tải diễn ra ngay tức thì.
Bộ chuyển đổi luồng (stream adapter)
Bộ chuyển đổi kết nối một lớp TStream của Delphi tới FPDF_FILEACCESS là TPdfStreamAdapter. Hàm dựng của nó nhận luồng dữ liệu và một cờ sở hữu, chụp lại độ dài luồng một lần, điền thông tin vào bản ghi FPDF_FILEACCESS, và thiết lập callback đọc. Khi PDFium sau đó gọi lại với một độ lệch và một kích thước, bộ chuyển đổi sẽ tìm vị trí (seek) của luồng tới độ lệch đó và sao chép chính xác phạm vi đó vào bộ đệm mà PDFium đã cung cấp.
// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
inherited Create;
if AStream = nil then
raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
FStream := AStream;
FOwnsStream := AOwnsStream;
// FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
// that would silently truncate past 4 GiB.
if AStream.Size > High(FPDF_DWORD) then
raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');
FillChar(FFileAccess, SizeOf(FFileAccess), 0);
FFileAccess.m_FileLen := FPDF_DWORD(AStream.Size);
FFileAccess.m_GetBlock := GetBlockCallback;
FFileAccess.m_Param := Self;
end;
Cờ sở hữu quyết định ai sẽ giải phóng luồng dữ liệu. Truyền giá trị False và bên gọi giữ lại luồng và phải duy trì sự tồn tại của nó trong suốt vòng đời của tài liệu. Truyền giá trị True và bộ chuyển đổi sẽ tiếp quản, giải phóng luồng khi tài liệu đóng lại. Trong cả hai trường hợp, luồng phải tồn tại lâu hơn mọi hoạt động đọc mà PDFium thực hiện, vì PDFium nắm giữ con trỏ FPDF_FILEACCESS và sẽ gọi ngược lại tại bất kỳ thời điểm nào khi tài liệu đang mở, chứ không chỉ trong quá trình tải ban đầu.
Tại sao callback là một hàm tĩnh (static function)
Callback đọc mà PDFium lưu trữ trong m_GetBlock là một con trỏ hàm C thuần túy với quy ước gọi cdecl. Một phương thức Delphi không thể được sử dụng trực tiếp, vì một phương thức mang theo một đối số Self ẩn mà bên gọi C không biết gì và sẽ không bao giờ cung cấp. Bộ chuyển đổi do đó khai báo callback dưới dạng một class function được đánh dấu cdecl; static, vốn biên dịch thành một hàm độc lập với bố cục khung C mà PDFium yêu cầu và không có biến Self ngầm định.
Điều đó giải quyết được vấn đề quy ước gọi nhưng lại đặt ra câu hỏi thứ hai: nếu không có Self, làm thế nào callback tiếp cận được luồng dữ liệu cụ thể mà nó cần đọc? Câu trả lời là tham số người dùng mờ (opaque user parameter). Khi bộ chuyển đổi dựng bản ghi, nó sẽ lưu trữ con trỏ thể hiện (instance pointer) của chính nó vào m_Param. PDFium truyền lại chính con trỏ đó dưới dạng đối số đầu tiên của mọi callback. Hàm tĩnh ép kiểu nó trở lại thành TPdfStreamAdapter và điều phối hoạt động đọc đối với luồng dữ liệu của thể hiện đó. Đây là cấu trúc cầu nối (trampoline) tiêu chuẩn để truyền ngữ cảnh đối tượng qua một ranh giới C vốn không có khái niệm về đối tượng.
// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
param : Pointer;
position: FPDF_DWORD;
pBuf : PByte;
size : FPDF_DWORD): Integer; cdecl;
var
Adapter: TPdfStreamAdapter;
begin
Result := 0;
if (param = nil) or (pBuf = nil) or (size = 0) then
Exit;
Adapter := TPdfStreamAdapter(param); // recover the instance from m_Param
if Adapter.FStream = nil then
Exit;
try
Adapter.FStream.Position := Int64(position);
Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
Result := 1;
except
Result := 0; // report failure by return value, never by raising
end;
end;
Giới hạn trần 4 GiB và lý do tại sao nó cần một chốt bảo vệ
Trường độ dài m_FileLen trong FPDF_FILEACCESS là một giá trị không dấu 32-bit. Chiều dài lớn nhất có thể biểu diễn của nó là thiếu một byte để đạt 4 GiB. Một lớp TStream báo cáo kích thước của nó dưới dạng số nguyên Int64, do đó một luồng có thể mô tả nhiều byte hơn nhiều so với khả năng chứa của trường đó. Ngay khi kích thước của một luồng vượt qua giới hạn trần đó, không có cách nào chính xác để thông báo cho PDFium biết độ dài thực tế của tệp.
Phản ứng sai lầm là gán kích thước đó và để nó tự động xoay vòng. Việc cắt ngắn độ dài 5 GiB thành một trường 32-bit sẽ tạo ra một con số nhỏ trông có vẻ hợp lý, và PDFium sau đó sẽ phân tích cú pháp tệp tin với niềm tin rằng tệp kết thúc ở khoảng một gigabyte. Phần đuôi và bảng tham chiếu chéo nằm ở cuối thực tế của tệp, vượt xa độ dài bị cắt ngắn, vì vậy quá trình phân tích cú pháp thất bại theo cách không liên quan gì đến nguyên nhân thực tế. Bạn sẽ mất thời gian sửa lỗi tham chiếu chéo trên một tệp hoàn toàn hợp lệ, mà không có bất kỳ gợi ý nào cho thấy một số nguyên đã bị tràn ở hai lớp phía trên.
Thay vào đó, bộ chuyển đổi từ chối đầu vào này. Hàm dựng so sánh kích thước luồng với giá trị High(FPDF_DWORD) và phát sinh lỗi EPdfError ngay lập tức khi luồng quá lớn để mô tả. Một lỗi rõ ràng, ngay tức thì sẽ chỉ ra vấn đề thực sự tại thời điểm xây dựng đối tượng. Sự cắt ngắn âm thầm sẽ che giấu lỗi đó đằng sau một triệu chứng dễ gây hiểu lầm mà bạn sẽ phải truy đuổi rất lâu sau đó. Giới hạn 4 GiB là một ràng buộc thực tế của đường dẫn tải này, và giải pháp trung thực nhất là đưa nó ra ánh sáng một cách rõ ràng thay vì che đậy nó bằng các phép toán biên dịch thành công.
Các thất bại không được phép vượt qua ranh giới
Một thao tác đọc có thể thất bại. Luồng dữ liệu có thể là một đối tượng kết nối qua mạng bị hết thời gian chờ, một tay cầm blob đã bị đóng dưới quyền kiểm soát của bạn, hoặc một tệp đã bị cắt ngắn sau khi tài liệu được mở. Thỏa thuận của PDFium cho callback đọc là một giá trị trả về: khác không biểu thị thành công, bằng không biểu thị thất bại. Đây là một khung C, và nó không có cơ chế để bắt hoặc lan truyền một ngoại lệ Pascal.
Đây là lý do tại sao cầu nối bọc thao tác tìm vị trí (seek) và thao tác đọc trong một cấu trúc lệnh try/except để nuốt ngoại lệ và trả về số không. Nếu một ngoại lệ Delphi được phép lan truyền ra ngoài callback, nó sẽ tháo gỡ (unwind) qua các khung ngăn xếp cdecl của PDFium, vốn chưa bao giờ được xây dựng để hỗ trợ cơ chế tháo gỡ ngoại lệ của Pascal. Kết quả nhẹ nhất là hành vi không xác định và tệ nhất là sự cố treo ứng dụng nghiêm trọng, nằm sâu bên trong trình phân tích cú pháp PDF mà không có ngăn xếp hoạt động nào khả dụng. Trả về số không giữ lỗi nằm trong thỏa thuận. PDFium nhìn thấy một lượt đọc khối bị lỗi, hủy bỏ thao tác một cách sạch sẽ, và hàm FPDF_LoadCustomDocument báo cáo tài liệu không thể tải được, thành phần này sẽ hiển thị lỗi đó dưới dạng EPdfError ở phía Pascal nơi nó thuộc về.
Mở một tài liệu theo cách này
Phương thức của thành phần thúc đẩy đường dẫn truyền luồng is LoadCustomDocument, được khai báo là một phương thức riêng biệt thay vì một nạp chồng (overload) khác của LoadDocument để việc truyền một đối tượng TMemoryStream không bao giờ vô tình rơi vào đường dẫn có đệm (buffered path). Nó xây dựng bộ chuyển đổi, gọi FPDF_LoadCustomDocument, và duy trì sự tồn tại của bộ chuyển đổi trong suốt vòng đời của tài liệu được tải.
var
Pdf: TPdf;
FileStream: TFileStream;
begin
Pdf := TPdf.Create(nil);
FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
try
// Hand stream ownership to Pdf: it frees FileStream when the document closes.
Pdf.LoadCustomDocument(FileStream, True);
// PDFium has read only the trailer and catalog so far.
// Rendering a page pulls just that page's bytes through the callback.
// ... render or inspect pages here ...
finally
Pdf.Free; // closes the document, which frees the adapter and the stream
end;
end;
Lệnh gọi tương tự cũng hoạt động cho đối tượng TMemoryStream, một luồng blob từ một tập dữ liệu cơ sở dữ liệu, hoặc một lớp dẫn xuất tùy chỉnh của TStream. Việc tải theo yêu cầu phát huy giá trị lớn nhất khi tệp lớn và chỉ một phần của nó được đọc: trình xem tài liệu lưu trữ, trình tạo hình thu nhỏ lấy mẫu một vài trang, một chỉ mục tìm kiếm kéo dữ liệu từng trang một. Khi tệp nhỏ hoặc bạn định đọc toàn bộ tệp, một lượt tải có đệm sẽ đơn giản hơn và cơ chế truyền luồng không mang lại lợi ích gì. Yếu tố quyết định là tỷ lệ giữa các byte bạn thực sự tương tác so với các byte mà tệp chứa.
Khi các trang được truyền luồng theo yêu cầu, mối quan tâm tiếp theo là duy trì khả năng phản hồi nhanh nhạy của các trang được hiển thị khi người dùng zoom và cuộn trang, vấn đề này được trình bày trong ghi chú của chúng tôi về hiệu năng render caching và zoom. Khi tài liệu được truyền luồng là tài liệu mà trình xem nên hiển thị nhưng không cho phép người dùng xuất hoặc thay đổi, các kỹ thuật trong hướng dẫn xem trước PDF bảo mật sẽ kết hợp tự nhiên với đường dẫn tải này. Cả hai đều dựa trên quy trình tải truyền luồng được mô tả ở đây, vốn đi kèm như một phần của PDFium Component dành cho Delphi và C++Builder bên cạnh các API kết xuất, trích xuất văn bản và chú thích được đề cập ở những nơi khác trên blog này.