技術文章

HotPDF CompressDocument:Delphi 裡的精簡字型子集

HotPDF 的 THotPDF.CompressDocument 是單一開關,讓 BeginDoc 產出這個元件寫得出來的最小無損 PDF:最高等級的 FlateDecode、帶物件串流的交叉引用串流、字型子集化,以及把保留字形重新編號、藏在一個顯式 /CIDToGIDMap 之後的精簡字型子集。EndDoc 隨後把您自己的設定放回原位。一份三頁的 Arial 與 SimSun 測試文件從 10.2 MB 掉到 20 KB,渲染完全一致

CompressDocument 到底打開了什麼?

CompressDocument 覆寫六個寫出設定、外加物件串流上限,只針對一份文件,事後全部還原。在 BeginDoc、PDF 版本尚未定案之前,HotPDF 記下您的值,把 Compression 設為 cmFlateDecode、CompressionLevel 設為 clMaximum,打開 EnableFontSubsetting 與 CompactFontSubsetting,並啟用 UseXRefStream 與 UseObjectStreams(ISO 32000-1 §7.5.7 與 §7.5.8)。物件串流需要 PDF 1.5,所以較舊的 Version 在未被鎖定時會被抬到 1.5。PDF/A-1 禁止這兩種結構,所以 PDF/A-1 文件保留傳統交叉引用表,只做 Flate 與字型的工作。影像完全按您內嵌的原樣保留

Delphi 裡 HotPDF CompressDocument 生命週期示意:BeginDoc 記下寫出器自己的值,為一份文件覆寫含 Compression 與 UseObjectStreams 在內的六個設定,EndDoc 在最外層 finally 還原每個借來的值,CompressDocument 屬性本身保持 True
六個寫出設定與物件串流上限只借給恰好一份文件,EndDoc 執行時歸還,失敗的報告不會讓元件卡在最大壓縮上
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // 由 BeginDoc 套用,EndDoc 撤銷
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

還原動作發生在 EndDoc 最外層的 finally 裡,所以報告跑到一半出例外,不會讓一個長壽命元件帶著最大壓縮進入下一個工作。CompressDocument 屬性本身維持 True;只有它借走的六個設定回去。版本號的處理更細心:HotPDF 只有在文件最終仍是 1.5 時才撤銷自己那次抬升,所以執行期間若有別的功能把檔案推到 1.6(比如內嵌了 OpenType 字型),較高的版本留著,與沒有壓縮時的行為完全一致

為什麼不做精簡化,字型子集還是很大?

傳統 TrueType 子集丟掉您從不畫的輪廓,卻讓每個字形 ID 留在原位,正是這套編號讓它沈重。內容串流裡出現的 CID 等於原始 GID,所以子集必須為它保留的最高字形以下的每個槽位——空槽也一樣——保有一個 loca 偏移與一條 hmtx 條目。對拉丁字型,這點開銷是雜音;對 SimSun 這種 CJK 字型——漢字深居巨大字形表的深處——兩個中文字拖著為整個字型準備的表。成形字形的字型子集閉包規則決定哪些字形活下來;精簡化管的是活下來的值多少錢

CompactFontSubsetting 把保留的字形重新編號成從零開始的稠密區間,並在 CIDFont 上寫一條 /CIDToGIDMap 串流——ISO 32000-1 §9.7.4.2 把它定義為以 CID 索引、每項兩位元組 GID 的表。整個戲法就在這張表。內容串流、/W 寬度陣列與 ToUnicode CMap 都保留原始 CID,已經寫出的東西不必改;只有 CID 到字形的查表搬進映射裡。在催生這個功能的測試裡,兩個字的 SimSun 從 24.8 KB 的字型資料降到 3.1 KB

稀疏 HotPDF 字型子集與 CompactFontSubsetting 輸出的對比:前者為最高保留 GID 以下的每個原始字形 ID 保留 loca 與 hmtx 條目;後者把保留字形從零稠密重編、經 CIDToGIDMap 串流映射 CID,而內容串流、/W 與 ToUnicode 原樣不動
重編號把成本從字型程式搬進一小條映射串流——兩個 SimSun 字從 24.8 KB 降到 3.1 KB,已寫出的內容一個位元組都沒碰

精簡化有硬性邊界,而且它無聲降級而不是失敗。HotPDF 只為 Type 0 TrueType 字型建精簡子集:經 SetFont 設定且開著子集化的,以及經 RegisterUnicodeTTF 註冊的字型都算。簡單 TrueType 字型靠字型程式內部的 cmap 找字形,重編號會弄壞它,所以保留稀疏子集。OpenType-CFF 字型也沒有精簡路徑。精簡建置失敗時退回稀疏子集,不丟例外。屬性預設關閉,既有輸出保持逐位元組相同;而在 PDF/A 之下,註冊的 Unicode 字型一律拿到精簡子集

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // 不開 CompressDocument 也能用
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

打包寫出器怎麼擰乾檔案結構?

字型與串流變小之後,字典與交叉引用資料成了剩下最大的成本,所以 CompressDocument 背後的物件串流寫出器把這些也修剪。物件串流與增量更新指南講容器格式本身;壓縮路徑在其上再加四項打磨:

  • 按 ISO 32000-1 §7.2.2 的緊湊語法:只有兩個 token 若不加空格會連成一般字元時才寫空格,/Type /Page 因此變成 /Type/Page
  • 交叉引用串流欄位寬度取 §7.5.8.2 允許的任意寬度,16 MB 以下的檔案每個偏移用 3 位元組而非 4 位元組存
  • 每個物件串流最多裝 250 個物件,而非慣常的 100,除非您透過 ConfigureAdaptiveObjectStreamPacking 設了自己的上限
  • 檔案未加密時,Catalog 與 Info 字典也打包進物件串流;加密輸出把它們留在頂層

如果您要擴充寫出器,緊湊語法帶著一個值得知道的陷阱。簽章在檔案寫完之後才填入,做法是在位元組裡搜尋字面佔位符 /ByteRange ( 與 /Contents <,而緊湊拼寫會把它們變成 /ByteRange( 與 /Contents<,搜尋永遠找不到。因此簽章字典(Type Sig 或 DocTimeStamp、FT Sig)與加密字典保留帶空格的排版。相關缺陷影響 v2.766.41 之前的建置:每次物件串流存檔——CompressDocument 也在內——開頭都有兩行 %PDF- 標頭,嚴格驗證器若挑您的輸出的毛病,請升級

已經載入的 PDF 能壓嗎?

能,走選項重載 CompressLoadedDocument(Options, Info),對既有檔案執行同一套無損步驟。用 THPDFLoadedDocumentCompressionOptions.Default 時,它移除未使用的頁面資源、合併相同的字型與表單、對內嵌字型做子集化並開精簡子集、對未過濾、Flate、LZW、ASCII 與 RunLength 串流在結果更小時用 Flate 重壓,並讓下一次存檔使用物件串流。HighRatioFlate 預設關閉,PDF/A-1 與增量存檔跳過物件串流。不帶參數的 CompressLoadedDocument 重載是較舊、較窄的呼叫,只對未壓縮串流做 Flate 壓縮

Delphi 裡 HotPDF CompressLoadedDocument 的流程:呼叫移除未使用的頁面資源、合併相同字型與表單、對內嵌字型做精簡子集、只在結果更小時用 Flate 重壓串流、為下一次存檔打開物件串流;有簽章欄位時觸發 RefusedBySignaturePolicy,檔案原樣不動
每一步都會改寫簽章覆蓋的位元組,所以除非您明確允許失效,整份文件一律拒收——屆時 Info.BytesSaved 也只統計資源、字型與串流那幾步
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

載入路徑上有兩條邊界要緊。每一步都改寫簽章覆蓋的位元組,所以帶簽章欄位的文件整份拒收:呼叫回傳 0、設 RefusedBySignaturePolicy、什麼都不改,除非您設 AllowSignatureInvalidation,之後 Info.SignaturesInvalidated 會告訴您放棄了什麼。精簡化在這裡也比建立路徑保守:HotPDF 只精簡僅由帶 Identity /CIDToGIDMap(CID 等於 GID)的 CIDFontType2 字型使用的字型程式,帶既有映射串流、/CIDSet 或 COLR、sbix、CBDT、SVG 之類彩色字形表的程式一律跳過,因為精簡重建會丟掉彩色圖層。另請注意 Info.BytesSaved 只統計資源、字型與串流三步;物件串流的收益在檔案寫出時才顯現

實戰中該期待什麼樣的結果?

收益跟著檔案裡有多少未壓縮結構與超重字型資料走,不跟著頁數走。三頁的 Arial 與 SimSun 範例,用 CompressDocument 產生時從 10.2 MB 縮到 20 KB;把未壓縮原件載入、跑 CompressLoadedDocument 時從 10.2 MB 縮到 19.8 KB,兩種方式渲染完全一致。已經夠緊湊的 PDF 幾乎不動:迴歸集裡這類檔案只省下原始大小的 -0.07% 到 +0.06%。照片為主的檔案收益甚少,因為兩條路徑都不碰影像資料

如果您每晚產生同樣的 CJK 報告,把精簡子集與磁碟上的持久字型子集快取配成一對,子集化工作就不必每輪重來;壓縮輸出的 diff 按物件內容比,不要按位元組比,因為一個欄位變動就會把整個物件串流重新 Flate。完整屬性與記錄參考在HotPDF Delphi PDF component 產品頁