技术文章

PDFlibPas MovePage:继承盒子何时共享实例

在 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 附了上去
PDFlibPas 的 MovePage 别名问题:被移动的页面与前同级页面都持有祖先自己的 MediaBox 数组实例,SetPageBox 编辑一页却改了另一页的尺寸;v3.539.36 起物化附着解码副本,编辑只落在你触碰的那一页
两个页面树表项指向同一个内存数组,每次编辑都落到每个持有者身上,而保存出的 PDF 自始至终都合法

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 对它重新挂父的每页调用同一个辅助函数,在那边物化的页面同样拿到独立实例

PDFlibPas 的物化往返:PLInheritPageAttributes 附着 Page.Owner.Decode(Value.Output)——直接数组序列化成字面文本再解码成全新实例,间接的 11 0 R 序列化并解码成仍指向共享对象 11 的新引用
序列化再重解析免费拿到 PDF 对象语义:直接值复制,引用仍是引用,与 ISO 32000-1 的意图一致

只复制还不够,因为引用情形仍指向共享对象。如果 SetPageBox 顺着引用去编辑对象 11,被移动的页面又会改写旧父节点及其其他子节点的尺寸。所以盒子写入器现在用写时复制:只有页面自己的表项是直接数组时才就地编辑,间接或缺失的盒子则替换成新的直接数组。对象 11 对引用它的其他页面原封不动

PDFlibPas SetPageBox 的写时复制决策:页面自己的表项是直接数组时就地编辑,是间接引用或缺失时写入新的直接数组,共享对象 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 库产品页