線性化 PDF 的第一個物件必須寫明它所在檔案的精確長度,精確到位元組,而且必須出現在檔案前 1024 個位元組之內。ISO 32000-1 附錄 F 把這個條目叫 /L,並指示讀取端一旦發現它與實際檔案大小不符,就把文件當成普通 PDF 處理、無視裡面所有的線性化提示。所以寫入端必須在還沒寫完檔案的時候,就印出檔案長度——而且就印在提示串流偏移量、第一頁結尾、以及靠近檔案末端的交叉引用表位置旁邊。只要前面任何東西改變大小,這四個數字全都會動
回報是 Fast Web View。普通 PDF 把交叉引用表放在檔尾,瀏覽器拿到一份 200 頁的掃描報告,得等到最後一個位元組抵達才找得到第 1 頁;線性化檔案把第 1 頁和一小段交叉引用搬到檔頭,使用 HTTP range 請求的用戶端就能提早畫出第一頁。以下是寫入端的故事。讀取端的全貌在PDF 線性化與 Fast Web View 解說一文
HotPDF 如何佈局線性化檔案
當 THotPDF.LinearizeOutput 為 True 時,HotPDF 寫出線性化輸出,檔案按附錄 F 規定的物理順序逐部分排列。標頭之後是線性化參數字典,然後是第一頁交叉引用節與它的 trailer、目錄與其他文件層級物件、主提示串流、第一頁及其所需的所有物件、其餘頁面按序排列、後續頁面共用的物件、其餘一切,最後是主交叉引用表
文件模型裡沒有任何東西說明物件屬於哪個部分。寫入端在 EndDoc 儲存時從引用圖推算:從每個頁面物件出發走訪,統計每個間接物件被多少頁觸及。只被一頁觸及的物件進入該頁的分節;被多頁觸及的物件是共用物件,但有一個粗讀規範會漏掉的轉折:當第 1 頁也在使用它的頁面之中時,該物件仍然進入第一頁分節,因為第 1 頁必須只靠開頭的位元組完成渲染。只有純粹在後續頁面之間共用的物件才在共用部分等待。目錄(無論其 /ViewerPreferences、/OpenAction、/Threads、/AcroForm 條目指向什麼)與受保護文件的加密字典構成文件層級群組。頁面樹節點則被推進尾部部分,免得撐大第一頁
用十六進位編輯器打開輸出,物件編號看起來是反的。從線性化字典到提示串流的前段,拿著檔案裡最高的物件編號;它後面的所有東西反而從 1 開始編號。前段那個小的交叉引用節恰好涵蓋這個高位區塊,主表則涵蓋 1 到 N
分割清單裡的記錄初始化陷阱
這種清單很自然會寫成一個記錄:一個動態陣列加一個獨立的計數,而這個形狀藏著一個 Delphi 陷阱。編譯器會初始化區域記錄的受管理欄位,但躺在旁邊的普通 Integer 什麼都得不到。Win32 下堆疊槽位常常恰好是零,第一次 append 落在索引 0,所有測試照樣通過;Win64 下槽位可能留著殘值,第一次 append 就寫到任意的索引
type
TPartList = record
Values: TArray<Integer>; // 受管理的:由編譯器清空
Count: Integer; // 未受管理的:堆疊上原本的殘值
end;
procedure Append(var List: TPartList; Value: Integer);
begin
if List.Count = Length(List.Values) then
SetLength(List.Values, List.Count * 2 + 16);
List.Values[List.Count] := Value;
Inc(List.Count);
end;
procedure BuildParts;
var
FirstPage, Shared: TPartList;
begin
FirstPage := Default(TPartList); // 必要,不是裝飾
Shared := Default(TPartList);
Append(FirstPage, 12);
Append(Shared, 40);
end;
HotPDF 的寫入器在碰每個分割清單之前都先指派 Default。一次綠燈的 Win32 測試,說明不了這種記錄到底有沒有被初始化
為什麼版面必須在第一個位元組寫出之前收斂
因為檔案前段存放著指向後段的偏移量,而這些偏移量的位數一變,後面所有東西都跟著移動。第一頁 trailer 帶著 /Prev 條目,內容是主交叉引用表的位元組偏移量。把這個偏移量寫成 99998,後來發現表格實際落在 100003,trailer 就多了一位數。第一頁交叉引用節跟著變長,它後面的每個物件都後移一個位元組,主表現在落在 100004。單趟就想做對,純靠運氣
HotPDF 用兩招打破這個循環。參數字典放進固定 384 位元組、以空格補滿的槽位,/L、/H、/E、/T 要幾位數都可以,不會挪動任何物件;如果字典文字超過 384 位元組,寫出會直接拋出例外,而不是把檔案位移。剩下 /Prev 是唯一的自由變數,它走一個不動點迴圈:每個物件先序列化進一條只記長度、不保留位元組的計數串流;一趟版面 pass 逐節指派偏移量並算出主表會落在哪裡,這個答案成為下一趟 pass 的輸入。提案偏移量不再移動時迴圈停止,八次嘗試之後仍不收斂就拋出例外放棄
只有位數變化才會挪動東西,所以第三趟 pass 只在十的冪次邊界附近才需要,例如 99998 那個案例。收斂之後,寫入器再跑一趟確認 pass,用真實偏移量重建提示串流並檢查長度沒有變化;偏移量放在固定位寬欄位裡,佔位版與最終版必須完全一致。然後輸出直接寫進目的地串流,沒有緩衝的第二份複本;每到一個分節邊界,就比較已寫位元組數與承諾的偏移量。不符就從 EndDoc 拋出例外
提示表,以及附錄 F 的兩種讀法
主提示串流帶著兩張表。頁面偏移提示表為每一頁記錄:該頁分節含多少物件、該分節多少位元組、以及它依賴哪些共用物件。共用物件提示表列出每個共用物件群組的長度。有了這兩張表,讀取端可以直接請求第 37 頁;串流的 /S 條目則給出共用表在串流內的起始位置
附錄 F F.4 裡有兩個細節很容易做錯。第一,提示表裡存的偏移量是邏輯偏移量,計算方式如同主提示串流不存在。第一頁物件和第一個共用物件物理上都在提示串流之後,所以寫入器在記錄它們的偏移量之前先減去提示物件的長度。記錄物理偏移量的寫入器,會讓之後每個 range 請求都恰好偏移一個提示物件的長度
第二個是真正的歧義。F.4 的文字把每張表描述成單一連續位元流、最高有效位在前,並把第一頁共用群組與後續共用群組列成兩個獨立序列。流通中的提示串流卻把每一欄補齊到位元組邊界,並把共用群組視為一個合併清單,而這正是已知提示表讀取端願意接受的形態。HotPDF 寫的是位元組對齊的合併布局。這是該選的那一邊:一張滿足 F.4 字面、卻讓讀取它的用戶端錯位的表,恰好會在線性化本該發揮價值的地方失效,而且整檔下載永遠暴露不了這個問題
從 Delphi 開啟線性化輸出
API 端的開關是一個布林值。LinearizeOutput 預設為 False,寫入器在 EndDoc 序列化文件時才查閱它,所以在 BeginDoc 之前與其他輸出選項一起設定好,之後別再動它。上文描述的每一種失敗(不收斂、參數字典超寬、邊界不符、下文涵蓋的 4 GiB 上限)都以 EndDoc 例外的形式浮現,這讓備援方案很好寫
procedure WriteReport(const FileName: string; Linearize: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.FileName := FileName;
PDF.Version := pdf17;
PDF.LinearizeOutput := Linearize; // 在 BeginDoc 之前設定
PDF.BeginDoc; // 建立第 1 頁
PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
PDF.AddPage;
PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
PDF.EndDoc; // 版面計算與寫出都在這裡發生
finally
PDF.Free;
end;
end;
procedure PublishReport(const FileName: string);
begin
try
WriteReport(FileName, True);
except
on E: Exception do
begin
LogWarning('Linearization failed: ' + E.Message); // 你自己的日誌器
WriteReport(FileName, False); // 改用普通 PDF
end;
end;
end;
備援路徑產出任何檢視器都打得開的普通 PDF;損失只是不夠快的第一頁。對報表管線來說這筆交易很划算,因為第一屏慢是掃興,報表不見是事故
部署端比程式碼更重要。只有伺服器配合 HTTP Range 請求,線性化才有回報;整檔串流輸出的端點只會讓你白白多付更慢的寫出與更大的檔案。會被 Email 寄出、附加或先下載後檢視的檔案,讓 LinearizeOutput 保持關閉。瀏覽器直接從 URL 開啟的文件才打開它,前提是先確認回應帶有 Accept-Ranges: bytes
為什麼 LinearizeOutput 會關閉物件串流
因為每條提示表項目描述的是一個位元組範圍,而打包進 /ObjStm 容器的物件沒有自己的位元組範圍;它只是另一條必須整條抓取、整條解壓的壓縮串流裡的一段。啟用 LinearizeOutput 時,即使 UseXRefStream 或 UseObjectStreams 也設了,HotPDF 仍寫出傳統文字交叉引用表與未打包的間接物件。線性化路徑在儲存時優先,所以你的程式碼沒有衝突要調解
代價是檔案體積,因為物件串流本可壓在一起的小字典現在各自鬆散。如果緊湊輸出比第一頁延遲更重要,HotPDF 的物件串流與增量更新一文把這筆取捨攤開來談。Office 匯出器從另一頭撞上同樣的張力,這正是它們之中某些會輸出Office 產生 PDF 裡的混合交叉引用串流那種布局的原因
線性化還需要 PDF 1.2 或更新版本。Version 更低時,HotPDF 會在儲存時把文件提升到 1.2。設了 StrictVersionLock 則改為拋出例外,因為刻意釘死的版本不該在背後被改掉
線性化輸出的 4 GiB 上限
提示表以 32 位元欄位存放偏移量與長度,所以線性化檔案無法定址超過 4 GiB 的任何東西;HotPDF 寧可拒絕產出,也不寫出會迴繞的偏移量。這個上限來自附錄 F 定義的欄位寬度
檢查在三個時點執行,全部都在第一個位元組抵達目的地之前:每個物件在其序列化長度已知時、每個頁面分節在提示項目建構時、最終檔案長度在主交叉引用表定尺寸之後。偏移量迴繞是最糟的失敗方式:整檔下載的檢視器可以無視提示,而位元組範圍用戶端會跳到錯誤的位元組,所以桌面測試永遠重現不了故障。多 GB 的輸出請看大型 PDF 工作流程的 Direct File API 這條串流路線
檢查進來的檔案是否已線性化
THotPDF.IsLoadedLinearized 回報已載入文件是否宣告自己為線性化。檢查發生在解析之前:HotPDF 從來源位置零讀取至多 1024 位元組,找到第一個物件,並在物件內尋找值為 1 的 /Linearized。註解、literal string 與十六進位字串都會跳過,所以名稱出現在字串裡不算數,掃描在 stream 或 endobj 處停止。只有 1 或 1.0 這類等價寫法會被接受
答案在載入時快取,因為等到應用程式碼開口詢問,解析器早已移動串流位置,LoadFromFile 也已釋放它的來源串流
這個旗標回報的是檔案的自我宣稱。附錄 F 還有第二個條件:/L 必須與實際檔案長度相符,而線性化之後又附加的增量更新會破壞這個相符,讀取端於是退回把檔案當普通檔處理。接受「會附加儲存的編輯器」所產出檔案的管線,可以自己比較這兩個數字
// 你自己的輔助函式,不是 HotPDF 的一部分:比較 /L 與實際大小
// 簡化剖析;假設 /L 後面是一個空格
function DeclaredLengthMatches(const FileName: string): Boolean;
var
FS: TFileStream;
Head: AnsiString;
ActualSize: Int64;
P, Q: Integer;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
ActualSize := FS.Size;
SetLength(Head, 1024);
SetLength(Head, FS.Read(Head[1], 1024));
finally
FS.Free;
end;
P := Pos(AnsiString('/L '), Head);
Result := P > 0;
if not Result then
Exit;
Inc(P, 3);
Q := P;
while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
Inc(Q);
Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;
procedure InspectUpload(const FileName: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
if PDF.LoadFromFile(FileName) <= 0 then
Exit;
if not PDF.IsLoadedLinearized then
Writeln('Not linearized')
else if not DeclaredLengthMatches(FileName) then
Writeln('Linearized, but modified after linearization')
else
Writeln('Ready for Fast Web View');
finally
PDF.Free;
end;
end;
LinearizeOutput 與 IsLoadedLinearized 都是 HotPDF Delphi Component 的一部分,支援 Delphi 與 C++Builder,其產品頁面記錄了它們與交叉引用串流、物件串流及版本鎖定的互動方式