技术文章

PDFlibPas 页面盒子与 TrimBox、BleedBox、CropBox 默认值

页面没有 TrimBox 时,它的生效 TrimBox 就是页面的 CropBox;CropBox 也没有时,就是 MediaBox。BleedBox 与 ArtBox 遵循同样的规则。PDF Library for Delphi(PDFlibPas)从 v3.539.44 起在 GetPageBox、HasPageBox 和 CapturePageEx 里一致地应用这条默认链,并且无视放在 /Pages 节点上的生产盒子,因为 ISO 32000-1 不允许它们继承

这话看着像脚注,直到你开始拼版一个活。设想一本书的内页,MediaBox 是 6.25 × 9.25 英寸,CropBox 设成 6 × 9 英寸的成品尺寸,没有 TrimBox——导出它的人压根没想到要写。你要裁切盒,拿到的却是媒体盒,于是压印版上每个单元格都拖着八分之一英寸的出血和毛边挤进邻居家。PDFlibPas 恰恰在这个区域有过缺陷,v3.539.42 和 v3.539.44 修复,而修复的方式对「任何 PDF 库该怎么实现页面盒子语义」都有话说

页面没有 TrimBox 时,哪个盒子生效?

答案是一条来自 ISO 32000-1 §14.11.2 的固定默认链:CropBox 缺省取 MediaBox,BleedBox、TrimBox 和 ArtBox 各自缺省取 CropBox。只有 CropBox 直接缺省到 MediaBox。所以只定义了 MediaBox 的页面有五个完全相同的盒子,定义了 MediaBox 加 CropBox 的页面有四个盒子等于 CropBox

盒子PDFlibPas BoxType缺失时的默认可从 /Pages 继承
MediaBox1无,该表项必备是
CropBox2MediaBox是
BleedBox3CropBox否
TrimBox4CropBox否
ArtBox5CropBox否

两步链之所以要紧,是因为 CropBox 自己也可能来自继承。既没有自己的 TrimBox 也没有 CropBox 的页面,其生效 TrimBox 是最近的持有 CropBox 的祖先的 CropBox,再不行就是继承来的 MediaBox。规范还有一条容易忘的规则:crop、bleed、trim 和 art 盒子不应超出媒体盒,超出的部分按它们与媒体盒的交集折算。PDFlibPas 按文件里存储的原样报告每个盒子,所以处理不可信输入的校验器应当自己对照 MediaBox 收敛

PDFlibPas 的页面盒子默认链:CropBox 缺省取 MediaBox,BleedBox、TrimBox 与 ArtBox 各自缺省取 CropBox;旁边画着一本内页,MediaBox 450 × 666 磅、CropBox 432 × 648 磅,没有 TrimBox 时后者就是生效的裁切盒
只有 CropBox 直接缺省到 MediaBox,所以只有 MediaBox 的页面有五个完全相同的盒子

/Pages 节点能传下去哪些页面属性?

恰好四个:Resources、MediaBox、CropBox 和 Rotate。ISO 32000-1 §7.7.3.4 定义属性继承,Table 30 只把这四个页面对象表项标为可继承。BleedBox、TrimBox 和 ArtBox 属于叶子页面。写进 /Pages 节点的 TrimBox 不是继承值,而是一个合规范读者会无视的非标准键

这样的非标准文件确实存在,通常是根页面树节点上挂一个 TrimBox,当作“每页都是这个裁切”的简写。这个简写在任何对每个键都顺着 /Parent 找的工具里看着都对——问题就在这:这份文件从此有了两种含义,取决于谁在读。守规范的读者看不到 TrimBox,改用 CropBox;什么都继承的读者看到父节点的值。在印前流水线里,这种歧义最终落在压印版上

PDFlibPas 的页面树继承:只有 Resources、MediaBox、CropBox 和 Rotate 能沿 Pages 节点下传,所以停在根节点上的 TrimBox 是合规范读者会无视的非标准键;v3.539.44 之前两条独立的代码路径都继承它,同一份文档报出两个不同的裁切尺寸
这份文件的含义取决于谁在读,而在印前流水线里,歧义就落在压印版上

PDF/X(ISO 15930)工作流依赖 TrimBox 表达成品尺寸,而且 PDF/X 配置要求每页声明一个 TrimBox 或 ArtBox。停在 /Pages 节点上的盒子满足不了这个要求,因为那个键根本到不了页面对象。Preflight 应当把这类文件标出来,而不是悄悄按某一种方式读过去

v3.539.44 之前 PDFlibPas 错在哪?

PDFlibPas 有三个互不相干的缺陷,全部落在「规范说的话」与「两条独立代码路径干的事」之间的缝隙里。第一个在 v3.539.42 修复,后两个在 v3.539.44

捕获时生产盒子缺省到 MediaBox

v3.539.42 之前,为捕获准备页面的内部例程(它把继承表项拷到页面上,并补齐缺失的盒子)在 BleedBox、TrimBox 和 ArtBox 缺失时给了它们 MediaBox 的值。选项 2 到 4 的 CapturePageEx 恰恰从这些补出来的表项里读包围矩形,所以在只定义了 CropBox 的页面上,要裁切盒捕到的却是整个媒体盒。GetPageBox 早已应用 CropBox 默认,CapturePageEx 的参考文档也一直写着请求的盒子缺失时使用裁切盒;捕获代码跟两者都不对付。从 v3.539.42 起,三个生产盒子缺省到页面的 CropBox——到这一步它已经在页面上了(自己的、从祖先拷来的、或从 MediaBox 补出来的)——只有 CropBox 本身才回退到 MediaBox

两条继承路径,一条语义规则

第二个缺陷是非标准继承本身,微妙之处在于 PDFlibPas 沿两条独立路径解析盒子。盒子查询(GetPageBox 和 HasPageBox)经由一个辅助函数走 /Parent 链,捕获经由另一个本地辅助函数走。两条路径都继承所有键,生产盒子也不例外。只修一条会在同一份文档内部造出矛盾:/Pages 节点上放一个 180 磅宽的 TrimBox、页面上放 380 磅宽的 CropBox 时,GetPageBox 仍报 180 磅的裁切宽度,而 CapturePageEx 造出一个 380 磅宽的表单。v3.539.44 让两条路径都把 /Parent 遍历限制在四个可继承键上,生产盒子只从叶子读,多余的父节点表项原样留在文件里,不删也不改写

PDFlibPas 的 HasPageBox 返回码 0、1、2:v3.539.44 起直接与间接数组都算继承;旁边是 CapturePageEx 的选项 0 到 4,v3.539.42 起 BleedBox、TrimBox 与 ArtBox 回退到 CropBox 而非 MediaBox
一条规范规则的两个实现入口一起修,按 18 个场景的矩阵来测,查询与捕获对每份文件口径一致

HasPageBox 漏掉了直接的父节点数组

HasPageBox 在页面没有所请求类型的盒子时返回 0,页面有自己的盒子(直接存储或经间接引用)时返回 1,MediaBox 或 CropBox 从祖先继承时返回 2。旧代码只在继承值是间接引用时返回 2,于是继承来的直接数组返回 0。修复把解引用与数组测试分开,两种表示现在都返回 2。从 v3.539.44 起,对 BleedBox、TrimBox 或 ArtBox 的 HasPageBox 只可能返回 0 或 1

这条教训远不止页面盒子适用。当一条规范语义在库里有两个实现入口时,把它们一起修、按矩阵测,而不是拿一个顺手的 happy-path 文件。PDFlibPas 的回归集用两种父盒子表示(直接与间接数组)交叉三种叶子状态(缺失、直接数组、间接数组)和三个捕获选项(bleed、trim、art),共 18 个场景,每个场景都核对查询结果、捕获到的边界、合法的 MediaBox 与 CropBox 继承,以及未被动的父节点表项

在 Delphi 里怎么读生效的 TrimBox?

在选中的页面上调 GetPageBox(4, Dimension)。PDFlibPas 替你应用默认链,所以无论页面有没有 TrimBox,结果都是生效 TrimBox。需要知道值从哪来时搭配 HasPageBox——预检报告通常正需要这个

uses
  System.SysUtils, PDFlibrary;

const
  BOX_CROP   = 2;
  BOX_TRIM   = 4;
  DIM_LEFT   = 0;
  DIM_WIDTH  = 2;
  DIM_HEIGHT = 3;
  DIM_BOTTOM = 5;

function DescribeTrim(Lib: TPDFlib; Page: Integer): string;
var
  Source: string;
begin
  Lib.SelectPage(Page);
  if Lib.HasPageBox(BOX_TRIM) = 1 then
    Source := 'own TrimBox'
  else if Lib.HasPageBox(BOX_CROP) <> 0 then   // 1 = 自己的,2 = 继承的
    Source := 'defaulted to the CropBox'
  else
    Source := 'defaulted to the MediaBox';
  Result := Format('page %d: trim %.2f x %.2f pt at (%.2f, %.2f), %s',
    [Page,
     Lib.GetPageBox(BOX_TRIM, DIM_WIDTH),
     Lib.GetPageBox(BOX_TRIM, DIM_HEIGHT),
     Lib.GetPageBox(BOX_TRIM, DIM_LEFT),
     Lib.GetPageBox(BOX_TRIM, DIM_BOTTOM),
     Source]);
end;

var
  Lib: TPDFlib;
  Page: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('interior.pdf', '') = 1 then
      for Page := 1 to Lib.PageCount do
        Writeln(DescribeTrim(Lib, Page));
  finally
    Lib.Free;
  end;
end.

GetPageBox 和 SetPageBox 都按文档当前的坐标设置工作。这里的例子用默认设置:原点 0(左下角,与 PDF 用户空间一致)、单位磅,所以 Top 维度是从页面底边向上量的上边缘。调用 SetOrigin(1) 之后,Top 和 Bottom 维度改为从页面顶边向下量;调用 SetMeasurementUnits(1) 之后,所有值以毫米返回。宽和高与原点无关

找出滞留在 /Pages 节点上的生产盒子

从 v3.539.44 起,盒子 API 不再看到 /Pages 节点上的 TrimBox,这是对的,但预检工具通常想把这类文件报告出来,而不是默默按规范方式读。页面树节点是普通对象,所以低层对象 API 能找到它们:对象号从 1 走到 GetMaxObjectNumber,逐个用 GetObjectToString 读出,找带生产盒子键的 /Pages 字典。检查的后半段是 PDF/X 关心的逐页测试,HasPageBox 现在会像 PDF/X 校验器那样回答它,因为父节点上的 TrimBox 不再计数

procedure PreflightTrim(Lib: TPDFlib; Log: TStrings);
const
  ProductionKeys: array[0..2] of string = ('/BleedBox', '/TrimBox', '/ArtBox');
var
  ObjNum, K, Page, Missing: Integer;
  Src: string;
begin
  // 1. 页面树节点上的生产盒子:非标准且被无视
  for ObjNum := 1 to Lib.GetMaxObjectNumber do
  begin
    Src := '';                                // 空闲对象号不返回文本
    Src := string(Lib.GetObjectToString(ObjNum));
    if Pos('/Type /Pages', Src) = 0 then
      Continue;
    for K := Low(ProductionKeys) to High(ProductionKeys) do
      if Pos(ProductionKeys[K] + ' ', Src) > 0 then
        Log.Add(Format('object %d: %s on a /Pages node is not inheritable',
          [ObjNum, ProductionKeys[K]]));
  end;

  // 2. PDF/X:每页需要自己的 TrimBox 或 ArtBox
  Missing := 0;
  for Page := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(Page);
    if (Lib.HasPageBox(4) = 0) and (Lib.HasPageBox(5) = 0) then
    begin
      Inc(Missing);
      Log.Add(Format('page %d: no TrimBox or ArtBox', [Page]));
    end;
  end;

  // 3. 可选修复:6.25 x 9.25 英寸媒体盒里的 6 x 9 英寸裁切盒
  //    (磅,左下角原点:Left, Top, Width, Height)
  if Missing > 0 then
    Log.Add(Format('TrimBox written on %d pages',
      [Lib.SetPageBoxRange('', 4, 9, 657, 432, 648)]));
end;

文本匹配是务实的检查,不是解析器。它依赖 PDFlibPas 把每个字典表项序列化成「键、一个空格、一个值」,对经 GetObjectToString 读回的对象成立。修复那一步值得三思而非条件反射:父节点上的多余值没准正是作者想要的,但把它转正之前先对着工单确认。空范围的 SetPageBoxRange 把盒子应用到每一页,返回更新的页数。页面上已有的盒子是间接数组(可能被另一页面或 /Pages 节点共享)时,SetPageBox 会给这页一个新的直接数组,而不是改写共享对象。设置 BleedBox、TrimBox 或 ArtBox 还会把未锁版本的文档升到 PDF 1.3——引入这些表项的版本

用 CapturePageEx 按 TrimBox 拼版

CapturePageEx(Page, 3) 把一页变成 Form XObject,其包围盒是该页的生效 TrimBox,DrawCapturedPage 把这个表单以任意尺寸放到另一页上。从 v3.539.42 起,在没有 TrimBox 的页面上用选项 3 得到的是 CropBox——参考文档是这么写的——而不是拖着全部毛边的 MediaBox

捕获有两个性质左右着代码写法。捕获是破坏性的:被捕获的页面从文档移除,而文档永远不能降到零页,所以捕获任何东西之前先把第一张输出版追加进去。捕获还只在单个文档内部工作,所以先把所有输入并进一个文档;一次完成 PDF 源的整理与交错的技巧可以直接套用

procedure ImposeTwoUp(const InFile, OutFile: string);
var
  Lib: TPDFlib;
  Captures: array of Integer;
  SourceCount, I: Integer;
  TrimW, TrimH: Double;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(InFile, '') <> 1 then
      raise Exception.Create('Cannot open ' + InFile);
    SourceCount := Lib.PageCount;

    // 第 1 页的生效裁切尺寸(此版式假设裁切统一)
    Lib.SelectPage(1);
    TrimW := Lib.GetPageBox(4, 2);
    TrimH := Lib.GetPageBox(4, 3);

    // 追加第一张输出版并定尺寸;NewPage 会选中新页
    Lib.NewPage;
    Lib.SetPageDimensions(2 * TrimW, TrimH);

    // 每次捕获都移除第 1 页,所以下一个源页面顶上来
    SetLength(Captures, SourceCount);
    for I := 0 to SourceCount - 1 do
    begin
      Captures[I] := Lib.CapturePageEx(1, 3);   // 3 = TrimBox
      if Captures[I] = 0 then
        raise Exception.CreateFmt('Capture of source page %d failed', [I + 1]);
    end;

    // 只剩输出版:每版两页裁切后的页面,并排
    Lib.SelectPage(1);
    for I := 0 to SourceCount - 1 do
    begin
      if (I > 0) and (I mod 2 = 0) then
        Lib.NewPage;                            // 与当前输出版同尺寸
      // 默认原点:Top 是上边缘,从底边向上量
      Lib.DrawCapturedPage(Captures[I], (I mod 2) * TrimW, TrimH, TrimW, TrimH);
    end;
    Lib.SaveToFile(OutFile);
  finally
    Lib.Free;
  end;
end;

按裁切盒捕获会裁掉 TrimBox 之外的一切——对数码打样或裁切后堆叠的版式正合适。对印后要裁切的压印版,用选项 2 捕获让出血活下来,单元格间距按出血宽度留。因为捕获会移除源页面,指向它们的书签和链接失去目标,所以拼进单独的输出文件,别动还需要导航的文档;换页而不弄坏书签覆盖页面手术的这一面

源文档必须保持完好时,ImportPageAsFormXObject(SourceDocumentID, SourcePage, Options) 接受同样的 0 到 4 选项值(当前文档传 Lib.SelectedDocument),源页面树保持不动,把继承的页面旋转归一化进表单矩阵,返回 DrawCapturedPage 接受的句柄。CapturePageEx 不撤销 /Rotate,所以旋转过的输入得先做那一步,摊平页面旋转而不弄坏页面盒子展示了每个盒子在此过程中的遭遇。对可能在 /Pages 节点上带生产盒子的输入提个醒:导入路径经由它自己的祖先查找解析盒子,与 v3.539.44 对齐的那两条路径是分开的,所以先在源页面上查 HasPageBox(4),返回 0 就传选项 1(CropBox)。这让结果系于规范,而不是系于文件碰巧怎么写

页面盒子速查

  • 生效 CropBox:页面自己的 CropBox,否则最近的继承 CropBox,否则生效 MediaBox(ISO 32000-1 §14.11.2)
  • 生效 BleedBox、TrimBox 与 ArtBox:叶子页面自己的表项,否则生效 CropBox
  • 只有 Resources、MediaBox、CropBox 和 Rotate 从 /Pages 节点继承(§7.7.3.4,Table 30);/Pages 节点上的生产盒子被无视
  • GetPageBox(BoxType, Dimension):BoxType 1 MediaBox、2 CropBox、3 BleedBox、4 TrimBox、5 ArtBox;Dimension 0 Left、1 Top、2 Width、3 Height、4 Right、5 Bottom
  • HasPageBox(BoxType):0 无盒子,1 页面自己的盒子(直接或间接),2 继承来的 MediaBox 或 CropBox(直接或间接)
  • CapturePageEx(Page, Options):0 MediaBox,1 CropBox(回退 MediaBox),2 到 4 BleedBox、TrimBox 或 ArtBox(回退 CropBox)
  • 盒子查询与捕获要默认值一致、继承一致,升级到 v3.539.44 或更高

页面盒子是 PDF 安静的默认值与按毫米小数计量的印前公差相遇的地方,一个库要么处处以同一方式应用这些默认值,要么给你同一个问题的两个答案。完整的盒子、捕获与 Form XObject API 见 PDFlibPas PDF Library for Delphi 产品页