PDF Library for Delphi 在存檔階段的 peephole 內容串流優化中移除單位文字矩陣運算元 1 0 0 1 0 0 Tm 的條件只有一個:文字矩陣與文字行矩陣當下正是單位矩陣——也就是緊跟在 BT 之後,或緊跟在前一個單位 Tm 之後。單位的 cm 依舊一律丟棄,因為 cm 是與 CTM 相乘,而 Tm 是整個取代兩個文字矩陣。從 v3.539.28 起,其他所有單位 Tm 都留在串流裡
這個修掉的 bug 屬於安靜型。某報表產生器輸出 BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET,指望那個單位 Tm 把第二個字串送回文字空間原點,再套用自己的定位邏輯。舊優化器看到六個數字拼出單位矩陣,斷定這個運算元不可能改變任何東西,就刪了。沒有失敗、沒有警告,存出來的頁面上「Total」緊貼著「Invoice」畫在同一條基線上——正是那種要等客戶把 PDF 拿去列印才有人發現的缺陷
為什麼 1 0 0 1 0 0 Tm 不一定是 no-op?
單位 Tm 只有在它將取代的兩個矩陣本來就是單位矩陣時才是 no-op,而這是它前面那些運算元的性質,不是它自己運算元的性質。ISO 32000-1 §9.4.1 說 BT 把文字矩陣(Tm)與文字行矩陣(Tlm)都初始化為單位矩陣,§9.4.2 則把 Tm 定義成將兩者設成給定值,而不是串接上去。對照 cm(§8.4.4):它對目前轉換矩陣做右乘,乘上單位矩陣不改變任何 CTM,所以 1 0 0 1 0 0 cm 在任何地方刪掉都安全。文字物件裡面就是另一番光景。Td、TD、T* 與非單位的 Tm 都會移動 Tlm,而每個顯示文字的運算元(Tj、TJ、'、")都按畫出的字形寬度推進 Tm。任何一個之後,單位 Tm 就是一次貨真價實的歸零。如果您曾用內容串流 CTM 與文字矩陣狀態追蹤器手工推過文字位置,這就是「串接狀態」與「取代狀態」的同一個區分
反向掃描如何決定丟哪個單位 Tm
TPDFContentPeepholeOptimizer.RemoveIdentityMatrices 現在從每個單位 Tm 向回走,只有掃描先碰到 BT 或另一個單位 Tm 才丟棄它。較早那個單位 Tm 無論是被保留、還是本身剛被排進刪除名單都算數,因為兩種情況下它都把兩個矩陣留在了單位狀態,跟 BT 一模一樣。這條規則把它可能遇到的每個運算元分成兩類:
- 停下並保留 Tm:
Td、TD、T*、非單位的Tm、Tj、TJ、'、"、ET、解析器不認得的任何運算元,或串流開頭 - 跨過繼續掃:從不碰 Tm 或 Tlm 的運算元,例如
Tf、Tc、顏色設定、gs、marked-content 運算元與cm
保守的情況是刻意的。未知運算元可能是任何東西,所以掃描拒絕對它之後的世界做推理。ET 關閉文字物件,它之後的 Tm 沒有 BT 為矩陣值背書。掃描一次也只處理一條內容串流,這對 /Contents 是陣列的頁面很要緊:某個圖層從文字物件中間開始、自己沒有 BT,它的單位 Tm 就保留下來,即使前一個圖層早已讓它變得多餘。這在怪檔案上多花幾個位元組,但永遠不搬動字形。如果您在指令層級編輯頁面文字,如字元到內容位元組的映射走查那篇,優化器改寫的正是同一個解析後的 TPDFContentProgram 模型
uses
PDFlibContentModel, PDFlibContentOptimize;
function OptimizeSnippet(const Source: AnsiString): AnsiString;
var
Prog: TPDFContentProgram;
Optimizer: TPDFContentPeepholeOptimizer;
begin
Result := Source;
Prog := TPDFContentProgram.Create;
try
if not Prog.Parse(Source) then
Exit; // 串流損壞:位元組原樣保留
Optimizer := TPDFContentPeepholeOptimizer.Create(Prog);
try
Optimizer.Run; // 回傳移除的指令數
finally
Optimizer.Free;
end;
Result := Prog.Emit; // 每行一條指令
finally
Prog.Free;
end;
end;
// 會移除:緊跟 BT 的 Tm、連續兩個單位 Tm 中的第二個
// OptimizeSnippet('BT /F1 12 Tf 1 0 0 1 0 0 Tm (hello) Tj ET')
// 會保留:Td 之後、Tj 之後、非單位 Tm 之後、或 BT 之外的 Tm
// OptimizeSnippet('BT (Invoice) Tj 1 0 0 1 0 0 Tm (Total) Tj ET')
對開頭那條發票串流跑這個輔助函式,單位 Tm 活得下來,因為反向掃描在抵達 BT 之前先撞上 Tj。把 /F1 12 Tf、2 Tc 與 0 g 放進 BT 與單位 Tm 之間,它照樣被刪,因為這些沒有一個碰文字矩陣。像 BT 10 20 Td 1 0 0 1 0 0 Tm 1 0 0 1 0 0 Tm 這樣的序列恰好只丟一個運算元:第一個單位 Tm 把 Td 挪過的矩陣歸零,多餘的只有第二個
peephole 優化器到底什麼時候跑?
優化器只在壓縮階段裡跑,也就是 TPDFPageTree.Compress 內部,而且只跑在還沒做 Flate 壓縮的內容串流上。TPDFlib.SetOptimizeContentStreams(1) 是預設值,同一個開關也以 TPDFlibSaveOptions 的 OptimizeContentStreams 欄位暴露;CompressContent 與 CompressPage 都吃這個設定。/Filter 已經是 /FlateDecode 的串流整個跳過,所以載入一份既有的壓縮 PDF 再存一次,運算元不會被改寫。串流解析失敗時,原始解碼位元組原樣壓縮。TPDFlib.NormalizeContentStreams 會以規整的空白與數字解析並重新輸出內容,但從不呼叫優化器——想看 peephole 規則對體積貢獻了多少,它是現成的基線,再搭配靠字型子集化做 PDF 檔案瘦身裡那些更大的收益
var
Lib: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
// 未壓縮串流先過 peephole 規則,再 Flate
Lib.SetOptimizeContentStreams(1);
Lib.CompressContent;
Lib.SaveToFile('report-optimized.pdf');
// 同樣的選擇改走打包好的存檔選項;False 表示關閉
Options.CompressContent := True;
Options.CompressFonts := True;
Options.CompressImages := True;
Options.Linearize := False;
Options.KeepModDate := False;
Options.OptimizeContentStreams := False;
Options.GarbageCollect := False;
Options.PackObjectStreams := True;
Lib.SaveToFileOptions('report-plain.pdf', Options);
finally
Lib.Free;
end;
end;
舊迴歸測試到底保證了什麼?
舊迴歸測試只保證了一種形狀:緊跟 BT 之後的單位 Tm 會被移除。Peephole_RemovesIdentityTextMatrix 把 BT 1 0 0 1 0 0 Tm (hello) Tj ET 餵給優化器,斷言沒有 Tm 殘留。更早的版本其實已經注意到,Tlm 不是單位矩陣時丟掉單位 Tm 並不安全,卻因為測試「鎖定」了這個行為而照樣保留。仔細重讀,這個測試對 Td 之後、或顯示過文字之後的單位 Tm 隻字未提;把單一樣本的覆蓋當成整條規則的契約,才是真正的錯。修法讓原本的案例繼續通過,並新增六個案例,把可移除與必保留的形狀都釘死,包括文字物件之外的 Tm 與跟在 ET 後面的
這筆交易寫下來就不難接受。把每個文字物件都包成 BT 1 0 0 1 0 0 Tm ... 的產生器,那個多餘運算元照樣被移除——節省量幾乎全從這裡來。優化器放棄的,只是文字物件中間偶爾出現的單位 Tm,Flate 還沒看到之前每頁區區幾個位元組,換來的是模組開頭白紙黑字的保證:每個變換都輸出等價、絕不改變可見頁面。會搬動文字的體積優化器不是優化器,是壓縮率漂亮的渲染 bug
本文描述的內容串流解析器、peephole 優化器與存檔階段的壓縮選項,都隨 PDF Library for Delphi and C++Builder 出貨;它也暴露 NormalizeContentStreams、CompressContent 與 TPDFlibSaveOptions,供您調校每份文件的寫出方式