技術文章

在 Delphi 用 PDFium 產出 Fast Web View 的線性化 PDF

PDFium Component 透過 TPdf.SaveAsLinearized 從 Delphi 產出 Fast Web View(線性化)PDF。該方法先用 PDFium 存出目前文件,再按 ISO 32000-1 附件 F 改寫結果:第一頁物件挪到檔頭、寫出兩段交叉參照區,頁偏移與共用物件提示表則告訴檢視器,任何一頁該抓哪些位元組範圍

大家在乎的原因是網路。從物件儲存或文件入口服務的 40 MB 型錄,應該在前幾 KB 到手後就顯示第一頁;跳到第 380 頁應該只花幾個 HTTP Range 請求,而不是整份下載。檔案必須線性化,檢視器才做得到。離開編輯管線的大多數 PDF 都不是,因為寫 PDF 的自然方式就是把交叉參照表放在檔尾,頁物件散落在寫出器恰好輸出的位置

PDFium 為什麼讀得了線性化檔案、卻寫不出來?

PDFium 有完整的線性化讀取器,卻沒有對應的寫出器。FPDFAvail_* 可用性 API 偵測線性化字典、解碼提示表、並向宿主精確要它需要的位元組範圍,PDFium Component 的漸進下載與取消流程正是建立在這之上。公開的存檔 ABI 卻只提供普通複製存檔與指定目標 PDF 版本的存檔,裡面沒有任何東西會重排物件或發出提示流

PDFium Component 補上這個缺口,但不重做 PDFium 已經做得很好的部分。TPdf.SaveAsLinearized 先呼叫既有的 SaveAs,語意層的存檔仍歸 PDFium:編輯過的頁面、表單欄位狀態與版本處理,全部走那條錘鍊多年的程式碼路徑。接著元件對那份穩定的中間 PDF 跑一遍附件 F 的重組,把線性化結果寫到您的目標。工作這樣拆分,意味著線性化永遠不必懂註解或表單,只要懂物件、參照與位元組偏移

從 Delphi 呼叫 TPdf.SaveAsLinearized

TPdf.SaveAsLinearized 有兩個多載,一個收 TStream、一個收檔名,兩者都帶與 SaveAs 相同的可選參數 Option: TSaveOption 與 PdfVersion: TPdfVersion。成功都回傳 True。檔案多載先寫到目標目錄裡的暫存檔,再原子性地替換目的地,所以存檔失敗絕不會在一個好檔案的位置留下一份寫了一半的 PDF

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'catalog-2026.pdf';
    Pdf.Active := True;
    // ... 編輯頁面、填表單、蓋章,管線愛做什麼做什麼 ...
    if not Pdf.SaveAsLinearized('catalog-2026-web.pdf', saNoIncremental, pv17) then
      raise Exception.Create('Linearized save failed');
  finally
    Pdf.Free;
  end;
end;

參數規則刻意嚴格。saIncremental 會丟錯誤,因為增量更新把新修訂接在檔尾,立刻毀掉前置式版面。低於 PDF 1.2 的目標版本同樣被拒;pvUnknown 保留來源版本。加密輸入需要 saRemoveSecurity,因為寫出器要重新編號物件,而 PDF 物件層級的加密金鑰是從物件編號推導的。線性化又是整份改寫,任何既有數位簽章因此失效。若必須交付「又簽章又線性化」的檔案,先線性化、再簽章

寫出器怎麼為 Fast Web View 分組物件

線性化不只是把第一頁挪到檔案頂端。寫出器解析目前的修訂——交叉參照資料是傳統表、混合檔、還是交叉參照流都一樣——並把作用中物件串流裡的每個成員展開成獨立物件。接著建立參照圖,把物件分成五組:開檔所需的文件層級物件、第一頁物件、後面某一頁私有的物件、多個後面頁面共用的物件,以及其餘一切

這趟圖走訪的陷阱是 /Parent。每個頁面字典都往上指回頁面樹,天真的走訪會順著指標到父節點,再沿 /Kids 鑽進每一個兄弟頁與其內容流。一步之後,第一頁組就裝下了整份文件,檔案在技術上線性化了、對範圍優先存取卻毫無用處。PDFium Component 無視頁面的 /Parent 連結,也無視正在收集的頁面之外的頁面樹節點,第一頁區段才得以保持嬌小。在一個 51 頁、頁面樹很深的迴歸樣本上,第一頁區段是 5289 位元組,佔 1105354 位元組輸出的 0.48%

輸入裡的物件串流與交叉參照流會被攤平成普通的 generation 0 間接物件,每個參照都改寫成新編號,輸出使用傳統交叉參照區。與壓縮物件串流相比,這要多付一些檔案大小,但它簡化了提示表的覆蓋計算,而且這是所有懂線性化的檢視器都處理得了的版面。如果某個輸出裡緊湊比 Fast Web View 更重要,PDFium Component 另有專職的物件串流寫出器

線性化字典與兩段 xref 區裡放了什麼?

PDFium Component 寫出的線性化檔案,帶兩段傳統交叉參照區,線性化字典就放在第一個物件裡。第一段 xref 區緊跟線性化字典,覆蓋文件層級與第一頁物件,所以檢視器光憑檔頭就能算繪第一頁。主 xref 區坐在檔尾,覆蓋其餘一切

線性化字典本身裝著檢視器在讀任何東西之前需要的數字:/L 檔案長度、/H 提示流偏移與長度、/O 第一頁物件編號、/E 第一頁區段的結尾、/N 頁數,以及 /T 主 xref 表第一個條目的偏移。這些全都取決於最終版面,字典卻坐在版面之前。用自然寬度的數字寫它會形成迴圈:長度從 999999 漲到 1000000 多一位數,後面每個偏移跟著移位,某個數字的寬度可能因此改變,如此往復

PDFium Component 為線性化字典保留固定的 320 位元組來打破這個迴圈。所有物件、提示流、兩段 xref 區與 trailer 都先寫完;所有偏移定案之後,字典就地填入並補白到保留寬度。一個微妙細節是 /T:它必須指向主 xref 表的物件 0 條目,而不是它前面那個 xref 關鍵字。嚴格的驗證器會標記關鍵字偏移,就算寬容的檢視器照樣開得了檔

提示表的偏移為什麼無視提示流?

線性化 PDF 裡的提示表偏移,解讀時視同檔案裡沒有提示流。附件 F 就是這樣定義的,讓提示流可以坐在第一頁區段與其餘部分之間,而它自己的大小不必回饋進它包含的數字。對寫出器來說,實務後果很容易搞錯:任何位於提示流之後的真實偏移,進頁偏移或共用物件提示表之前都得先扣減

問題是扣多少。誘人的答案是流資料的長度,因為那一段感覺才像提示內容。正確答案是整個提示間接物件的長度,包括 N 0 obj、串流字典、stream 與 endstream 關鍵字,以及 endobj。只扣資料長度,之後每個偏移都差幾十位元組。第一頁照樣渲染,因為它住在提示流之前,所以這個 bug 熬得過快速目檢,只在檢視器跳到後面某頁、請求的範圍正好落在錯誤物件中段時現形

兩張提示表都以最高有效位在前的位元組裝(bit-packed),同樣依附件 F。元件用真實客戶端的方式驗證結果:稀疏範圍儲存只供應 51 頁樣本的前 1 KiB,其餘由 PDFium 原生的提示處理來要。最後一頁照樣打開,全程完整檔案從未存在

在 Delphi 裡驗證線性化 PDF

最便宜的檢查是直接問 PDFium。TPdf.InspectAvailability 不載入文件、直接對串流跑可用性 API,其 Linearization 欄位回報 plnLinearized、plnNotLinearized 或 plnUnknown

function IsFastWebView(const FileName: string): Boolean;
var
  Stream: TFileStream;
  Info: TPdfAvailability;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Info := TPdf.InspectAvailability(Stream);
    Result := Info.Linearization = plnLinearized;
  finally
    Stream.Free;
  end;
end;

這確認了 PDFium 認得這個版面;卻證明不了提示是對的。真正的端到端測試,是把檔案放進限制範圍的傳輸層,用 TPdfProgressiveDocument 打開靠後的頁面,就跟用 PDFium VCL 按需串流大型 PDF 指南做的一樣。如果只抓了檔案的一小部分頁面就渲染出來,提示表就是在幹活。發行管線裡再加一個獨立的線性化檢查器也值得,各檢視器對放錯位置的 /T 或差一點的偏移寬容程度不同

兩個限制要記在心上。物件實體化器仍然拒絕 2 GiB 以上的陣列,極大文件目前得另尋策略。線性化只有伺服器願意履行 HTTP Range 請求時才有回報;躲在剝掉 Accept-Ranges 的代理後面,線性化檔案的下載方式跟其他檔案毫無二致

FAQ

TPdf.SaveAsLinearized 保得住數位簽章有效嗎?

保不住。TPdf.SaveAsLinearized 重新編號、改寫每一個物件,既有簽章因此失效。先線性化,再套簽章

PDFium Component 能線性化加密的 PDF 嗎?

只有在您傳 saRemoveSecurity 時才行。物件層級加密金鑰依賴物件編號,而寫出器會改編號,所以輸出是不帶加密寫出的

線性化輸出支援哪些 PDF 版本?

TPdf.SaveAsLinearized 接受 pvUnknown 保留來源版本,或 PDF 1.2 以上的任何目標版本;拒絕 saIncremental,因為追加的修訂會破壞版面

Fast Web View 輸出、漸進載入與可用性 API,都是 PDFium Component for Delphi and C++Builder 的一部分;它為 Delphi、C++Builder 與 Lazarus 專案封裝 PDFium