技術文章

PDFlibPas 頁面框預設鏈:TrimBox、BleedBox 與 CropBox

PDF 頁面沒有 TrimBox 時,它的生效 TrimBox 就是該頁的 CropBox;CropBox 也沒有時,就是 MediaBox。BleedBox 與 ArtBox 走同一條規則。Delphi 的 PDF Library——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,旁邊畫著一本書的內文版——450 × 666 點的 MediaBox 與 432 × 648 點的 CropBox,沒有 TrimBox 時後者就是生效的裁切框
只有 CropBox 直接預設到 MediaBox,所以只有 MediaBox 的頁面有五個一模一樣的框

/Pages 節點能往下傳哪些頁面屬性?

恰好四個:Resources、MediaBox、CropBox 與 Rotate。ISO 32000-1 §7.7.3.4 定義屬性繼承,表 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 的參考文件也一直寫著「請求的框缺失時使用 crop box」;擷取程式碼跟兩邊都對不上。從 v3.539.42 起,三個生產框預設為該頁的 CropBox——到了那一步,CropBox 已經在頁上(自己的、從祖先複製的、或從 MediaBox 補出來的)——只有 CropBox 本身才後備到 MediaBox

兩條繼承路徑,一條語意規則

第二個缺陷就是那個非標準繼承本身,而微妙之處在於 PDFlibPas 沿兩條獨立的路徑解析框。框查詢(GetPageBox 與 HasPageBox)經一個輔助函式走 /Parent 鏈,擷取則經另一個獨立的區域輔助函式。兩邊什麼鍵都繼承,生產框在內。只修一邊的話,同一份文件內部就會自相矛盾:/Pages 節點上是 180 點寬的 TrimBox、頁面上是 380 點寬的 CropBox,GetPageBox 仍報裁切寬 180,CapturePageEx 卻建出 380 寬的 form。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

這課遠不止頁面框。一條規格語意在函式庫裡有兩個實作進入點時,要一起修、按矩陣測,而不是拿一個一切都順的檔案交差。PDFlibPas 的迴歸集把兩種父框表示法(直接與間接陣列)交叉三種葉狀態(缺席、直接陣列、間接陣列)與三個擷取選項(bleed、trim、art),得 18 個情境,每個情境都檢查查詢結果、擷取邊界、合法的 MediaBox 與 CropBox 繼承,以及未被動過的父條目

在 Delphi 裡怎麼讀生效的 TrimBox?

在選中的頁面上呼叫 GetPageBox(4, Dimension)。PDFlibPas 會替您套預設鏈,所以不管頁面有沒有 TrimBox,結果都是生效 TrimBox。需要知道值從哪來時,搭配 HasPageBox,preflight 報告通常就要這個

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 user space 一致)、度量單位為 points,所以 Top 維度是自頁面底部往上量的上緣。SetOrigin(1) 之後,Top 與 Bottom 維度改為自頁面頂部往下量;SetMeasurementUnits(1) 之後,每個值都以公釐回傳。寬與高不依賴原點

找出滯留在 /Pages 節點上的生產框

從 v3.539.44 起,框 API 不再看得到 /Pages 節點上的 TrimBox,這是對的,但 preflight 工具通常想把這種檔案報告出來,而不是默默按規格讀法讀過去。頁面樹節點是普通物件,低階物件 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 英吋裁切
  //    (points、左下角原點: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 再以任意大小把這個 form 擺上另一頁。從 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)、不動來源的頁面樹、把繼承的頁面旋轉正規化進 form 矩陣,並回傳 DrawCapturedPage 收得的 handle。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,表 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 產品頁