技術文章

Delphi 線性化 PDF 輸出:HotPDF 提示表

HotPDF 透過 THotPDF 上的 LinearizeOutput 屬性,寫出 Acrobat 標示為快速網頁檢視(Fast Web View)的線性化 PDF 檔案。在 BeginDoc 之前設定它,會讓 HotPDF 重新排序完成的物件圖,讓具備位元組範圍感知能力的閱讀器只需擷取檔案前段就能顯示第一頁,而不必先下載整份文件。這套機制就是 ISO 32000-1 附錄 F

這件事重要的原因並不光鮮。一份普通的 PDF 把交叉參照表放在最後,所以閱讀器必須抵達最後一個位元組才能知道任何東西在哪裡。把一份 200 頁的掃描報告丟給瀏覽器,即使使用者只想要第一頁,也得盯著轉圈圈直到整份文件傳完。線性化在寫入時付出代價來解決這個問題。這篇文章專門談的就是那條寫入路徑:切割、量測迴圈與硬性限制;至於快速網頁檢視能帶來什麼概念上的好處,先前的PDF 線性化與快速網頁檢視說明已有涵蓋

線性化版面實際上保證了什麼?

線性化檔案是一份普通的 PDF,只是帶有極為特定的實體排列順序,而它提供的每一項保證都來自這個排列順序本身,而非任何新的物件型別。HotPDF 依照附錄 F 規定的順序輸出各個部分:線性化參數字典在前 1024 位元組內、早期的交叉參照表、文件層級物件、主要提示串流、第一頁及其私有物件,接著是其餘頁面、然後是共用物件,再來是其他一切,最後是主交叉參照表

切割是推導出來的,不是宣告的。HotPDF 從每個頁面物件走訪參照圖,記錄每個間接物件被多少頁面參照到、以及最先參照它的是哪一頁。只被一頁使用的物件成為該頁的私有物件。被超過一頁參照的物件成為共用物件。目錄,加上它在 /ViewerPreferences/OpenAction/Threads/AcroForm 下參照的任何東西,再加上啟用保護時的加密字典,共同組成必須排在最前面的文件層級群組。頁樹節點會被刻意保留在後面,以免污染第一頁區段

參數字典帶著閱讀器在讀到其他任何東西之前就需要的數字:/L 是檔案總長度,/H 是提示串流的偏移量與長度,/O 是第一頁的物件編號,/E 是第一頁區段結束的位元組位置,/N 是頁數,/T 是主交叉參照表項目的偏移量。這其中每一項都是指向一份此刻根本還不存在的檔案的位元組偏移量

為何提示表偏移量必須收斂?

因為參數字典裡的數字描述的正是包含它們自己的那份檔案,改動其中任何一個就會改動這份檔案。這正是線性化寫入器的核心難處,也是為何 HotPDF 反覆量測而不是只寫一次的原因。把 /T 從 6 位數擴到 7 位數,參數字典就會多一個位元組;標頭跟著變大;每個物件都位移;主交叉參照表跟著移動;/T 現在又需要一個不同的值。版面必須先抵達一個固定點,才能提交一個位元組的真正輸出

HotPDF 用一種有界迭代來處理這件事。它先把每個物件序列化進一個只記錄長度、不保留位元組的計數串流,讓每個物件都有一個已知的序列化大小。接著它跑一趟版面配置,為文件層級群組、提示串流、第一頁群組、後續頁面群組、共用群組與其餘部分指派偏移量,並回報主交叉參照表落腳的位置。這個結果會被回饋成下一輪的輸入。迴圈上限是八次嘗試,不收斂就會丟出例外,而不是產生一份帶著看似合理、實則錯誤偏移量的檔案

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

有兩個細節讓這個迴圈不至於亂晃。參數字典被寫進一個固定的 384 位元組欄位,以空格補滿,所以它自己的成長絕不會讓版面失去穩定;如果字典文字真的超出這個保留量,HotPDF 會丟出例外,而不是悄悄讓一切位移。收斂之後,HotPDF 還會再跑一趟確認用的版面配置,重新檢查提示串流長度,因為提示串流本身編碼的偏移量,要等版面確定之後才知道。所有這些量測換來的回報,是 HotPDF 從不緩衝第二份文件副本:一旦偏移量固定下來,物件就直接序列化進目的地串流,並在每個區段邊界斷言寫入的位元組數與承諾的偏移量一致

從 Delphi 開啟它

API 表面只有一個布林值,唯一的要求是必須在產生開始之前設定它。LinearizeOutput 預設為 False,版面配置的那趟過程在文件寫出時執行,所以在 EndDoc 之後才設定它不會有任何效果

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

有一項部署上的顧慮凌駕於程式碼面的一切之上。線性化只有在傳輸端支援 HTTP 範圍請求時才會有回報。若從一個整份串流輸出的端點提供同一份檔案,或用忽略 Range 的 CDN 設定,你買到的就只是一條較慢的寫入路徑與一份較大的檔案,卻沒有使用者能感受到的好處。在檢查程式碼之前先檢查伺服器

為何線性化會覆寫 UseXRefStream 與 UseObjectStreams?

因為線性化寫入器需要每個物件都有自己可直接定址的位元組偏移量,而這兩項功能都會拿走這一點。因此只要啟用 LinearizeOutput,HotPDF 就會輸出傳統的文字交叉參照表與未打包的間接物件,即使呼叫端也設定了 UseXRefStreamUseObjectStreams。這是刻意的覆寫,不是需要你自行解決的衝突

這個道理從提示表推導而來。一個提示表描述的是某個頁面區段從哪裡開始、有多長,好讓閱讀器能精準要求那段範圍。一個被打包進 /ObjStm 容器的物件根本沒有獨立的偏移量;它只是另一個必須整體擷取並解壓的壓縮串流裡的一個切片。如果你原本指望物件串流來縮小檔案大小,要理解線性化與壓縮在這裡是往相反方向拉扯,這個取捨可以在HotPDF 中物件串流與增量更新的姊妹篇裡讀到。同樣的張力也塑造了混合參照檔案,這類檔案存在的目的正是讓較舊的閱讀器能與基於串流的表格並存,涵蓋於Office 產生 PDF 中的混合交叉參照串流一文

還有一個版本下限。線性化需要 PDF 1.2 或更新版本。若選取的版本較舊,HotPDF 會自動提升版本,除非設定了 StrictVersionLock,此時寫入會丟出例外,而不是悄悄提升一份你刻意釘住的文件

4 GiB 高牆,以及為何 HotPDF 選擇拒絕而非截斷

線性化提示表把偏移量存成 32 位元值,所以一份線性化檔案無法定址到 4 GiB 或以上的任何內容,HotPDF 會對這類輸出丟出明確的例外,而不是寫出一份帶有繞回偏移量的檔案。這個上限不是 HotPDF 的實作選擇,而是附錄 F 定義欄位的寬度

這項檢查套用在三個地方,三處都很重要。HotPDF 在每個物件的序列化長度確定後會驗證它,在建構提示項目時驗證每個頁面區段的長度,並在主交叉參照表定案後驗證最終檔案長度。及早失敗正是重點所在:一個帶著悄悄截斷偏移量的提示表,會產生一份在整份下載的閱讀器裡開起來沒問題、卻只對線性化原本要服務的位元組範圍用戶端失效的檔案,這是最糟糕的失效方式,因為你的測試用閱讀器永遠不會重現它。如果你要產生數 GB 的輸出,線性化不是合適的工具,大型 PDF 工作流程的 Direct File API 筆記所描述的串流方式才是該看的方向

偵測已載入檔案是否為線性化

THotPDF.IsLoadedLinearized 回報目前載入的文件是否早已以線性化形式寫入,而它的答案來自剖析之前拍下的快照,而非即時的串流。HotPDF 從來源串流位置零讀取前 1024 個位元組,掃描其中的第一個 obj 關鍵字,接著掃描值為 1 的 /Linearized 項目,並快取這個布林結果

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

這段描述裡有兩個限制條件是關鍵所在。偵測不能依賴串流位置,因為等到應用程式碼問這個問題時,剖析器早已把它移動過;也不能按需重新讀取,因為 LoadFromFile 一旦載入完成就會釋放內部的來源串流。因此設計成先擷取、再剖析、再快取。這個掃描對值本身也刻意寫得很嚴格:只接受 /Linearized 1,或分數部分全為零的數值等價形式,因為一份參數字典寫著別的東西的檔案,並沒有做出附錄 F 的承諾

一個值得學起來的 Delphi 記錄陷阱

含有動態陣列的本地記錄,只會初始化它的受控欄位,別無其他。如果你在陣列旁邊保留一個普通的 Count 欄位,就必須自己清空它。這在開發期間咬過線性化的切割邏輯,而它之所以要花上一天才能抓到,正是因為某個平台把這個問題藏了起來

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

動態陣列欄位是參照計數的,所以編譯器會把它歸零。旁邊的 Count 卻是個普通整數,沒有這種保證,一個未初始化的 Count 會把第一次附加動作送到一個任意索引上。在 Win32 底下,堆疊插槽恰好是零,附加動作恰好落在索引 0,每項測試都通過。在 Win64 底下,同一段程式碼卻寫超出了陣列末端。這個教訓遠遠超出線性化本身的範疇:當一筆記錄混雜了受控與非受控欄位時,指派 Default(TRecord),別再去推敲編譯器覆蓋了哪些欄位,也絕不要把一次綠色的 Win32 測試結果,當成初始化正確的證據

這裡描述的 LinearizeOutputIsLoadedLinearized 成員,隨標準版 Delphi 與 C++Builder 用 HotPDF Component 一併出貨;產品頁面收錄完整的屬性參考,包括與交叉參照串流、物件串流及版本鎖定的互動規則