技術文章

PDF 的快速網頁檢視:線性化改變了什麼

從連結開啟一份 200 頁的 PDF,瀏覽器常常一片空白,直到大部分檔案傳完才動作。Fast Web View 是 Adobe Acrobat 對線性化 PDF(ISO 32000-1 附錄 F)的稱呼:第一頁和一份小索引放在檔案最前面,檢視器因此能早早繪出第一頁,後續頁面再透過 HTTP Range 請求抓取。HotPDF 和 PDFium Component 都能在 Delphi 裡寫出線性化檔案

這一切都不是另一種檔案格式。線性化 PDF 就是一份普通的 PDF,任何合規的閱讀器都不需要特殊處理就能開啟;差別在位元組的排列順序,以及兩個額外結構:線性化參數字典與提示串流。閱讀器能不能真正受惠,取決於兩個條件。檔案必須經過線性化並保持原樣——一次附加式編輯就會悄悄破壞它;而且網頁伺服器必須以 206 Partial Content 回應位元組範圍請求。在本機磁碟上,跳到檔案結尾不需要任何成本,線性化 PDF 的行為與其他 PDF 完全一樣,所以 Fast Web View 是純粹的網路最佳化,只有直接從 URL 開啟的大型文件才真正得到回報

線性化 PDF 重新排列了什麼?

線性化 PDF 會把第一頁需要的一切收進檔案開頭附近的一塊連續區域,排在其他所有頁面之前。普通 PDF 的物件可以放在任意位置,因為檔案結尾的交叉引用表會把每個物件編號對應到它的位元組偏移:閱讀器跳到檔案尾端,讀取 startxref 指標,載入交叉引用表,之後就能找到任何物件(PDF 檔案結構總覽一文完整走過 header、body、xref 與 trailer 的版面)。這種設計在磁碟上很出色,在網路上卻很糟——最後抵達的恰恰是檔案結尾。要繪製第一頁,傳統閱讀器需要頁面物件、其內容串流,以及用到的字型和影像;在未排序的檔案裡,這些東西任何一個都可能落在最後一個 MB 裡

線性化把順序固定下來。標頭之後是線性化字典和一小段第一頁交叉引用區,接著是文件層級的物件,例如 catalog、主提示串流,以及帶著全部資源的第一頁。其餘頁面逐頁跟在後面,然後是後續頁面共用的物件,最後才是主交叉引用表。忽略這項最佳化的閱讀器照樣從檔案結尾出發、透過主表找到每個物件,這也是線性化檔案與舊軟體完全相容的原因。結果就是在十六進位編輯器裡,物件編號看起來是倒著排的:最前面的區塊拿著最大的編號,後面的一切反而從 1 開始編

普通 PDF 與啟用 Fast Web View 的線性化 PDF 之位元組版面對比
線性化檔案把線性化字典、第一頁 xref 與提示串流搬到所有內容之前;普通 PDF 只有一份索引,放在檔案結尾

線性化字典與提示表

%PDF 標頭之後的第一個物件就是線性化參數字典,附錄 F 規定它必須落在檔案前 1024 個位元組內,讓串流式閱讀器在開頭的封包裡就找到它。表 F.1 列出各個項目:/L 是整份檔案的位元組長度,/H 是主提示串流的偏移與長度,/O 是第一頁的物件編號,/E 是第一頁結束位置的偏移,/N 是頁數,/T 是主交叉引用表第一個項目的偏移。/L 這個項目比表面上看起來更重要。當它與實際檔案大小不符時,表 F.1 要求閱讀器把這份文件當成普通 PDF 處理,無視裡面的每一條提示

提示串流是讓「跳到第 40 頁」變得便宜的功臣。它裡面有一張頁面偏移提示表,記錄每一頁的物件從哪裡開始、佔多少位元組;還有一張共用物件提示表,為多頁共用的字型、標誌等資源做同樣的記錄。想看第 40 頁的閱讀器查出它的位元組範圍,只請求這一段,資料一到就開始渲染,缺的共用物件也比照辦理。自製線性化工具常栽在兩個細節上。提示偏移是邏輯偏移,計算時把提示串流本身視為不存在,寫入端若存了實體偏移,之後每個範圍請求都會偏移整整一個提示物件的大小。而寫入端如果沿著頁面的 /Parent 連結收集資源,就會一路走回頁面樹,把整份文件拖進第一頁區段。這兩種檔案照樣宣稱自己是線性化的,但載入速度一個都不會變快

Fast Web View 為什麼需要伺服器配合?

Fast Web View 只有在伺服器能按請求交付檔案任意切片時才成立。閱讀器送出帶 Range 標頭的 HTTP 請求,並期待收回 206 Partial Content;如果伺服器從未宣告 Accept-Ranges: bytes,或者它前面的代理或 CDN 把範圍請求退化成整檔傳輸,閱讀器就無法自己抓第 40 頁,只能整份下載。此時 PDF 內部結構完全正確卻整個浪費掉,而這正是最常被誤診為「線性化壞掉」的失敗情境。重建文件之前,先對檢視器開啟的確切 URL 請求第一個 KB(例如用 curl -r 0-1023),確認狀態碼是 206。靜態檔案主機通常預設就做對了,而透過自家 handler 串流輸出 PDF 的應用程式端點則常常沒有

線性化提示表驅動 HTTP 範圍請求,為 Fast Web View PDF 的單一頁面取回 206 Partial Content
提示表告訴閱讀器任一頁的位元組範圍,支援範圍請求的主機用一次部分回應就能交付第 40 頁;沒有 byte-serving,整份檔案就得重下載

如何檢查 PDF 有沒有 Fast Web View?

直接檢查位元組,別盡信產出檔案的工具,而且條件的兩半都要驗:檔案宣告自己是線性化的,且這份宣告仍然與磁碟上的檔案相符。三種檢查涵蓋幾乎所有情況,從桌面上隨手一看,到發布管線裡的自動化閘門:

  • Adobe Acrobat 或 Reader:開啟 File > Properties,Description 分頁的右下角會顯示 Fast Web View: Yes 或 No。Acrobat 只有在結構存在且仍是最新的情況下才會報 Yes,所以線性化之後又編輯過的檔案會顯示 No
  • 命令列上的 qpdf:qpdf --check-linearization 會對照實際物件位置驗證提示表,qpdf --show-linearization 則傾印字典與每一條提示表項目
  • 檔案的第一個 KB:用文字或十六進位編輯器開啟,在前 1024 個位元組內找含有 /Linearized 1 的物件;找到之後,把它的 /L 值與作業系統回報的檔案大小比一比
# 對照實際版面驗證線性化字典與提示表
qpdf --check-linearization report-web.pdf

# 傾印 /L、/H、/O、/E、/N、/T 與每一條提示表項目
qpdf --show-linearization report-web.pdf

值得接進管線的是 qpdf 這一項檢查,因為它會把提示表與實際物件位置核對一遍;只確認字典存在的檢查,會照樣放行偏移指向錯誤位元組的檔案。從 Delphi 端看,HotPDF 的 THotPDF.IsLoadedLinearized 回報載入的文件是否宣告自己為線性化,解析前會先掃描前 1024 個位元組找 /Linearized。PDFium Component 則透過類別函式 TPdf.InspectAvailability 回答同一個問題,其 Linearization 欄位傳回 plnLinearized、plnNotLinearized 或 plnUnknown,完全不必載入文件。這兩個旗標回報的都是檔案的宣告;/L 的比對才能告訴您這份宣告是否仍與磁碟上的位元組相符:

uses
  System.SysUtils, System.Classes, System.Math, System.StrUtils, PDFium;

type
  TFastWebView = (fwvNo, fwvStale, fwvYes);

// 您自己的輔助函式:檔案是否線性化交給 PDFium 判定,
// 而 /L 的比對則抓出事後附加的編輯
function FastWebViewState(const FileName: string): TFastWebView;
var
  Stream: TFileStream;
  Head: AnsiString;
  Text: string;
  P, Q: Integer;
begin
  Result := fwvNo;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if TPdf.InspectAvailability(Stream).Linearization <> plnLinearized then
      Exit;
    SetLength(Head, Min(Stream.Size, 1024));   // 附錄 F:前 1024 個位元組
    Stream.Position := 0;
    if Length(Head) > 0 then
      Stream.ReadBuffer(Head[1], Length(Head));
    Text := string(Head);
    P := Pos('/Linearized', Text);
    if P = 0 then
      Exit;
    P := PosEx('/L ', Text, P + Length('/Linearized'));
    if P = 0 then
      Exit;
    Inc(P, 3);
    Q := P;
    while (Q <= Length(Text)) and CharInSet(Text[Q], ['0'..'9']) do
      Inc(Q);
    if StrToInt64Def(Copy(Text, P, Q - P), -1) = Stream.Size then
      Result := fwvYes
    else
      Result := fwvStale;   // 曾線性化,之後又被修改過
  finally
    Stream.Free;
  end;
end;

在 Acrobat、qpdf 與 Delphi 中開啟 Fast Web View

任何按附錄 F 順序重寫整份檔案的工具都能開啟 Fast Web View,重點是它必須是最後一步。在 Acrobat 裡,先在 Preferences > Documents 下勾選 Save As optimizes for Fast Web View,然後用 File > Save As 而不是 Save,因為普通的 Save 可能以增量更新收尾。在伺服器或建置腳本裡,qpdf --linearize in.pdf out.pdf 能把任何 PDF 重寫成線性化形式,輸入檔原封不動。Delphi 程式碼有兩條路,取決於文件是您產生的還是收到的。當 THotPDF.LinearizeOutput 為 True 時,HotPDF 直接寫出線性化輸出;在 BeginDoc 前把它與其他輸出選項一起設定好,附錄 F 版面就會在 EndDoc 內計算並寫入:

qpdf --linearize report-final.pdf report-web.pdf
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;       // EndDoc 之後不自動開啟檢視器
    PDF.FileName := 'catalog-web.pdf';
    PDF.LinearizeOutput := True;   // 在 BeginDoc 之前設定
    PDF.BeginDoc;                  // BeginDoc 之後第一頁已存在
    PDF.CurrentPage.TextOut(72, 72, 0, 'Spring catalog');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Price list');
    PDF.EndDoc;                    // 線性化版面在此寫入
  finally
    PDF.Free;
  end;
end;

對已經存在的檔案,PDFium Component 提供 TPdf.SaveAsLinearized。它先把一般的 PDFium 儲存跑進中間暫存區,頁面編輯與表單值走的路徑與任何其他儲存完全相同,然後在寫入目標之前按附錄 F 重排這份輸出。該方法接受與 SaveAs 相同的 TSaveOption 與 TPdfVersion 參數,拒絕 saIncremental 與低於 PDF 1.2 的版本;其檔名多載會先寫到暫存檔,儲存成功後才取代目標。由於寫入端是從目前的修訂版重建整份檔案,它同時也是為吃過附加式編輯的檔案恢復 Fast Web View 的方法。加密輸入需要 saRemoveSecurity:重新編號物件會讓標準安全 handler 的逐物件金鑰失效(ISO 32000-1 §7.6.2),所以網頁版副本以未加密形式寫出

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'manual-rev3.pdf';   // 帶有附加式編輯
    Pdf.Active := True;
    if not Pdf.SaveAsLinearized('manual-web.pdf', saNoIncremental) then
      raise Exception.Create('Linearized save failed');
  finally
    Pdf.Free;
  end;
end;

增量更新會悄悄把 Fast Web View 關掉

增量更新是正確線性化的檔案後來失去 Fast Web View 最常見的原因。工具透過增量儲存加上簽章、填表單欄位或貼便籤時,原始位元組原封不動,改動的物件、新的交叉引用區與新的 trailer 直接附加到檔案結尾。前端的線性化字典描述的仍是舊版面,/L 不再等於檔案大小,合規的閱讀器便退回把文件當成普通 PDF 對待——即使線性化結構還完整地待在檔案前段。HotPDF 透過 BeginIncrementalUpdate 與 SaveIncrementalUpdate 附加的正是這種修訂;文件還在變動時它是對的工具,對即將放上網頁伺服器的檔案則是錯的最後一步:

// 附加一個修訂版:速度快,先前的位元組原樣保留,
// 但結果不再具備 Fast Web View
PDF.BeginIncrementalUpdate('report.pdf');
PDF.AddPage;
PDF.CurrentPage.TextOut(72, 760, 0, 'Addendum');
PDF.SaveIncrementalUpdate('report-rev2.pdf');
增量更新附加的修訂版讓線性化字典逐漸失準,直到最後一次完整重寫才恢復 Fast Web View
每次增量儲存都在失準的前端字典之後再疊一層修訂版;最後以完整重寫收尾並重新線性化,Fast Web View 才能保住

可行的原則是:文件還在變動時用增量編輯,最後一步做一次線性化;至於先被寄出或下載、之後才有人開啟的檔案,就別開 Fast Web View——前端載入的索引變大,對它們沒有任何好處。已簽署的文件則逼您做真正的選擇:完整重寫會動到每個簽章 /ByteRange 涵蓋的位元組,簽署後再附加更新又會讓 /L 失配立刻回歸,所以合約類文件保留簽章、接受整檔下載。附錄 F 的寫入端——包括第一個位元組寫下之前偏移如何收斂,以及線性化輸出為何放棄物件串流——詳見 以 HotPDF 提示表撰寫線性化 PDF 與 在 Delphi 用 PDFium Component 實現 Fast Web View 線性化。LinearizeOutput 與 IsLoadedLinearized 隨 HotPDF Delphi Component 出貨,SaveAsLinearized 與 InspectAvailability 則屬於 PDFium Component for Delphi, C++Builder and Lazarus