技術文章

PDFlibPas 在 Delphi 的漸進式 PDF 範圍載入

一個 2 GB 的掃描封存檔放在 S3 bucket 裡,使用者要第 900 頁。PDFlibPas 可以不下載整個檔案就交出那一頁:LoadFromRangeSource 在您自己的位元組範圍回呼之上建構一個唯讀可定位串流,交給 TPDFDocument,於是解析器只取回交叉參照表、頁面樹的一條分支,以及一個內容串流

傳輸那一側是老掉牙又無聊的東西。HTTP 伺服器宣傳位元組範圍已有數十年,現在規範在 RFC 9110 §14,而每個物件儲存服務都講同一種方言。PDF 這一側同樣塵埃落定:ISO 32000-1 §7.5.8 定義線性化,正是為了讓閱讀器能從檔案前端算繪第一頁。Delphi 一直缺的是中間那塊——決定要請求哪些範圍、保留多少、以及如何避免重複請求的部分

LoadFromRangeSource 需要您的傳輸層提供什麼?

兩樣東西,而且都不是串流。PDFlibPas 要求一個權威的 SourceSize 與一個型別為 TPDFlibRangeReadEvent 的同步讀取回呼,宣告為 function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object。這一對在內部變成暴露 SourceSizeReadRangeTCallbackByteRangeSource,包進一個所有權移交給文件的串流。您的回呼目標及其後端仍是您的:文件在關閉、清除或重新載入時釋放包裝器,但絕不觸碰方法指標背後的傳輸物件

這份契約在一個方向上刻意寬容,在另一個方向上嚴格。短讀取是合法的,單純意味著解析器會再問一次。拋出例外的回呼會被轉換成短讀取,經由正常的載入失敗路徑收斂。宣稱寫入超過 Count 位元組的回呼會被箝制,因為有臭蟲的提供者絕不能溢出快取緩衝區。密碼重試會在同一個回呼來源上重建全新的範圍串流與全新的解析狀態,所以失敗的嘗試不會留下陳舊的位置、視窗或解密狀態

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { 一次阻斷式 GET,帶 Range: bytes=Offset-(Offset+Count-1) }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { 釋放包裝器串流 }
  Src.Free;  { 你的傳輸層,你的生命週期 }
end;

範圍快取實際上裝多少?

預設 4 MiB,分散在按區塊對齊的視窗上,以 LRU 逐出。早期的單視窗設計會長到呼叫方要求的任何長度,所以一次大型循序讀取可以衝過名目區塊大小,而一次隨機跳轉又立刻丟掉前一個視窗。目前的快取把每個來源偏移對齊到 ChunkSize,每次 miss 恰好取回一個區塊,並跨多個視窗強制硬性位元組預算。您傳入的任何明確預算都會被提高到至少一個完整區塊,所以單次讀取永遠逐區塊推進,快取峰值負載保持可預測。低於 4096 的 ChunkSize 退回 64 KiB 預設值

PDFlibPas 如何在 Delphi 中不下載 PDF 就供應解析器讀取:絕對偏移向下對齊到區塊大小,命中時由多個 LRU 視窗之一供應,未命中時轉成單次被箝制的回呼呼叫
每個來源偏移都對齊到區塊大小,所以一次 miss 恰好取回一個區塊,快取峰值負載保持可預測

重複讀取計量是值得接進您遙測的部分。PDFlibPas 以對齊的區塊起點識別重複,並維護有序的連續區間,這把真正的首次擷取與逐出後的重取分開,同時讓簿記不隨檔案大小線性增長。GetRangeSourceCacheInfo 以 JSON 回傳全貌,SetRangeSourceCacheLimit 在執行期調整預算,ClearRangeSourceCache 連同統計一起丟棄視窗。在執行期縮小預算會保留歷史,並把預算驅動的釋放計為逐出,所以 repeatedReads 上升而 hits 持平,就是工作集已裝不下的訊號

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount"、"cacheLimitBytes"、"cachedBytes"、"hits"、"misses"、
      "evictions"、"sourceReads"、"sourceBytes"、"repeatedReads"、
      "coalescedRequests"、"coalescedSourceReads" }
    LogRangeStats(Info);
end;

當多個執行緒要同一個區塊時會發生什麼?

它們等待同一個請求,而不是各發各的。傳統 TStream 只有一個位置游標,兩個各自正確上鎖的執行緒仍可能在 SeekRead 之間被改寫位置,所以 PDFlibPas 的延遲物件與分段讀取使用永不移動游標的絕對式 ReadAt。每個對齊區塊只有一個進行中請求,要該區塊的每個呼叫方共享它;佇列中相鄰的區塊在來源讀取開始前合併;單次實體讀取以 16 MiB 封頂,所以一波平行頁面工作既不會放大成重複的小請求,也不會放大成一個離譜的大請求。合併視窗預設 2 ms,只適用於每個 ReadAt 的第一個缺失區塊;位置式 Read 永不等它,而傳入零會完全移除初始收集延遲——這對否則會逐區塊累積等待的長循序掃描很重要。位置、快取中繼資料與來源讀取分別由三道獨立的鎖保護,而來源回呼本身被序列化,這正是沒有內部執行緒保護的資料庫或物件儲存配接器可以原樣使用的原因。等待者各自拿到一份資料複本,所以之後的 LRU 逐出不可能讓已交出的緩衝區失效

PDFlibPas 範圍載入中的請求合併:兩個要同一區塊的執行緒共享一個進行中請求,佇列中相鄰的區塊在兩毫秒的視窗內合併,一次序列化的來源讀取服務全部
一波平行頁面工作收斂成每個區塊一個共享請求,而每個等待者仍拿到自己的位元組複本

能不取資料就問第 900 頁是否就緒嗎?

可以,這正是選配的可用性回呼存在的原因。普通讀取回呼無法區分已經落地的位元組與需要阻斷式往返的位元組,而用試探性讀取來探測會觸發您正想避免的那次下載。TPDFlibRangeAvailabilityEvent 只回答一個問題——一個完整範圍能否立即被讀取——且被禁止擷取任何東西;快取已涵蓋的位元組永遠算可用。GetRangeSourceDataAvailability 把間接物件對應到交叉參照項目記錄的實體儲存範圍,把壓縮物件解析到其所屬的物件串流容器,修正位移過的 PDF 標頭,並且只在完整範圍通過不取資料的探測後才解析物件,所以缺失路徑絕不呼叫您的讀取回呼

走訪是有範圍的,而非窮舉。頁面查詢只走訪包含目標頁面的頁面樹分支,然後加上頁面內容、資源、註解與繼承的頁面屬性,跳過 ParentP 反向邊,所以單一頁面或 widget 不會向後膨脹成整份文件。物件圖以 100000 個請求物件與 256 層深度為界,串流物件採字典優先解析,而完整解析的後備只允許用於 4 MiB 以內的已儲存物件。JSON 報告在計數前合併重疊與相鄰區間,所以 requiredBytesmissingBytes 由合併後的 requiredRangesmissingRanges 陣列算出,其 end 是含端點值。查詢一個已可用的物件可能會填入範圍快取;查詢一個缺失的物件則不動讀取統計

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { Report 帶有 "missingBytes" 與合併後的 "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { 例如該檔案根本沒有 AcroForm }
end;

為什麼預取必須迭代

因為讀一次當前的 missingRanges 並不會讓頁面變得可用。缺失的頁面樹節點或物件串流要等它抵達,才會揭露下一層相依,所以 PDFlibPas 預取工作跑一個查詢、擷取、再查詢的迴圈,直到頁面、表單或物件圖完全可用,或位元組或回合上限叫停。該工作使用自己的讀取器與一個小型次級快取,其資料來源把絕對讀取轉發給原始範圍串流,這讓解析狀態與前景的 TSmartPDFReader 隔離,而它真正下載的位元組仍落進共享的主快取。每個範圍串流存在一個工作執行緒,與來源回呼本來就要求的序列化一致,佇列先按四個優先等級、再按同級內的提交順序挑選。MaxBytes 以實體區塊位元組計費,所以請求未快取區塊內單一位元組的解析器仍要付整個區塊的費用,而已在共享快取中的區塊不花這個工作任何東西。取消一個排隊中的工作會以零次來源讀取抵達終態;執行中的工作在每個相依回合與每個來源區塊之前被檢查;釋放範圍串流會等待進行中的回呼返回,而不是試圖打斷它

PDFlibPas 在 Delphi 中的預取迴圈:工作查詢可用性、擷取缺失範圍、再查詢,因為每個抵達的頁面樹節點或物件串流會揭露下一層相依,直到圖完整或上限叫停
預取工作之所以迭代,是因為缺失節點要等抵達才說得出自己的子節點,且每一回合都以整個實體區塊計費
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes"、"plannedRanges"、"sourceReads"、"fetchedBytes" 與最後一份
    完整可用性報告,所以 LIMIT_REACHED 與 FAILED 保持可區分 }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

這在哪裡退化為整檔下載

範圍載入是對檔案配置的賭注,而有些檔案不兌現。依 ISO 32000-1 §7.5.8 線性化的檔案是好情況:第一頁區段在開啟時預熱,受既有的 4 MiB 安全門檻與當前快取預算雙重約束,所以預熱不會立刻把自己大半逐出。非線性化檔案仍經由檔尾附近的 trailer 與交叉參照鏈解析,代價是多幾次往返而非災難。真正的懸崖是逼出修復路徑的受損檔案,因為重建交叉參照表意味著在整份文件裡掃描物件標頭,而那是一次逐區塊抵達的完整下載。延遲是另一個誠實的上限:每次請求 60 ms,需要四十個未快取區塊的隨機存取解析,無論快取多好都要花超過兩秒在傳輸上,而這正是預讀引數與優先佇列存在所要隱藏的。同樣的紀律也出現在合併與拆分大型 PDF 的直接存取做法中,而這個快取同時墊在平行頁面算繪檢視器磁碟頁面快取底下

範圍來源 API、可用性查詢與預取排程器都是標準版 PDFlibPas Delphi PDF Library 的一部分,適用於 Delphi、C++Builder 與 Free Pascal;產品頁收錄 LoadFromRangeSource 的完整參數參考,以及預取優先等級與狀態常數