在 Delphi PDF 函式庫 PDFlibPas 裡,用 MovePage 搬走的頁面,過去拿到的正是舊 Pages 節點手上那幾個 MediaBox、CropBox 與 Resources 物件本身,所以之後對搬移頁做 SetPageBox 或 DrawText,會不聲不響地改寫那個節點,連帶每一個還在向它繼承的兄弟頁。從 v3.539.36 起,搬移頁拿到自己的複本,間接參照也仍是參照。同一版還封掉兩條相關路徑:數頁共用的間接 box 上的 SetPageBox,以及 CopyPageRanges 讓來源文件的頁面綁在 Pages 節點上、CropBox 又綁在 MediaBox 上
一路通到這裡的回報,從來不會提到物件身分。它們的說法是「我裁了第 7 頁,第 8 到 12 頁也跟著被裁」、「我把 CropBox 縮小,MediaBox 卻跟著動」,還有最讓人摸不著頭腦的:「我把一頁複製到新文件,原始檔卻變了」。沒有崩潰、沒有洩漏,存出來的檔案是完全合法的 PDF,只是多了沒人要的幾何形狀
對一頁 SetPageBox 為什麼會改到兄弟頁的大小?
SetPageBox 會改到兄弟頁,是因為兩個頁面樹條目指向同一個記憶體內陣列,而 SetPageBox 是原地編輯目標陣列。握著同一實例的任何頁或 Pages 節點都看得到這次編輯。v3.539.36 之前,PDFlibPas 有三條程式路徑會造出這種共用:
MovePage在把頁面從父節點拆下之前,先把可繼承屬性落到頁面上,但它掛上去的是祖先自己的物件、不是複本,於是搬移頁與它前兄弟共用一個 box 陣列與一個 Resources 字典SetPageBox會追間接參照、編輯被指向的陣列,所以一個數頁同指/MediaBox 11 0 R物件的檔案,一次呼叫就把那些頁全改了大小,跟MovePage有沒有出場無關CopyPageRanges在把來源頁複製進目標文件之前,先把繼承值落到來源頁上,但它把 Pages 節點的實例掛到來源頁,還把 MediaBox 實例本身順手當成預設 CropBox
MovePage 這一段有段短歷史。v3.539.27 之前,MovePage 只帶 /Resources 過去,搬到不同父節點底下的頁面悄悄換成了那個父節點的大小與旋轉。v3.539.27 補上遺漏的 MediaBox、CropBox 與 Rotate——CollateDocumentsEx 重排頁面時依賴的正是這套——但掛的是以共用實例形式存在的祖先值。v3.539.36 關掉的正是這扇窗。SetPageBox 與 CopyPageRanges 的路徑更老;v3.539.36 之前的任何建置都有
直接值、間接參照與頁面屬性繼承
一份正確的繼承頁面屬性複本,會複製直接值、讓間接參照保持為參照,因為這正是 ISO 32000-1 自己畫下的區分。[0 0 400 300] 這樣寫在字典裡的直接物件,只屬於那個字典。間接物件定義一次為 11 0 obj、引用為 11 0 R,設計上就是共用:ISO 32000-1 §7.3.10 讓它可以從檔案任何地方定址,每個 11 0 R 指的都是同一個物件
頁面屬性繼承(ISO 32000-1 §7.7.3.4)加上第三種情況。Resources、MediaBox、CropBox 與 Rotate 可以放在 Pages 節點上,套用到每一個沒有自訂的後代頁。頁面並不持有那個值,它沿 /Parent 向上查找。頁面一換父節點,這條查找鏈就斷了,這正是 MovePage 與 BalancePageTree 必須先把生效值寫到頁面本身的原因。剩下的問題只是怎麼寫
物件池為什麼會把這個錯誤藏起來
PDFlibPas 裡每個剖析或建立出來的 PDF 物件,都歸文件的 TPDFStructure 池所有,字典與陣列存的是指向條目的普通指標。TPDFDictionary.Add 只記下指標,別的什麼都不管。把一個實例加進兩個父容器,在執行期能檢查的每一層都是合法的:拆除時沒有雙重釋放、沒有會出錯的參照計數、沒有例外。序列化也一樣寬容,每個容器把共用實例的當前值行內寫出,而在任何編輯之前,輸出與正確複本逐位元組相同
別名只有在有人原地變異共用實例時才現形。SetPageBox 透過包在現有陣列上的矩形包裝做的正是這件事,頁面上繪圖、註冊字型或影像時,對 Resources 字典做的也是這件事。編輯無聲無息地落進每一個握著指標的其他容器
PDFlibPas v3.539.36 怎麼用複製取代共用
PDFlibPas v3.539.36 從兩頭修起:實體化現在掛複本,box 寫入現在只編輯頁面自有的陣列。每個修正各自覆蓋另一個顧不到的情況
實體化輔助函式 PLInheritPageAttributes 現在掛上的是 Page.Owner.Decode(Value.Output),不再是 Value。過一趟序列化器是個笨辦法,卻精確地白拿 PDF 語意。直接陣列或字典序列化成它的字面文字,再解碼成一個全新、獨立的實例。間接參照序列化成 11 0 R,解碼成一個仍指向物件 11 的新參照物件,所以頁面依舊引用共用物件、而不是收到一份行內展開的複本,v3.539.27 引入的參照行為因此保住。複製的深度恰好等於直接結構:複製字典裡經由參照才碰得到的東西仍然是共用的,檔案格式本來就這麼打算。BalancePageTree 對它重新掛父的每一頁呼叫同一個輔助函式,在那裡實體化的頁面同樣拿到各自獨立的實例
光複製還不夠,因為參照情況仍指向共用物件。要是 SetPageBox 跟著參照去編輯物件 11,搬移頁又會把舊父節點與它的其他子頁改了大小。所以 box 寫入器現在套用 copy-on-write:只有頁面自己的條目是直接陣列時才原地編輯,間接或缺失的 box 則以新的直接陣列取代。物件 11 原封不動,其他引用它的頁面不受影響
| 程式路徑 | v3.539.36 之前 | v3.539.36 起 |
|---|---|---|
MovePage 實體化 | 頁面握著祖先自己的直接實例 | 頁面握著解碼複本;參照保持為參照 |
SetPageBox | 追參照、編輯共用陣列 | 只編輯頁面上的直接陣列,否則寫入新的 |
CopyPageRanges 來源頁 | 共用 Pages 節點的 box;CropBox 就是 MediaBox 實例 | 來源頁上每個實體化值都是複本 |
| 複製頁面資源時的預設 box | CropBox、BleedBox、TrimBox 與 ArtBox 共用一個陣列 | 每個預設 box 有自己的陣列 |
最後一行是潛伏的那個。函式庫為頁面擷取或合併複製頁面資源時,會補上缺失的 CropBox、BleedBox、TrimBox 與 ArtBox 條目,而這幾個過去是同一個陣列實例。現有的呼叫者沒有人讓這個別名活到被編輯的那一天,但下一個呼叫者就會。這些預設 box 值怎麼選是另一個主題,見 PDFlibPas 的 TrimBox、BleedBox 與 CropBox 預設值指南
用手工打造的 PDF 重現 MovePage 別名
檢查任何 PDFlibPas 建置最快的辦法,是用 LoadFromString 載入一份手工打造的小 PDF,每個物件編號事先都知道。下面的輔助函式寫出經典的交叉參照表、位元組偏移照規則計算,所以測試不必依賴剖析器對損壞檔案的復原行為
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // "N 0 obj" 的 0 起算位元組偏移
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // 每個條目恰好 20 位元組
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
測試文件有兩個中間層 Pages 節點。節點 3 帶間接 MediaBox(物件 11,400 乘 300 points)、直接 CropBox 與直接 Resources 字典,底下有兩頁。節點 4 帶 Letter 大小的 MediaBox,底下是第三頁。把第 1 頁搬到位置 3,等於把它改掛到節點 4 底下——這正是需要實體化的那次搬移:不做實體化,這頁就變成 Letter 頁了
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // 剛搬過來的那頁
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// 在選擇其他頁面「之前」檢查舊父節點(見下文)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // 前第 2 頁,仍在節點 3 之下
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension) 的 box 型別 1 是 MediaBox、2 是 CropBox,維度 2 是寬。在預設的左下角原點下,SetPageBox(1, 0, 200, 200, 200) 意思是左 0、上 200、寬 200、高 200。在 v3.539.27 到 v3.539.35 之間的建置上,兄弟檢查會失敗:CropBox 的編輯落進節點 3 的直接陣列,MediaBox 的編輯則經由參照改寫了物件 11
CopyPageRanges 會改動來源文件嗎?
從 v3.539.36 起,CopyPageRanges 照樣寫來源頁,但它寫下的每個值都是獨立複本,之後對來源的編輯就只留在您編輯的那頁。寫入本身是有意的:來源頁的字典被複製進目標之前,需要明確的 MediaBox、CropBox、Rotate 與 Resources,否則複本會丟掉它繼承的一切。重新編號與把頁面複製進目標的部分,見 PDFlibPas 的跨文件物件深層複製;這個 bug 蹲在來源那一側——多數人以為複製只會讀來源
輸出從來沒露過餡。共用也好、複製也好,實體化值序列化出來一模一樣,所以修正前後,兩份文件存出來逐位元組相同。只有複製之後對來源文件再動一下編輯,別名才現形:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // 會成為目前選取的文件
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // 只收窄 CropBox
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // 複本維持原來的大小
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
v3.539.36 之前,這裡的兩頁都繼承根節點的直接 MediaBox,複製把那個實例掛上來源第 1 頁,又把它再掛一次當第 1 頁的 CropBox。於是收窄 CropBox 就收窄了 MediaBox,改 MediaBox 的大小又經根節點改到第 2 頁。先把頁面複製出來、之後繼續編輯來源的工作流——例如先把雙面掃描整理成一份 PDF、再裁切原始檔——正是這問題現身的地方
實例別名為什麼特別難測?
實例別名難測,因為可觀察的效果需要按特定順序做三步:造出別名、變異其中一側、然後在其他任何東西碰到之前檢查另一側。多數測試只做第一步、比較存出來的輸出,而那份輸出有沒有別名都長一樣
PDFlibPas 裡的順序陷阱是 SelectPage。選擇一頁會經由 SelectFont 重新套用目前字型,順手把那個字型註冊進頁面資源。沒有自己 /Resources 的頁面會解析到父節點的字典,所以光是選中這種頁,就會合理地給 Pages 節點加上 /Font。在上面的 MovePage 測試裡,選中前第 2 頁會把 Helvetica 條目加進節點 3——這是正確行為、不是洩漏。這正是 GetObjectToString(3) 檢查要跑在 SelectPage(1) 之前的原因;兩者對調,修正後的建置反而會測失敗
同一條規則也劃出 v3.539.36 刻意不碰的部分。對一個 Resources 字典靠繼承得來的頁面寫入資源,會寫進祖先的字典,每個兄弟都看得到新條目。這是繼承按規格運作,不是實例共用,而且無害:往共用字典裡加字型或影像名稱,不會改變其他頁的渲染方式。要讓某頁停止繼承,先給它自己的 Resources 字典
PDF 物件模型程式碼的檢查清單
這些教訓可以推廣到任何建立在池與指標容器之上的 PDF 物件模型,Delphi 內外都適用:
- 按 ISO 32000-1 §7.7.3.4 實體化繼承屬性時,深層複製直接值,間接參照則保持為指向同一物件的新參照
- 除非共用是刻意設計並寫進文件,否則絕不把既有實例
Add進第二個容器;歸池管轄意味著執行期永遠不會抗議 - 只原地編輯目前節點以直接物件形式擁有的東西;間接或繼承來的值以全新的直接物件取代(copy-on-write)
- 從另一個條目推導出來的預設值,例如從 MediaBox 來的 CropBox,要有自己的實例
- 測別名要用「變異後檢查另一個持有者」的序列,並核對中間可能合法寫入的呼叫順序
- 比較存出的輸出在這裡證明不了任何事:第一次編輯之前,共用與複製的值序列化得一模一樣
- 用 PDFlibPas 時,只要呼叫過
MovePage、CollateDocumentsEx、BalancePageTree或CopyPageRanges,之後還會編輯頁面 box 或在頁面上繪圖,就升級到 v3.539.36 以上
PDFlibPas 用單一 TPDFlib 類別向 Delphi、C++Builder 與 Free Pascal 開放頁面樹編輯、跨文件複製與頁面 box 控制。版本、平台與完整 API 參考請見 PDFlibPas Delphi PDF 函式庫產品頁