В 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материализует наследуемые атрибуты на страницу перед отцеплением от родителя и прикреплял собственные объекты предка вместо копий, так что перенесённая страница и её бывшие соседи делили массив бокса и словарь ResourcesSetPageBoxследовал по косвенным ссылкам и правил массив, на который ссылка указывает, так что в файле, где несколько страниц указывают на один объект/MediaBox 11 0 R, один вызов менял размер всех этих страниц — вовлечён лиMovePageили нетCopyPageRangesматериализует наследуемые значения на исходной странице перед клонированием её в целевой документ и прикреплял инстансы узла Pages к исходной странице, плюс сам инстанс MediaBox как дефолтный CropBox
У случая 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 зовёт тот же хелпер для каждой перепривязываемой страницы, так что материализованные там страницы тоже получают отдельные инстансы
Одного копирования мало, потому что ссылочный случай всё ещё указывает на общий объект. Если бы SetPageBox шёл по этой ссылке и правил объект 11, перенесённая страница снова меняла бы размер старого родителя и его остальных детей. Поэтому писатель боксов теперь применяет copy-on-write: правит на месте, только когда собственная запись страницы — прямой массив, а косвенный или отсутствующий бокс заменяет новым прямым массивом. Объект 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