Техническая статья

Боксы страниц PDFlibPas: дефолты TrimBox, BleedBox, CropBox

Когда у страницы PDF нет TrimBox, её эффективный TrimBox — это CropBox страницы, а когда нет и CropBox — MediaBox. BleedBox и ArtBox следуют тому же правилу. PDFlibPas, PDF Library for Delphi, применяет эту цепочку дефолтов единообразно в GetPageBox, HasPageBox и CapturePageEx с v3.539.44 и игнорирует производственные боксы, положенные на узел /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. Прямо к MediaBox по умолчанию откатывается только CropBox. Страница, определившая один MediaBox, стало быть, имеет пять одинаковых боксов, а страница с MediaBox плюс CropBox — четыре бокса, равных CropBox

БоксBoxType в PDFlibPasДефолт при отсутствииНаследуется от /Pages
MediaBox1Нет, запись обязательнаДа
CropBox2MediaBoxДа
BleedBox3CropBoxНет
TrimBox4CropBoxНет
ArtBox5CropBoxНет

Двухступенчатая цепочка важна, потому что CropBox сам может быть унаследованным. Эффективный TrimBox страницы без собственного TrimBox и без собственного CropBox — это CropBox ближайшего предка, у которого он есть, а в его отсутствие — унаследованный MediaBox. Спецификация добавляет ещё одно легко забываемое правило: crop-, bleed-, trim- и art-боксы не должны вылезать за media box, а если вылезают, фактически ужимаются до пересечения с ним. PDFlibPas отчитывает каждый бокс так, как он записан в файле, так что валидатору недоверенного ввода стоит клампить по MediaBox самостоятельно

Цепочка дефолтов боксов страниц в PDFlibPas, где CropBox по умолчанию равен MediaBox, а BleedBox, TrimBox и ArtBox каждый — CropBox, рядом с книжным блоком с MediaBox 450 на 666 пунктов и CropBox 432 на 648 пунктов, который становится эффективным обрезом при отсутствии TrimBox
Прямо к MediaBox откатывается только CropBox, поэтому страница с одним MediaBox имеет пять одинаковых боксов

Какие атрибуты страницы может передавать узел /Pages?

Ровно четыре: Resources, MediaBox, CropBox и Rotate. ISO 32000-1 §7.7.3.4 определяет наследование атрибутов, и Table 30 помечает как наследуемые только эти четыре записи объекта-страницы. BleedBox, TrimBox и ArtBox принадлежат листовой странице. TrimBox, записанный в узел /Pages, — не унаследованное значение; это нестандартный ключ, который корректный читатель игнорирует

Нестандартные файлы вроде таких существуют, обычно с единственным 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 должен флагать такие файлы, а не тихо читать их тем или иным способом

Что PDFlibPas делал не так до v3.539.44?

У PDFlibPas было три отдельных дефекта, все — в зазоре между тем, что говорит спецификация, и тем, что делали два независимых кодовых пути. Первый исправлен в v3.539.42, остальные два — в v3.539.44

Производственные боксы откатывались к MediaBox при захвате

До v3.539.42 внутренняя процедура, готовящая страницу к захвату (она копирует наследуемые записи на страницу и дозаполняет отсутствующие боксы), выдавала BleedBox, TrimBox и ArtBox значения MediaBox при их отсутствии. CapturePageEx с опциями 2–4 читает свою ограничивающую рамку ровно из этих дозаполненных записей, так что на странице с одним лишь CropBox запрос обрезного бокса захватывал весь медиа-бокс. GetPageBox уже применял дефолт CropBox, и справка CapturePageEx всегда говорила, что при отсутствии запрошенного бокса используется crop box; код захвата расходился с обоими. С v3.539.42 три производственных бокса откатываются к CropBox страницы — который к тому моменту уже на странице (собственный, скопированный с предка или дозаполненный из MediaBox), — и лишь сам CropBox откатывается к MediaBox

Два пути наследования, одно семантическое правило

Второй дефект — само нестандартное наследование, и тонкость была в том, что PDFlibPas разрешал боксы по двум независимым путям. Запросы боксов (GetPageBox и HasPageBox) ходили по цепочке /Parent через один хелпер, а захват — через отдельный локальный. Оба наследовали каждый ключ, включая производственные боксы. Починка только одного из них породила бы противоречие внутри одного документа: с TrimBox шириной 180 пунктов на узле /Pages и CropBox шириной 380 пунктов на странице GetPageBox продолжал бы отчитывать ширину обреза 180, пока CapturePageEx строил форму шириной 380. В v3.539.44 оба пути ограничивают проход по /Parent четырьмя наследуемыми ключами, производственные боксы читаются только с листа, а блуждающая родительская запись остаётся в файле нетронутой — ни не удалённой, ни переписанной

Коды возврата HasPageBox в PDFlibPas — ноль, один и два, где прямой и косвенный массивы оба считаются унаследованными с v3.539.44, рядом с опциями CapturePageEx от нуля до четырёх, где BleedBox, TrimBox и ArtBox откатываются к CropBox вместо MediaBox с v3.539.42
Две точки входа реализации одного правила спецификации чинятся вместе и тестируются матрицей из 18 сценариев, где запрос и захват сходятся на каждом файле

HasPageBox пропускал прямые родительские массивы

HasPageBox возвращает 0, когда у страницы нет бокса запрошенного типа, 1 — когда у страницы собственный бокс (записанный напрямую или через косвенную ссылку) и 2 — когда MediaBox или CropBox унаследован от предка. Старый код возвращал 2, только если унаследованное значение было косвенной ссылкой, так что унаследованный прямой массив возвращал 0. Правка отделяет разыменование от теста на массив, и обе формы теперь возвращают 2. С v3.539.44 HasPageBox для BleedBox, TrimBox или ArtBox может вернуть только 0 или 1

Урок обобщается далеко за пределы боксов страниц. Когда одна часть семантики спецификации имеет две точки входа реализации в библиотеке, чините их вместе и тестируйте матрицей, а не одним happy-path файлом. Регрессионный набор PDFlibPas скрещивает две формы родительского бокса (прямой и косвенный массив) с тремя состояниями листа (отсутствует, прямой массив, косвенный массив) и тремя опциями захвата (bleed, trim, art), давая 18 сценариев, и каждый проверяет результат запроса, захваченные границы, легитимное наследование MediaBox и CropBox и нетронутую родительскую запись

Как прочитать эффективный TrimBox в Delphi?

Вызовите GetPageBox(4, Dimension) на выбранной странице. PDFlibPas применяет цепочку дефолтов за вас, так что результат — эффективный 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 (левый нижний угол, как в user space PDF) и пункты в качестве единиц, так что размерность Top — верхний край, отмеренный вверх от низа страницы. После SetOrigin(1) размерности Top и Bottom отмеряются вниз от верха страницы, а после SetMeasurementUnits(1) каждое значение возвращается в миллиметрах. Ширина и высота от начала не зависят

Ищем производственные боксы, застрявшие на узлах /Pages

С v3.539.44 бокс-API больше не видит TrimBox на узле /Pages, и это правильно, но инструменту preflight обычно нужно отчитать такой файл, а не молча читать его по спецификации. Узлы дерева страниц — обычные объекты, так что низкоуровневый объектный API способен их найти: переберите номера объектов до 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 x 9 дюймов внутри медиа-бокса 6.25 x 9.25
  //    (пункты, начало в левом нижнем углу: 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 — версии, которая ввела эти записи

Спуск полос по TrimBox с CapturePageEx

CapturePageEx(Page, 3) превращает страницу в Form XObject, чья ограничивающая рамка — эффективный TrimBox страницы, а DrawCapturedPage кладёт эту форму на другую страницу в любом размере. С v3.539.42 опция 3 на странице без TrimBox даёт вам 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 — именно это нужно для цифрового корректора или раскладки cut-and-stack. Для печатного листа, обрезаемого после печати, захватывайте с опцией 2, чтобы вылеты выжили, и раскладывайте ячейки с шагом в ширину вылета. Поскольку захват удаляет исходные страницы, закладки и ссылки, указывавшие на них, теряют цели, так что спускайте в отдельный выходной файл, а не правьте документ, чья навигация вам ещё нужна; замена страниц без поломки закладок разбирает эту сторону страничной хирургии

Когда исходник обязан остаться целым, ImportPageAsFormXObject(SourceDocumentID, SourcePage, Options) принимает те же значения опций 0–4 (передавайте Lib.SelectedDocument для текущего документа), оставляет дерево страниц исходника неизменным, нормализует унаследованный поворот страницы в матрицу формы и возвращает хэндл, который принимает DrawCapturedPage. CapturePageEx не отменяет /Rotate, так что повёрнутому вводу нужен сперва тот шаг, а сплющивание поворота страницы без поломки боксов показывает, что происходит с каждым боксом при этом. Одно предостережение для вводов, могущих нести производственные боксы на узлах /Pages: путь импорта разрешает свой бокс через собственный поиск по предкам, отдельный от двух путей, выровненных в v3.539.44, так что сперва проверьте HasPageBox(4) на исходной странице и передавайте опцию 1 (CropBox), если она вернёт 0. Это привязывает результат к спецификации, а не к тому, как файл случайно записали

Шпаргалка по боксам страниц

  • Эффективный CropBox: собственный CropBox страницы, иначе ближайший унаследованный CropBox, иначе эффективный MediaBox (ISO 32000-1 §14.11.2)
  • Эффективные BleedBox, TrimBox и ArtBox: собственная запись листовой страницы, иначе эффективный CropBox
  • От узлов /Pages наследуются только Resources, MediaBox, CropBox и Rotate (§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 встречаются с допечатными допусками, меряемыми долями миллиметра, и библиотека либо применяет эти дефолты одинаково всюду, либо выдаёт два ответа на один вопрос. Полный API боксов, захвата и Form XObject задокументирован на странице продукта PDFlibPas PDF Library for Delphi