Слейте два PDF вручную, перенесите один объект страницы в целевой документ — и копирование сразу упирается в нарушение доступа. PDFlibPas исправляет это в CopyForeignObject: метод глубоко копирует один косвенный объект вместе со всем его ссылочным замыканием и сводит циклические обратные ссылки вроде /Parent к null вместо рекурсии
Почему копирование одной страницы между документами падает?
Потому что дерево страниц PDF — дерево, только пока вы читаете его сверху вниз. Пройдите его так, как идёт рекурсивный копировщик, следуя за каждым значением в каждом словаре, и словарь страницы выдаст вам /Parent, указывающий назад на узел /Pages, откуда вы пришли, а тот узел выдаст /Kids, указывающий назад на страницу. ISO 32000-1 §7.7.3 требует /Parent на каждом узле дерева страниц, кроме корня, так что это не повреждённый файл, который можно отклонить, — это нормальная форма любого документа, который вам когда-либо достанется
Вторая половина проблемы — нумерация. Косвенные объекты идентифицируются номером объекта, локальным для одного файла (ISO 32000-1 §7.3.10), поэтому объект, перенесённый из документа A в документ B, надо перенумеровать, и каждую ссылку на него внутри скопированного замыкания надо перенумеровать тем же способом, иначе две ссылки, которые раньше указывали на один общий шрифт, теперь укажут на две несвязанные вещи. Эта перенумерация — та же работа, которую быстрое слияние выполняет на уровне байтов, и стоит прочесть о них рядом: сдвиг ссылок на уровне байтов для быстрого слияния PDF решает задачу трансляцией целых файлов, тогда как копирование на уровне объектов решает её по одному ребру за раз
Что именно копирует CopyForeignObject в PDFlibPas
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) клонирует один косвенный объект и всё достижимое из него — вложенные словари, массивы, строки, имена, числа и потоки с нетронутыми их словарями — в текущий выбранный документ и возвращает ненулевой хэндл новой косвенной ссылки. Номера исходных объектов заново отображаются через живую таблицу, удерживаемую на время вызова, поэтому объект, достигнутый в замыкании дважды, клонируется один раз и используется дважды. Метод возвращает ноль, не выбрасывая исключений, когда ID исходного документа неизвестен, когда источник — сам выбранный документ или когда ObjectNumber меньше 1
var
Lib: TPDFlib;
SourceDoc, TargetDoc, Handle: Integer;
begin
Lib := TPDFlib.Create;
try
TargetDoc := Lib.NewDocument;
if Lib.LoadFromFile('source.pdf', '') <> 1 then
Exit; // LoadFromFile возвращает 1 при успехе
SourceDoc := Lib.SelectedDocument; // загрузка выбрала то, что загрузила
Lib.SelectDocument(TargetDoc); // копирование адресует выбранный документ
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Две детали кусаются при первом запуске. LoadFromFile отвечает 1 или 0, а не ID документа, поэтому нужный хэндл берётся из SelectedDocument сразу после загрузки; и копирование всегда пишет в тот документ, который SelectDocument последним сделал текущим, а не в тот, из которого вы загрузили. Внутри рекурсия также несёт жёсткий предел глубины 64 — это страховка от патологической вложенности, а не механизм обработки циклов; обработка циклов отдельна и осознанна
Почему резервирование Nil-отображения не разрывает цикл?
Потому что Nil в таблице отображений означает сразу две разные вещи, и код не может их различить. Очевидная защита от цикла — добавить запись в таблицу до рекурсивного входа в объект, чтобы всё, что зацикливается назад, нашло запись и остановилось. Но запись пока не может хранить реальную цель — цели не существует, пока не записано замыкание под ней, — поэтому она хранит Nil, и поиск, который должен поймать обратное ребро, читает Nil и заключает, что объект никогда не отображался
// Сломано: зарезервированная Nil-цель неотличима от «ещё не отображено»
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // зарезервировано, всё ещё Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // дозаполняется только на выходе
end;
Проследите это через цикл страницы. Клон страницы достигает /Parent, входит в рекурсию в узел /Pages, который достигает /Kids, который входит в рекурсию обратно в страницу — чья зарезервированная запись всё ещё читается как Nil, поэтому она клонируется второй раз, и третий, и каждый уровень кладёт свежий кадр и свежий полупостроенный объект. Наблюдаемое — даже не чистое переполнение стека: внешние кадры сидят на ссылках, чьи цели никогда не были присвоены, поэтому первая запись через один из таких слотов — это нарушение доступа где-то, что ничем не напоминает вызвавшее его копирование страницы
Исправление: явное состояние InProgress
Починка — перестать перегружать Nil и задать вопрос напрямую. Запись таблицы, чья цель всё ещё не присвоена, означает этот объект прямо сейчас клонируется, и предикат InProgress проверяет ровно это до того, как запустится обычный поиск. Когда он истинен, ребро — это цикл назад в предка текущего клона, и PDFlibPas выдаёт для него null-объект, а не следует по нему
// Запись таблицы с Nil-целью помечает клон, находящийся в процессе
function InProgress(Num: Integer): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to High(Map) do
if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
Exit(True);
end;
// ... внутри CloneObject, для косвенной ссылки:
if InProgress(SrcRef.ObjNum) then
Exit(FStructure.NewNull); // циклическое обратное ребро, без рекурсии
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
Exit(FStructure.NewNull); // висячая исходная ссылка
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // резервируем, затем рекурсия
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // дозаполнение
end;
Exit(NewRef);
Обобщать это безопасно только из-за структурного факта о PDF: циклы в графе объектов появляются на обратных связях, а не на контентных рёбрах. /Parent в дереве страниц и /Prev в цепочке закладок указывают вверх или назад на уже посещённое; замыкание шрифта, XObject изображения или XObject формы идёт вниз и завершается. Поэтому копирование дескриптора шрифта, цветового пространства или словаря затенения не затронуто подстановкой null — ничто в этих замыканиях не достигает InProgress. Цена, названная прямо, в том, что циклическое ребро не переживает копирование. Словарь страницы, клонированный таким образом, приходит с /Parent как null-объектом, что ISO 32000-1 §7.3.9 делает эквивалентным отсутствующей записи, поэтому скопированная страница — валидный объект, не принадлежащий ни одному дереву страниц, пока вы сами не привяжете её к целевому узлу /Pages и не поправите /Count. Скопированный элемент закладок так же теряет /Prev и требует пересборки цепочки соседей. Это честный размен: CopyForeignObject даёт корректное замыкание и оставляет структурное перепривязывание вызывающему — в тех же рамках работает и замена страниц с сохранением номеров объектов
Почему запись таблицы надо резервировать до NewObjRef
Очевидная альтернатива обошла бы весь танец с InProgress: сначала выделить пустой объект-оболочку, зарегистрировать его реальный номер в таблице, затем заполнить оболочку, когда дети склонированы. Здесь это не работает, потому что TPDFIndObj.Obj доступен только для чтения и его содержимое нельзя заменить после построения — оболочки для заполнения нет. Номер и содержимое решаются вместе в NewObjRef, а значит, запись таблицы надо создать до рекурсивного вызова и завершить после него, и интервал между этими двумя моментами — ровно то, что должен покрывать InProgress. Одно следствие стоит знать до сравнения вывода: поскольку NewObjRef выполняется после записи дочернего замыкания, нумерация в цели выходит снизу вверх, и номера объектов не будут зеркалить порядок источника. Ничего в формате файла это не волнует, но побайтовое сравнение с вручную построенным ожиданием — волнует. Если прогон оставил объекты, которые вы решили ни к чему не привязывать, они не повреждены, а просто без ссылок, и сборка недостижимых PDF-объектов mark-and-sweep — тот инструмент, который вычищает их перед сохранением
Регрессионный тест, покрывающий это, требует одной детали, удивляющей тех, кто пишет тесты против TPDFlib: конструктор уже держит документ по умолчанию, поэтому DocumentCount стартует с 1, и фикстура с двумя документами должна проверять >= 2, а не = 2. Наряду с успешным копированием тест фиксирует три отказа — неизвестный ID источника, выбранный документ в качестве собственного источника и номер объекта, равный нулю, — все возвращают 0, не выбрасывая исключений, потому что цикл слияния — плохое место, чтобы узнать, что защитная проверка бросает исключение
Где это место в конвейере слияния
Копирование на уровне объектов — примитив, к которому обращаются, когда слияние целых файлов слишком грубо: вынуть одну программу шрифта из шаблона, затянуть отдельный XObject формы в документ для наложения штампов или перенести аннотацию с её потоками внешнего вида между файлами, не таща за ней остальную страницу. PDFlibPas открывает его одним вызовом над загруженными документами, и то, как он уживается с остальным низкоуровневым API объектов, видно в справочнике PDFlibPas Delphi PDF Library