技術文章

Delphi 中 PDFium 對內嵌 PDF 的位元組範圍載入

PDFium Component 可以直接從一個位元組範圍,開啟一份存在於更大緩衝區裡的 PDF。多載函式 LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean) 就地定址一個視窗,不需要事先 Copy。作為交換,它要求你理解一條規則:當 BufferedFalse 時,底層陣列是被借用的,不是被複製的

這與用 PDFium VCL 按需串流大型 PDF裡描述的回呼驅動做法是不同的機制,那個做法交給 PDFium 一個 FPDF_FILEACCESS 讀取器,讓它按需從磁碟拉取區塊。那個做法是給大到根本無法整份放進記憶體的文件用的。這一個是給已經在記憶體裡、位於某個已知偏移量、藏在別的東西裡的文件用的。兩者互補,最後一節會說明哪種情境該用哪一個

沒人要求的那次 40 MB 複製

這個情境出現在 PDF 隨其他格式一起旅行的每個地方。一個郵件儲存區把郵件正文與附件存進同一筆紀錄。一個封存容器把清單、幾張圖片和一份 PDF 串接在一起。一個自訂的線路協定把一份文件框在一個帶長度前綴的標頭後面。每種情況下,你最終都會拿著一份大型的 TBytes,並且知道 PDF 從第 1,182,336 個位元組開始,長度為 312 KB

在這個位元組範圍多載出現之前,慣用的答案是 Copy(Data, Index, Count),這會配置第二個陣列,把視窗範圍 memcpy 進去。接著你把這個切片,以 Buffered = True 交給 LoadDocument,它又會把它複製第二次,進到元件私有的緩衝區裡。同一份位元組被複製了兩次,其中一次純屬儀式性動作,而且在一次大型信箱掃描中,每則訊息都會重複一次。位元組範圍多載無條件移除了第一次複製,並讓第二次成為可選

位元組範圍多載實際上做了什麼

這個多載函式設計得很薄:它做驗證、計算一個指標,然後委派給整個家族早已匯聚進去的、以指標為基礎的 LoadDocumentIndex 以零起算,Count 是一個位元組長度,Buffered 預設為 True,與其他多載完全一致。單一引數版本的 LoadDocument(const Data: TBytes; Buffered: Boolean) 本身現在只是以 Index = 0Count = Length(Data) 呼叫這個版本,因此只有一條驗證路徑,而不是兩條

呼叫方式看起來就跟你原本會寫的程式碼一樣,只是少了切片這一步

var
  Frame: TBytes;          // whole container record, tens of megabytes
  Offset, Size: Integer;
begin
  Frame := LoadContainerRecord('mailbox.dat');
  LocateEmbeddedPdf(Frame, Offset, Size);   // your container parser

  // No Copy(Frame, Offset, Size) here - the window is addressed in place
  Pdf.LoadDocument(Frame, Offset, Size, True);
  try
    RenderPreview(Pdf);
  finally
    Pdf.UnloadDocument;
  end;
end;

為何 Index 加 Count 會讓邊界檢查溢位?

因為 IndexCount 都是 Integer,而兩個很大的正 Integer 值相加,不見得還是一個很大的正 Integer。這是這個多載函式的技術核心,也是唯一一個看起來自然的檢查其實是記憶體安全漏洞的地方。直覺的寫法是錯的

// WRONG: Index + Count is evaluated in Integer and can wrap negative
if Index + Count <= Length(Data) then
  DataPtr := @Data[Index];

// RIGHT: reject signs first, then bound each term separately,
// with the only arithmetic done as a subtraction that cannot wrap
Check(Index >= 0,  'PDF byte range index cannot be negative');
Check(Count >= 0,  'PDF byte range count cannot be negative');
Check(Index <= Length(Data), 'PDF byte range index exceeds data length');
Check(Count <= Length(Data) - Index, 'PDF byte range exceeds data length');

把失敗的案例算一遍。取 Index = 2000000000Count = 2000000000。它們真正的總和是四十億,但在 32 位元有號算術裡,結果會繞回恰好負 294,967,296。這個值明顯小於 Length(Data),所以錯誤的檢查通過了,@Data[Index] 被取到陣列外面很遠的地方,PDFium 被交給一個亂指的指標加上一個 2 GB 的長度。接下來,好的情況是一次存取違規,壞的情況則是悄悄解析了不相干的行程記憶體

正確的順序透過絕不相加來修正這一點。負值在任何索引動作之前就被拒絕,所以 @Data[Index] 絕不會被取到陣列下界之下。接著 Index 單獨對照 Length(Data) 設界,這保證了 Length(Data) - Index 是一個非負的 Integer。只有到這時,Count 才會與那個餘量比較。每個中間值都停留在可表示的範圍內,所以沒有任何建置組態能改變這個結果。也不要指望靠 {$Q+} 溢位檢查當作安全網:發行版建置通常會關掉它,即使開著,你也只是把一個記憶體安全漏洞,變成一個從驗證常式中間逃逸的 EIntOverflow。PDFium Component 對待不受信任長度算術的方式,與它對待其餘邊界的方式一致,這種紀律涵蓋於強化 PDFium VCL ABI 與 Delphi 中的記憶體安全一文

為何長度為零的視窗必須傳 nil?

因為 @Data[Index] 對驗證所接受的每個 Index 都不是一個合法的運算式。Index = Length(Data) 搭配 Count = 0 是一個完全合法、位於緩衝區尾端的空視窗,而一個空的 TBytes 上,Index = 0 卻是一個根本沒有第零元素的陣列。在這兩種情況下取位址,都是索引到末端之外,或者對一個 nil 動態陣列解參照。因此這個多載函式會分支:Count = 0 產生一個 nil 指標,其他任何數量產生 @Data[Index]。這個 nil 接著流進以指標為基礎的多載函式,它自己的防護在大小為零時會接受一個 nil 指標,載入就會以一個一般的「Cannot load PDF document」錯誤結束,而不是一次存取違規。一個呼叫端從一個格式錯誤的容器算出零位元組視窗,得到的是一個乾淨、可捕捉的 EPdfError,就像其他任何錯誤輸入一樣

借用還是複製:Buffered 決定的事

Buffered 選擇的是所有權合約,也是這裡唯一一個影響超出這次呼叫本身的參數。當 Buffered = True 時,PDFium Component 會在載入之前,把選取的視窗、且僅僅是那個視窗,複製進它內部的緩衝區。那份 40 MB 的容器不會被複製;那份 312 KB 的 PDF 會。LoadDocument 一回傳,你就可以立刻釋放、重用或覆寫這份容器,因為元件已經不再參照它。這是預設值,也是幾乎所有程式碼的正確選擇

Buffered = False 會把 @Data[Index] 直接傳給 FPDF_LoadMemDocument64,PDFium 會在整份文件的生命週期內保留那個指標,而不是複製位元組。這讓載入不需要任何分配,也讓整個底層的 TBytes 變成一項被借用的資源。它必須維持存活且未被修改,直到 UnloadDocument 執行,或 Active 變成 False。不是那個視窗,是整個陣列:一個動態陣列是以整體為單位參照計數的,你程式碼裡任何地方讓最後一個參照消失,都會釋放 PDFium 仍在讀取的記憶體。對它設定 Length 同樣致命,因為重新配置可能會搬動這個區塊。無論你在自己的 API 文件裡什麼地方公開這類載入方式,都該說明這一點,這與 Pascal 程式碼裡任何借用對擁有的邊界精神一致;這種失效模式,與Delphi 中 FillChar 與結果字串外洩一文所述的別名危害如出一轍,那裡一個看似被擁有的緩衝區其實不然

type
  TFrameSession = class
  private
    FFrame: TBytes;   // owns the backing storage for as long as FPdf is loaded
    FPdf: TPdf;
  public
    procedure OpenEmbedded(Offset, Size: Integer);
    destructor Destroy; override;
  end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  // Buffered = False: FFrame must outlive the loaded document
  FPdf.LoadDocument(FFrame, Offset, Size, False);
end;

destructor TFrameSession.Destroy;
begin
  FPdf.UnloadDocument;   // release the borrow first
  FFrame := nil;         // only now may the storage go
  inherited;
end;

位元組範圍視窗不是合適工具的時候

老實面對這個邊界。位元組範圍多載假設容器已經整份在記憶體裡,而 Count 是一個 Integer,所以單一視窗不能超過 2 GB。如果容器是磁碟上一個 6 GB 的封存檔,或是透過一個你無法倒帶的通訊端抵達的,這個多載函式幫不了你,把整份東西讀進 TBytes 只為了定址裡面的一個視窗,也違背了這麼做的初衷。這正是 FPDF_FILEACCESS 路徑該出場的地方,按需串流一文示範了如何把一份檔案的偏移位移視圖,公開成一個自訂的文件來源。同樣地,如果內嵌的位元組在交給 PDFium 之前需要轉換,例如解壓縮、解密、拆封步驟,那麼一次真正的複製就無可避免,對轉換後的陣列使用 Buffered = True 才是誠實的答案。位元組範圍視窗只在一種形狀下回本:連續、未經修改的 PDF 位元組,已經常駐記憶體,位於已知的偏移量

如果你正在為檢視器、預覽窗格或批次匯入管線評估這個功能,位元組範圍多載與串流式載入器,是 PDFium Component 隨檔案、串流與原始指標載入一併提供的兩種載入策略。完整的 API 介面、授權方式與 Delphi 及 C++Builder 版本支援,都記載在 PDFium Component 產品頁面