把浮水印或標誌蓋到文件每一頁上,看起來像五分鐘就能收工的活,直到您用檔案大小檢視工具打開結果為止。顯而易見的作法是走過每一頁,在每一頁上把同樣的文字或影像物件再建一次。那在視覺上行得通,卻是一種會複利累積的浪費。一個斜向的「DRAFT」浮水印直接畫在一份一百頁的報表上,就是一百份相同的路徑與文字資料躺在內容串流裡,而存下來的檔案把它們每一份都帶著
Form XObject 正是 PDF 為了避開這件事而提供的構件。它把一塊可重複使用的內容——一整頁或一個小範本——包成單一具名物件,能在許多位置被畫上許多次。內容在檔案裡只存在一次。每一頁想要那個圖章時,就持有一小段指令,寫著「在這裡畫 XObject N,用這個轉換」。於是一份一百頁的浮水印只替檔案加了一個內容物件,而不是一百個,而這正是「隨頁數線性膨脹的文件」與「不會膨脹的文件」之間的差別。浮水印、標誌圖章、頁碼範本與印信,全都是同一種形狀的問題,而 Form XObject 對它們每一個都是對的工具
為什麼存一次的物件勝過重畫一百次
省下來的是結構性的,不是表面功夫。PDF 頁面是靠執行它的內容串流來呈現的,那是一連串繪圖運算子。當您逐頁重畫圖章時,您是把那個圖章的完整運算子序列附加到每一頁的串流上,位元組有幾頁就被複製幾份。Form XObject 把那些運算子搬進一條在文件中只存一次的串流。個別頁面留著的參照很小:推入一個轉換矩陣、叫用該 XObject、還原狀態。頁數不再會把美術內容的代價乘上去
圖章愈重,這件事愈要緊。一個帶著數百段路徑的向量印信,或一張標誌點陣圖,儲存起來都很昂貴。只存一次再參照,昂貴的部分只付一次,而每頁的額外開銷不過是幾個位元組的叫用。頁面上的視覺結果與直接重畫完全相同,那正是重點。讀者看不出差別;檔案大小可看得非常清楚
把一頁擷取進 XObject
PDFium 是從既有的頁面建出這個可重複使用物件的。來源可以是您已開啟之某份文件中的一頁,也可以是一份只裝著您浮水印美術內容的單頁小 PDF,或是一份較大檔案中的某一頁。CreateXObjectFromPage 會把那個來源頁的內容擷取成一個可重複使用的控制代碼,該控制代碼屬於目的地文件,也就是您要蓋章的那一份
var
Dest, Stamp: TPdf;
XObject: TPdfXObject;
begin
Dest := TPdf.Create(nil);
Stamp := TPdf.Create(nil);
try
Dest.FileName := 'Report.pdf';
Dest.Active := True;
Stamp.FileName := 'Watermark.pdf'; // 一頁美術內容
Stamp.Active := True;
if not (Dest.Active and Stamp.Active) then
raise Exception.Create('Could not open the input documents');
// 把圖章文件的第 0 頁擷取成一個可重複使用的控制代碼,
// 它由 Dest 擁有。來源必須是 Active;索引以零為起點。
XObject := Dest.CreateXObjectFromPage(Stamp, 0);
if XObject = nil then
raise Exception.Create('Could not build the stamp XObject');
// ... 放置它,然後在關閉 Stamp 之前釋放它(見下文)...
它的簽章是 CreateXObjectFromPage(Source: TPdf; SourcePageIndex: Integer): TPdfXObject。當來源文件不是 Active 時這個方法會擲出例外,而當 PDFium 建不出物件時它回傳 nil 而非擲出例外,所以上面那道明確的檢查不是可有可無的。回傳的控制代碼是一個歸您所有的 TPdfXObject,而附在它身上的兩條生命週期限制,正是整件事最容易絆倒人的部分,所以它們在下文自成一節
把圖章放到頁面上
擷取好的 XObject 自己什麼也不做。要讓它出現,您得用 InsertFormObjectFromXObject 把它的一份副本插進文件目前的頁面,也就是由以 1 為起點的 PageNumber 屬性選定的那一頁。該呼叫會回傳底層的頁面物件,一個 FPDF_PAGEOBJECT,而您就是用回傳的這個控制代碼來定位這次放置。沒有轉換時,圖章會落在來源頁自己座標系的原點上,而那很少是您想要的位置
由於 InsertFormObjectFromXObject 每次呼叫插入一份副本,而且每次都交回一個全新的頁面物件,您可以用不同的轉換把同一個 XObject 在一頁上畫好幾次,而被儲存的內容在檔案裡仍然只算一次。角落的標誌與一個淡淡的滿版浮水印,可以出自同一個擷取好的物件
var
PageObj: FPDF_PAGEOBJECT;
M: TPdfMatrix;
RawM: FS_MATRIX;
begin
// Dest 目前的頁面收到 XObject 的一份副本。
PageObj := Dest.InsertFormObjectFromXObject(XObject);
if PageObj = nil then
raise Exception.Create('Insert failed on this page');
// 定位它:向右移 200 單位、向上移 500,並縮放到 70%。
M := TPdfMatrix.Create;
try
M.Scale(0.7, 0.7);
M.Translate(200, 500);
RawM := M.Handle;
if FPDFPageObj_SetMatrix(PageObj, RawM) = 0 then
raise Exception.Create('Cannot assign the stamp matrix');
finally
M.Free;
end;
Dest.UpdatePage; // 把這一頁的編輯提交進它的內容串流
// if not Dest.SaveAs(...) then ... 等每一頁都做完之後再做。
end;
有兩件雜務讓這段程式是安全的。第一,一旦插入,那個頁面物件就屬於頁面,不屬於 XObject。之後釋放 XObject,並不會讓您已經做好的那些放置失效。這正是下文所述「建立、放置、釋放」順序行得通的原因。第二,插入與定位只改變記憶體中該頁的物件清單;UpdatePage 才是把那份清單序列化回頁面內容串流的動作,所以一頁您編輯過卻沒呼叫它,存檔時就像圖章從未被放上去一樣
那條會咬人的控制代碼生命週期規則
有兩條限制管著這個 XObject 控制代碼,忽略任何一條,產生的失敗看起來都和它的成因毫不相干。第一,在您呼叫 CreateXObjectFromPage 的那一刻,來源文件必須是作用中的。這次擷取是從活著的來源文件中讀取來源頁的內容,所以建立控制代碼時,那份文件與那一頁都必須開著且有效。第二,也是最讓人意外的一條:控制代碼必須在來源頁被關閉之前釋放,實務上就是在您關閉或釋放它所來自的來源文件之前
原因在於這個 XObject 是一個指進來源文件仍持有之結構的參照。它不是一份您可以在來源消失後帶著走、與來源脫鉤的自足副本。先關掉來源,控制代碼就留著指向已經被拆掉的內容,於是之後釋放它、或對它做任何其他使用,都是在操作已經無效的記憶體。症狀就是懸空控制代碼的經典表現:關閉時的存取違規,或是隨配置順序而遊移的間歇性損毀,堆疊指向的是清理程式碼,而不是真正闖禍的那一行。解法是順序,不是防禦式編碼。建立 XObject、把它插到每一頁需要的地方、釋放 XObject,然後才關閉來源文件。TPdfXObject 的解構式會替您釋放底層的 PDFium 控制代碼,所以在正確的時機釋放這層包裝,就是您全部的責任
矩陣,以及它那六個數字的意思
放置是一次 2D 仿射轉換,與 PDF 各處定位內容所用的是同一種(ISO 32000-1,第 8.3.4 節)。它是六個數字,寫成 a, b, c, d, e, f,PDFium 以 FS_MATRIX 記錄把它們暴露出來。它們把一個點從物件自己的空間對映到頁面空間:
// x' = a*x + c*y + e
// y' = b*x + d*y + f
//
// a, d:水平與垂直縮放
// b, c:傾斜/旋轉項
// e, f:平移(原點落在頁面上的哪裡)
您可以手工填那六個值,但手工組合它們正是旋轉出錯的地方,因為旋轉會把 a, b, c, d 四項全部攪在一起。來自 FPdfMatrix 單元的 TPdfMatrix 包裝會替您組合常見的操作,並且邊做邊右乘,所以 Translate、Scale 與 Rotate 會依您呼叫的順序串接。斜向浮水印是先旋轉、再平移回中央;角落標誌是先縮放、再平移。矩陣備好之後,請把它的原始值(型別為 FS_MATRIX 的 Handle 屬性)複製到一個區域變數,再把它傳給 FPDFPageObj_SetMatrix;那個匯入把矩陣宣告為 var 參數,所以屬性不能直接交給它,而它失敗時的結果是 0。若您寧可傳數字也不想建包裝,還有較低階的 FPDFPageObj_Transform 可用,它直接接受那六個 double 值
按正確的順序替每一頁蓋章
完整的模式,把各個環節依生命週期規則所要求的順序組起來。開啟兩份文件、把圖章擷取一次、依序設定以 1 為起點的 PageNumber 走過目的地各頁,插入並定位一份副本,以 UpdatePage 提交每一頁,接著釋放 XObject,然後用 SaveAs 存檔,並讓來源文件最後才關閉
procedure StampEveryPage(const ASource, AStamp, AOutput: string);
var
Dest, Stamp: TPdf;
XObject: TPdfXObject;
PageObj: FPDF_PAGEOBJECT;
M: TPdfMatrix;
RawM: FS_MATRIX;
I: Integer;
begin
Dest := TPdf.Create(nil);
Stamp := TPdf.Create(nil);
try
Dest.FileName := ASource;
Dest.Active := True;
Stamp.FileName := AStamp;
Stamp.Active := True;
if not (Dest.Active and Stamp.Active) then
raise Exception.Create('Could not open the input documents');
// 1. 把美術內容擷取一次。此時 Stamp 是 Active。
XObject := Dest.CreateXObjectFromPage(Stamp, 0);
if XObject = nil then
raise Exception.Create('Could not capture the stamp page');
try
// 2. 在 Dest 的每一頁上放一份副本。PageNumber 以 1 為起點。
for I := 1 to Dest.PageCount do
begin
Dest.PageNumber := I; // 把第 I 頁設為目前頁
PageObj := Dest.InsertFormObjectFromXObject(XObject);
if PageObj = nil then
Continue;
M := TPdfMatrix.Create;
try
M.Rotate(45); // 斜向浮水印
M.Translate(150, 100); // 微調到定位
RawM := M.Handle;
FPDFPageObj_SetMatrix(PageObj, RawM);
finally
M.Free;
end;
Dest.UpdatePage; // 提交這一頁的編輯
end;
finally
XObject.Free; // 3. 在 Stamp 關閉「之前」釋放
end;
// 4. 趁 Dest 還開著時把結果寫出。
if not Dest.SaveAs(AOutput) then
raise Exception.Create('Could not save ' + AOutput);
finally
Stamp.Free; // 來源最後才關
Dest.Free;
end;
end;
那些 try 區塊的形狀才是真正在做事的東西。內層的 finally 會在控制流有機會抵達釋放 Stamp 的外層 finally 之前就釋放 XObject,所以控制代碼永遠是在它的來源還活著時被釋放的,即使迴圈中途擲出例外也一樣。把那層巢狀結構做對,生命週期規則就會自己照顧好自己
蓋章只是建置與編輯頁面內容這套更大工具箱的一角。若您的圖章本身是一張影像而不是一個擷取來的頁面,用 PDFium 把影像轉成 PDF 文件涵蓋了先把那張點陣圖弄進文件的作法。而當您想隨著看得見的圖章一起攜帶的是一個檔案、而不是畫在頁面上的墨跡時,在 Delphi 中處理 PDF 附件展示了內嵌檔案那一面。這一切都隨適用於 Delphi 與 C++Builder 的 PDFium Component 出貨,與本部落格其他文章介紹的呈現、編輯與文件 API 並肩