掃描的封存檔案在單個 PDF 中可能達到數 GB;開啟此類檔案的檢視器通常只想顯示一頁,可能是目錄,也可能是使用者從書籤跳轉到的頁面;將整個檔案讀入記憶體以轉譯兩個頁面在每個軸線上都是浪費的:它會消耗位址空間、讓使用者在漫長的初始讀取中等待,且在 32 位元的 Delphi 程序上,它可能在單個頁面出現之前就直接失敗;PDFium 的建置考慮到了這一點;它可以透過回呼載入檔案,該回呼在需要時要求其所需的特定位元組範圍,而且它絕不會一次要求整個檔案
元件透過串流配接器公開該路徑;您給它任何 TStream,PDFium 就會按需從該串流中拉取資料塊;檔案可以存在於磁碟上、資料庫的 blob 欄位中,或任何其他 TStream 子代背後,且事先都不會被複製到記憶體中
PDFium 如何要求位元組
PDFium 的 C API 載入檔案自呼叫者提供的、由 FPDF_FILEACCESS 結構描述的物件;該結構在此處有三個重要的部分:長度欄位、讀取回呼,以及不透明的使用者參數;取用它的進入點是 FPDF_LoadCustomDocument;一旦 PDFium 持有該結構,它就會解析結尾、定位交叉參照表,此後僅讀取指定操作所要求的內容;開啟檔案會觸及檔案的尾部和少數型錄物件;轉譯第 400 頁只會讀取該頁面的內容串流和資源,而沒有其他內容
這就是緩衝載入與串流載入的區別;緩衝載入在 PDFium 看到位元組零之前端到端地讀取檔案;串流載入反轉了這種關係:PDFium 驅動讀取,而從未被觸及的位元組就永遠不會被讀取;對於一次檢視一頁的數 GB 檔案,這就是無法使用的載入與瞬間載入之間的差距
串流配接器
將 Delphi TStream 橋接至 FPDF_FILEACCESS 的配接器是 TPdfStreamAdapter;它的建構子接受串流和所有權旗標、擷取串流長度一次、填入 FPDF_FILEACCESS 記錄,並配置讀取回呼;當 PDFium 稍後使用位移量和大小進行回呼時,配接器會將串流定址(Seek)到該位移量,並精確地將該範圍複製到 PDFium 提供的緩衝區中
// 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;
所有權旗標決定了誰來釋放串流;傳遞 False,呼叫者保留串流且在檔案的整個生命週期中都必須使其保持活動狀態;傳遞 True,配接器接管,在檔案關閉時釋放串流;無論哪種方式,串流都必須比 PDFium 將執行的每次讀取活得更久,因為 PDFium 持有 FPDF_FILEACCESS 指標,且會在檔案開啟期間的任何時間點進行回呼,而不僅僅是在初始載入期間
為什麼回呼是靜態函數
PDFium 儲存在 m_GetBlock 中的讀取回呼是具有 cdecl 呼叫慣例的純 C 函數指標;Delphi 方法無法直接使用,因為方法攜帶一個隱藏的 Self 引數,C 呼叫者對此一無所知且永遠不會提供;因此,配接器將回呼宣告為標記有 cdecl; static 的 class function,這會編譯為具有 PDFium 預期之 C 訊框版面配置且沒有隱含 Self 的獨立函數
這解決了呼叫慣例,但提出了第二個問題:在沒有 Self 的情況下,回呼如何到達它應該從中讀取的特定串流?答案是不透明的使用者參數;當配接器建置記錄時,它將其自身的執行個體指標儲存在 m_Param 中;PDFium 在每次回呼的第一個引數中傳回該相同的指標;靜態函數將其轉換回 TPdfStreamAdapter,並針對該執行個體的串流分派讀取;這是跨越沒有物件概念之 C 邊界傳遞物件內容的標準彈簧床(Trampoline)方法
// 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;
4 GiB 上限以及為什麼它需要防護
FPDF_FILEACCESS 中的長度欄位 m_FileLen 是 32 位元無符號值;其最大可表示長度比 4 GiB 少一個位元組;TStream 將其大小回報為 Int64,因此串流可以描述遠超出該欄位所能容納的位元組;一旦串流的大小超過該上限,就沒有誠實的方法可以告訴 PDFium 檔案有多長
錯誤的反應是分配大小並讓其環繞;將 5 GiB 長度截斷為 32 位元欄位會產生一個看起來合理的小數字,然後 PDFium 將會解析檔案,相信它在約 1 GB 處結束;結尾和交叉參照表存在於檔案的真實末尾,遠超出截斷長度,因此解析會以與實際原因完全無關的方式失敗;您將會在完全有效的檔案上偵錯交叉參照錯誤,而沒有任何提示表明整數在兩層之上發生了環繞
配接器反而會拒絕該輸入;建構子會將串流大小與 High(FPDF_DWORD) 進行比較,並在串流過大而無法描述的瞬間引發 EPdfError;在建構點上,顯式且立即的錯誤指出了真實的問題;靜默截斷會將其隱藏在您稍後才會追查的誤導性症狀背後;4 GiB 限制是此載入路徑的真實約束,而誠實的做法是將其大聲呈現,而不是用剛好可以編譯的算術來掩蓋它
失敗絕不能跨越邊界
讀取可能會失敗;串流可能是逾時的網路備份物件、在下方被關閉的 blob 控制代碼,或是檔案開啟後被截斷的檔案;PDFium 對於讀取回呼的協定是一個傳回值:非零代表成功,零代表失敗;它是一個 C 訊框,且它沒有捕捉或傳播 Pascal 異常的機制
這就是為什麼彈簧床(Trampoline)將定址和讀取包裝在 try/except 中,該結構會吞掉異常並傳回零;如果允許 Delphi 異常從回呼中傳播出去,它將透過 PDFium 的 cdecl 堆疊訊框展開,而這些訊框從未設計用於被 Pascal 異常機制展開;結果最壞是未定義的行為,最好是嚴重的崩潰(深藏在 PDF 解析器內部且沒有可用的堆疊);傳回零使失敗保持在協定範圍內;PDFium 看到失敗的資料塊讀取,乾淨地放棄操作,且 FPDF_LoadCustomDocument 回報無法載入檔案,元件在它所屬的 Pascal 側將其呈現為 EPdfError
以此方式開啟檔案
驅動串流路徑的元件方法是 LoadCustomDocument,它被宣告為獨立的方法,而不是另一個 LoadDocument 多載,這樣傳遞 TMemoryStream 就不會意外落在阻礙路徑上;它建立配接器、呼叫 FPDF_LoadCustomDocument,並在載入之檔案的生命週期內保持配接器活動
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;
相同的呼叫適用於 TMemoryStream、資料庫資料集中的 blob 串流,或自訂的 TStream 子代;當檔案很大且僅會讀取其中一部分時,按需載入就發揮了價值:封存檢視器、取樣數個頁面的縮圖產生器、一次提取一頁的搜尋索引;當檔案很小或者無論如何您都要讀取其全部內容時,緩衝載入更簡單,且串流機制不會帶給您任何好處;決定因素是您實際會觸及的位元組與檔案包含的位元組之比例
一旦頁面按需進行串流,下一個要關注的問題是在使用者縮放和捲動時保持轉譯頁面的回應速度,這在我們關於轉譯快取和縮放效能的筆記中有所討論;當串流檔案是檢視器應該顯示但不允許使用者匯出或變更的檔案時,安全 PDF 預覽逐步說明中的技術可以與此載入路徑自然結合;兩者都建置在此處所述的串流載入之上,這些載入作為 Delphi 和 C++Builder 的 PDFium 元件的一部分隨附,並與本部落格其他地方介紹的轉譯、文字擷取和註釋 API 搭配