在 Delphi PDF 库 PDFlibPas 里,用 MovePage 移动的页面过去会原封不动接过旧 Pages 节点持有的那同一个 MediaBox、CropBox 和 Resources 对象,于是之后对这页的 SetPageBox 或 DrawText 会无声改写那个节点,连同仍在继承它的每个同级页面。从 v3.539.36 起,被移动的页面拿到自己的副本,间接引用仍保持为引用。同一版本还堵上两条相关路径:多条页面共享的间接盒子上的 SetPageBox,以及 CopyPageRanges 把源文档页面拴在它们的 Pages 节点上、把 CropBox 拴在 MediaBox 上
把人引到这里的报告从不提对象同一性。它们说的是“我裁了第 7 页,第 8 到 12 页也跟着裁了”,或者“我把 CropBox 收窄,MediaBox 跟着动了”,最让人摸不着头脑的是“我把一页复制进新文档,原始文件却变了”。什么都不崩溃,什么都不泄漏,保存出的文件是完全合法的 PDF,只是里面装了没人要的几何尺寸
为什么在一页上 SetPageBox 会改兄弟页面的尺寸?
SetPageBox 会波及兄弟页面,是因为两个页面树表项指向同一个内存中的数组,而 SetPageBox 就地编辑它的目标数组。持有同一实例的任何页面或 Pages 节点都会看到这次编辑。v3.539.36 之前,PDFlibPas 里有三条代码路径会造出这种共享:
MovePage在把页面从父节点摘下之前,会先把可继承属性物化到页面上,而它附着的是祖先自己的对象而非副本,于是被移动的页面与前同级页面共享同一个盒子数组和 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 从两头修起:物化现在附着副本,盒子写入只编辑页面自己拥有的数组。每个修复各自覆盖另一个够不到的情形
物化辅助函数 PLInheritPageAttributes 现在附着 Page.Owner.Decode(Value.Output),而不是 Value。过一遍序列化器再回来,是个笨但准的办法,白拿 PDF 语义。直接数组或字典序列化成字面文本,解码成全新独立实例。间接引用序列化成 11 0 R,解码成指向同一个对象 11 的新引用对象,页面仍然引用共享对象而不是收下一份内联副本——v3.539.27 引入的引用行为得以保留。副本的深度恰好等于直接结构的深度:复制出的字典里经引用能摸到的东西仍然共享,文件格式本来就是这个意图。BalancePageTree 对它重新挂父的每页调用同一个辅助函数,在那边物化的页面同样拿到独立实例
只复制还不够,因为引用情形仍指向共享对象。如果 SetPageBox 顺着引用去编辑对象 11,被移动的页面又会改写旧父节点及其其他子节点的尺寸。所以盒子写入器现在用写时复制:只有页面自己的表项是直接数组时才就地编辑,间接或缺失的盒子则替换成新的直接数组。对象 11 对引用它的其他页面原封不动
| 代码路径 | v3.539.36 之前 | v3.539.36 起 |
|---|---|---|
MovePage 物化 | 页面持有祖先自己的直接实例 | 页面持有解码副本;引用仍是引用 |
SetPageBox | 顺着引用编辑共享数组 | 只编辑页面上的直接数组,否则写入新数组 |
CopyPageRanges 源页面 | 共享 Pages 节点盒子;CropBox 就是 MediaBox 实例 | 源页面上每个物化出来的值都是副本 |
| 克隆页面资源时的默认盒子 | CropBox、BleedBox、TrimBox 与 ArtBox 共享一个数组 | 每个默认盒子有自己的数组 |
最后一行是潜伏的那颗雷。库为页面捕获或合并而克隆页面资源时,会补齐缺失的 CropBox、BleedBox、TrimBox 和 ArtBox 表项,而这些过去是同一个数组实例。现有的调用者没有让这个别名活到被编辑那一刻,但下一个调用者就会。这些默认盒子值怎么选是独立话题,见 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 磅)、直接 CropBox 和直接 Resources 字典,管着两页。节点 4 带信纸尺寸的 MediaBox,管第三页。把第 1 页移到位置 3 会把它重新挂到节点 4 之下,这正是需要物化的那一步移动:不物化,这页就会变成信纸页
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) 的盒子类型 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进第二个容器,除非共享是刻意的且写进了文档;归池所有意味着运行时永远不会抱怨 - 只就地编辑当前节点以直接对象形式拥有的东西;间接或继承的值换成新的直接对象(写时复制)
- 从另一表项派生的默认值——比如从 MediaBox 派生的 CropBox——需要自己的实例
- 测试别名时用“先改动、后检查另一持有者”的序列,并核对中间可能合法写入的调用顺序
- 在这里比较保存的输出什么也证明不了:共享值与副本值在第一次编辑之前序列化结果完全相同
- 用 PDFlibPas 的话,只要调过
MovePage、CollateDocumentsEx、BalancePageTree或CopyPageRanges,之后还要编辑页面盒子或在页面上绘图,就升级到 v3.539.36 或更高
PDFlibPas 通过一个 TPDFlib 类向 Delphi、C++Builder 和 Free Pascal 暴露页面树编辑、跨文档复制与页面盒子控制。版本、平台与完整 API 参考见 PDFlibPas Delphi PDF 库产品页