PDFium Component 透過 TPdfProgressiveDocument 開啟還在下載中的 PDF——那是 TPdf 的子類別,包著 PDFium 的 FPDFAvail_* 可用性 API。BeginProgressiveLoad 啟動工作階段,CheckDocumentAvailability 回報 PDFium 還缺哪些位元組範圍,OpenProgressiveDocument 在位元組夠了之後開檔,CancelProgressiveLoad 則放棄被中斷的下載,不漏任何原生控制代碼。難的從來不是正常路徑。在爛網路上,檢視器會看到使用者在 25% 時關掉分頁、又改變主意、再開同一個連結,而每一個被放棄的工作階段手上都有一個原生可用性控制代碼、兩筆 C 回呼記錄、一個串流配接器與一批還在路上的範圍請求,全部都得按精確的順序釋放
TPdfProgressiveDocument 怎麼載入還在下載中的 PDF?
隨機存取串流一點一點被填滿的同時,TPdfProgressiveDocument 讓 PDFium 的可用性提供者保持存活,每個解析步驟之前都先問它要的位元組在不在。BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) 收下背後的串流與遠端檔案的邏輯大小,把 IsDataAvail 回呼與 AddSegment 回呼接進兩筆記錄,然後呼叫 FPDFAvail_Create。PDFium 問某個範圍在不在時,若該範圍落在 AvailableByteCount 描述的連續前綴之內,或落在已透過 RangeRequests 排程器完成的範圍之內,元件就答「在」,而稀疏儲存可由 OnDataAvailable 事件推翻這個判定。CheckDocumentAvailability 每次呼叫回傳三個 TPdfDataAvailability 值之一(pdaAvailable、pdaNotAvailable、pdaError),並把 PDFium 要過的範圍以排序、合併後的 TPdfDownloadRanges 陣列交回來,且已按 rrpImmediate 優先權排在排程器上
// FetchRange 是您的傳輸層(HTTP Range GET、socket、blob 讀取器):
// 把 Size 個位元組寫進 Store 的 Offset 處,回傳實際送達多少
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;
procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
RemoteSize: UInt64);
const
MaxRounds = 64;
var
Hints: TPdfDownloadRanges;
State: TPdfDataAvailability;
Request: TPdfRangeRequest;
Round: Integer;
begin
Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
State := pdaNotAvailable;
for Round := 1 to MaxRounds do
begin
State := Pdf.CheckDocumentAvailability(Hints);
if State <> pdaNotAvailable then
Break;
// 提示已排入佇列;先寫位元組,再完成請求
while Pdf.RangeRequests.TryDequeue(Request) do
Pdf.RangeRequests.CompleteRequest(Request,
FetchRange(Store, Request.Offset, Request.Size));
end;
if State <> pdaAvailable then
raise EPdfError.Create('The document could not be discovered');
Pdf.OpenProgressiveDocument;
end;
那個迴圈裡有兩個細節是承重的。輪數上限不能省,因為死連結會讓 CheckDocumentAvailability 永遠要同樣的範圍,沒有上限的迴圈會把一次網路故障變成凍住的 UI。順序也不能錯,因為排程器用 critical section 保護自己的狀態,對背後儲存的 TStream.Position 卻什麼都不做:傳輸執行緒必須先把回應位元組寫進串流、再呼叫 CompleteRequest,因為完成一發佈,PDFium 隨時可能去讀那個範圍,而多個寫入者並存時得自己用定位 I/O 或另外上鎖
AvailableByteCount 為什麼拒絕往回走?
AvailableByteCount 只增不減,想把它調小,setter 就舉發 EPdfError,訊息是「Available byte count cannot move backwards」。IsDataAvail 回呼一旦告訴過 PDFium 某個範圍存在,解析器可能已經從那裡讀走並快取了物件,事後把那些位元組收回,可用性答案就與 PDFium 已經吃下的東西對不上了。同一個 setter 也拒絕大於 LogicalFileSize 的值,工作階段之外則舉發「No progressive load is active」,所以開始載入之前就握有的位元組,該走 BeginProgressiveLoad 的 AInitialAvailableByteCount 參數,而不是太早做的屬性指派。下載儲存若是亂序填滿的,別想用前綴表達它:範圍走排程器完成,或用 OnDataAvailable 回答
下載到一半的 PDF 什麼時候才真的開得起來?
只有線性化的 PDF(ISO 32000-1 附錄 F,「Fast Web View」版面)能在整個檔案到齊之前開啟;非線性化的 PDF 仍然每一個位元組都要。OpenProgressiveDocument 檢查 Linearization 屬性(plnUnknown、plnNotLinearized、plnLinearized)並據此分流:線性化檔案在首頁區段與提示表就位後,立即經 FPDFAvail_GetDocument 開啟;非線性化檔案則在同一份檔案存取記錄上經 FPDF_LoadCustomDocument 開啟,且只能當成整體來讀。這條分流有個具體的理由。對非線性化檔案呼叫 FPDFAvail_GetDocument,可能拿到一個非 null、頁數為零的控制代碼——一個看似開著、實則空空如也的文件。元件自己的測試套件裡,一份 51 頁的線性化夾具在稀疏下載儲存尚未蓋滿檔案時就到達 pdaAvailable,並帶著完整頁樹開啟
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
PageNumber: Integer): Boolean;
var
Hints: TPdfDownloadRanges;
Request: TPdfRangeRequest;
Round: Integer;
begin
Result := False;
for Round := 1 to 64 do
case Pdf.LoadAvailablePage(PageNumber, Hints) of
pdaAvailable:
Exit(True); // PageNumber 此時已是作用中頁面
pdaError:
Exit(False);
pdaNotAvailable:
while Pdf.RangeRequests.TryDequeue(Request) do
Pdf.RangeRequests.CompleteRequest(Request,
FetchRange(Store, Request.Offset, Request.Size));
end;
end;
LoadAvailablePage 收從 1 起算的頁碼,並強制 PDFium 期望的順序:第一次頁面檢查之前先跑 CheckFormAvailability——它包著 FPDFAvail_IsFormAvail——之後才呼叫 FPDFAvail_IsPageAvail。回傳 pfaNotPresent 是文件沒有 AcroForm 時的正常答案,不擋任何事。頁面就緒後,LoadAvailablePage 把它設為作用中頁面,所以檢視器可以在其餘頁面還在路上時,先渲染線性化手冊的第 1 頁;FirstAvailablePageNumber 告訴您線性化字典指定哪一頁當第一頁,已從 PDFium 的零基索引轉換過
CancelProgressiveLoad 釋放哪些東西、按什麼順序?
CancelProgressiveLoad 用四個不可重排的步驟拆掉一個工作階段:取消範圍排程器、關閉文件、用 FPDFAvail_Destroy 銷毀可用性控制代碼,最後釋放回呼記錄與串流配接器。先取消排程器會推進它的世代計數器、丟掉所有擱置與在途請求,並對每個在途請求觸發 OnCancelRequest,所以晚到的傳輸完成帶著舊世代,CompleteRequest 回傳 False、什麼都不碰。文件必須在可用性控制代碼與配接器消失之前關閉,因為 PDFium 關文件時可能回呼檔案存取提供者,配接器要是已經不在,那次回呼讀的就是已釋放的記憶體
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
FPdf := TPdfProgressiveDocument.Create(nil);
// 排程器與 FPdf 同壽,所以接一次就好
FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;
procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
Attempt: Cardinal);
begin
FTransport.Abort(RequestId); // 您的程式碼:關掉那個 socket 或請求
end;
procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
FPdf.CancelProgressiveLoad;
// ProgressiveLoading = False、Active = False、AvailableByteCount = 0
end;
這個方法是冪等的,也是三種情況唯一的清理路徑:BeginProgressiveLoad 建構到一半失敗、使用者明確取消、以及解構子。BeginProgressiveLoad 開始之前也會先呼叫它,所以同一個物件換個 URL 重啟不必先明確取消。所有權有一個決定要您自己拿對:如果工作執行緒會寫背後的串流,就傳 AOwnsStream = False,等執行緒停了再自己釋放串流,因為所有權一旦交出去,取消會立刻釋放串流,而遲到的寫入可能還在路上。OnCancelRequest 裡丟出的例外按請求各自吞掉,一個傳輸失敗擋不住其餘的取消
生命週期測試套件怎麼證明取消路徑不漏?
PDFium Component 的生命週期壓力套件在每個混合週期裡演練一次被中斷的網路式下載。每個週期啟動一個儲存區只放了四分之一夾具位元組的漸進載入,要求得到帶非空提示清單的 pdaNotAvailable,呼叫 CancelProgressiveLoad,並斷言物件不再回報 ProgressiveLoading 或 Active;接著用完整可用性把同一條串流路徑跑到底:OpenProgressiveDocument、渲染、關閉。預設混合跑涵蓋 100 個計測週期、600 次開啟、2300 次渲染與 100 次漸進取消,取樣的私用記憶體在 32 MiB 預算內成長 8.21 MiB。套件把漸進取消與渲染回呼取消分開計數,因為被放棄的下載與提前停止的渲染迴圈是兩種事件,驗收標準也不同
漸進路徑幫不上忙的地方
在這上面蓋檢視器之前,有幾個限制值得先知道。需要原始檔案位元組的功能,面對不完整的漸進來源寧可拒絕也不猜:ReadXmpPacket 明確失敗,簽章驗證在整個檔案到齊之前回報 Indeterminate。預設的可用性測試假設連續前綴,所以亂序抓範圍的傳輸層必須透過 RangeRequests 完成它們、或透過 OnDataAvailable 回答,否則 PDFium 會一直要您已經握有的位元組。非線性化檔案在首頁呈現時間上討不到任何便宜,首次繪製速度要緊的話,請在伺服器端把檔案線性化。還有,CancelProgressiveLoad 不會替您關 socket;OnCancelRequest 才是做這件事的掛鉤
按需載入完整本機檔案的普通串流配接器路徑,請看用 PDFium 按需串流大型 PDF;開啟嵌在更大緩衝區裡的 PDF,請看內嵌 PDF 的位元組範圍載入。取消已載入頁面的緩慢渲染是另一套機制,可取消的漸進頁面渲染一文有說明。TPdfProgressiveDocument 與它的範圍排程器隨 PDFium Component for Delphi and C++Builder 出貨