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

PDFlibPas MovePage: наследуемые боксы и общие инстансы

В PDFlibPas, PDF-библиотеке для Delphi, страница, переносимая через MovePage, раньше получала ровно те же объекты MediaBox, CropBox и Resources, что держал её старый узел Pages, поэтому последующий SetPageBox или DrawText на перенесённой странице молча переписывал этот узел и всех соседей, всё ещё наследующих от него. Начиная с v3.539.36 перенесённая страница получает собственные копии, а косвенная ссылка остаётся ссылкой. Тот же релиз закрыл два смежных пути: SetPageBox на косвенном боксе, который делят несколько страниц, и CopyPageRanges, оставлявший страницы исходного документа привязанными к их узлу Pages, а CropBox — к MediaBox

Отчёты, которые приводят сюда, никогда не упоминают идентичность объектов. Они звучат как «обрезал страницу 7, а страницы с 8 по 12 обрезались тоже», или «сузил CropBox, а MediaBox уехал вместе с ним», или, самое сбивающее с толку, «скопировал страницу в новый документ, а исходный файл изменился». Ничего не падает, ничего не течёт, и сохранённый файл — совершенно валидный PDF. Просто в нём геометрия, которую никто не заказывал

Почему SetPageBox на одной странице меняет размер соседей?

SetPageBox менял размер соседей, потому что две записи дерева страниц указывали на один массив в памяти, а SetPageBox правит свой целевой массив на месте. Любая страница или узел Pages, держащая тот же инстанс, видела правку. Три кодовых пути в PDFlibPas порождали это разделение до v3.539.36:

  • MovePage материализует наследуемые атрибуты на страницу перед отцеплением от родителя и прикреплял собственные объекты предка вместо копий, так что перенесённая страница и её бывшие соседи делили массив бокса и словарь Resources
  • SetPageBox следовал по косвенным ссылкам и правил массив, на который ссылка указывает, так что в файле, где несколько страниц указывают на один объект /MediaBox 11 0 R, один вызов менял размер всех этих страниц — вовлечён ли MovePage или нет
  • CopyPageRanges материализует наследуемые значения на исходной странице перед клонированием её в целевой документ и прикреплял инстансы узла Pages к исходной странице, плюс сам инстанс MediaBox как дефолтный CropBox
Алиасинг MovePage в PDFlibPas: перенесённая страница и её бывший сосед держали один и тот же инстанс массива 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 записывает указатель и ничего больше. Добавить один инстанс в два родительских контейнера, значит, легально на каждом уровне, который способен проверить рантайм: никакого double free при разборке, никакого счётчика ссылок, который мог бы сбиться, никакого исключения. Сериализация столь же снисходительна: каждый контейнер пишет текущее значение общего инстанса инлайн, и до любой правки вывод побайтово совпадает с тем, что произвела бы корректная копия

Алиасинг всплывает, лишь когда кто-то мутирует общий инстанс на месте. 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, перенесённая страница снова меняла бы размер старого родителя и его остальных детей. Поэтому писатель боксов теперь применяет copy-on-write: правит на месте, только когда собственная запись страницы — прямой массив, а косвенный или отсутствующий бокс заменяет новым прямым массивом. Объект 11 остаётся нетронутым для всех остальных страниц, которые на него ссылаются

Решение copy-on-write в SetPageBox для PDFlibPas: когда собственная запись страницы — прямой массив, она правится на месте, а при косвенной ссылке или отсутствии писатель заменяет её новым прямым массивом, так что общий объект 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

Воспроизводим алиасинг MovePage самодельным PDF

Быстрее всего проверить любую сборку PDFlibPas на маленьком рукописном PDF, загруженном через LoadFromString, где каждый номер объекта известен заранее. Хелпер ниже пишет классическую cross-reference таблицу с честно посчитанными байтовыми смещениями, так что тест не полагается на восстановительное поведение парсера для битых файлов

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" от нуля
    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 формата Letter и владеет третьей страницей. Перенос страницы 1 на позицию 3 перепривязывает её под узел 4 — ровно тот ход, которому нужна материализация: без неё страница превратилась бы в Letter

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; этот баг сидел на стороне исходника, которую большинство считает read-only при копировании

Вывод ничего не показывал. Общие или скопированные значения сериализуются одинаково, так что оба документа сохранялись побайтово одинаково до и после правки. Алиас выдавала только правка исходного документа после копирования:

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 и прикрепляла его же как CropBox страницы 1. Сужение CropBox, значит, сужало и MediaBox, а изменение размера MediaBox меняло размер страницы 2 через корневой узел. Рабочие процессы, которые вынимают страницы копированием и продолжают править исходник, — например сшивка дуплексных сканов в один PDF перед обрезкой оригиналов, — именно там это и всплывало

Почему алиасинг инстансов так трудно тестировать?

Алиасинг инстансов трудно тестировать, потому что наблюдаемый эффект требует трёх шагов в определённом порядке: создать алиас, мутировать одну сторону, затем осмотреть другую раньше, чем её коснётся что-то ещё. Большинство тестов делает только первый шаг и сравнивает сохранённый вывод, который идентичен независимо от того, существует алиас или нет

Ловушка порядка в PDFlibPas — SelectPage. Выбор страницы заново применяет текущий шрифт через SelectFont, а тот регистрирует шрифт в ресурсах страницы. Страница без собственного /Resources разрешается в словарь родителя, так что простое открытие такой страницы легитимно добавляет /Font в узел Pages. В тесте MovePage выше открытие бывшей страницы 2 добавляет запись Helvetica в узел 3 — это корректное поведение, а не утечка. Поэтому проверка GetObjectToString(3) идёт до SelectPage(1); поменяйте их местами, и тест упадёт на починенной сборке

То же правило очерчивает то, что v3.539.36 нарочно не трогает. Запись ресурса на страницу, наследующую словарь Resources, пишет в словарь предка, и каждый сосед видит новую запись. Это наследование, работающее как указано, а не разделение инстансов, и оно безвредно: добавление имени шрифта или картинки в общий словарь не меняет рендеринг других страниц. Если странице нужно перестать наследовать, сперва дайте ей собственный словарь Resources

Памятка для кода объектной модели PDF

Уроки обобщаются на любую объектную модель PDF, построенную на пуле и контейнерах-указателях, в Delphi или где угодно ещё:

  • Материализуя наследуемые атрибуты по ISO 32000-1 §7.7.3.4, глубоко копируйте прямые значения и сохраняйте косвенные ссылки как новые ссылки на тот же объект
  • Никогда не делайте Add существующего инстанса во второй контейнер, если разделение не задумано и не задокументировано; владение пулом означает, что рантайм никогда не пожалуется
  • Правьте на месте только то, чем текущий узел владеет как прямым объектом; косвенные или наследуемые значения заменяйте свежим прямым объектом (copy-on-write)
  • Дефолтные значения, выведенные из другой записи, — CropBox из MediaBox, — нуждаются в собственном инстансе
  • Тестируйте алиасинг последовательностями «мутируй — потом осматривай» на другом держателе и проверяйте порядок вызовов, которые могут легитимно писать между делом
  • Сравнение сохранённого вывода здесь ничего не доказывает: общие и скопированные значения сериализуются одинаково до первой правки
  • В PDFlibPas обновляйтесь до v3.539.36 или новее, если зовёте MovePage, CollateDocumentsEx, BalancePageTree или CopyPageRanges, а затем правите боксы страниц или рисуете на страницах

PDFlibPas открывает редактирование дерева страниц, междокументное копирование и управление боксами страниц через один класс TPDFlib для Delphi, C++Builder и Free Pascal. Издания, платформы и полная справка API — на странице продукта PDFlibPas Delphi PDF library