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

Замена страниц PDF в Delphi без разрушения закладок

Замена страницы 3 подписанного контракта не должна сдвигать оглавление. Удалите старую страницу, вставьте новую — и каждая закладка, указывавшая туда, теперь ведёт куда-то ещё. Delphi-библиотека PDF Library for Delphi избегает этого, сохраняя сам целевой объект страницы и перенося только записи, несущие визуальное содержимое

Почему закладки ломаются после замены страницы PDF?

Закладки ломаются, потому что назначение PDF именует страницу через косвенную ссылку на объект, а не через номер страницы. ISO 32000-1 §12.3.2.2 определяет явное назначение как массив, чей первый элемент — косвенная ссылка на объект страницы. Удалите этот объект и добавьте замену — и ссылка повисает: большинство просмотрщиков реагируют, забрасывая читателя на страницу 1, что в точности и есть симптом, о котором сообщают после замены по схеме удалить-затем-вставить. Дерево страниц выглядит идеально, число страниц верно, рендеринг верен, а весь навигационный слой тихо неверен

Именованные назначения тоже не спасают. §12.3.2.3 маршрутизирует имя через дерево имён /Dests в каталоге документа, но лист, к которому разрешается это имя, всё равно является массивом явного назначения, держащим ту же ссылку на страницу. Именование добавляет слой косвенности поверх ссылки на страницу, а не вокруг неё. То же рассуждение покрывает остальную часть интерактивного слоя, описанного в §12.5: аннотация-ссылка несёт /Dest или GoTo-действие /A, чей /D — тот же массив, каждая аннотация может нести запись /P, являющуюся косвенной ссылкой на её страницу, а виджет поля формы — это аннотация ровно на тех же основаниях. Одна наивная замена страницы отсоединяет четыре подсистемы разом, и если вы хотите увидеть их перечисленными на реальном файле, тот же граф объектов обходит интроспекция оглавления, аннотаций и действий

Сравнительная диаграмма PDF Library for Delphi: назначение закладки по косвенной ссылке переживает замену страницы на месте, но повисает после обмена с удалением и добавлением
Назначения привязывают закладки, ссылки и виджеты к номеру объекта страницы, поэтому мутация объекта на месте сохраняет навигацию живой, тогда как «удалить и вставить» сбрасывает читателей на страницу 1

Какие записи страницы несут идентичность, а какие — внешний вид

Словарь страницы смешивает два вида записей, и замена на месте удаётся именно тогда, когда вы их разделяете. Сторона внешнего вида конечна и перечислима: /Contents, /Resources, пять боксов страницы /MediaBox, /CropBox, /BleedBox, /TrimBox и /ArtBox, плюс /Rotate, /Group, /UserUnit и /BoxColorInfo. Эти одиннадцать записей решают всё, что растеризатор произведёт для страницы, и больше ничто в файле на них по имени не ссылается

Сторона идентичности — это то, к чему привязал себя остальной документ: номер объекта и поколение страницы, обратная ссылка /Parent в дерево страниц и /Annots. PDF Library for Delphi оставляет каждую из них нетронутой. ReplacePageRanges очищает одиннадцать визуальных записей из словаря целевой страницы и повторно добавляет их из импортированной исходной страницы, так что целевой объект страницы мутируется на месте, а не заменяется. Структура дерева страниц, требуемая §7.7.3, также остаётся байт-в-байт идентичной по форме: порядок /Kids, /Count и каждый выживший /Parent одинаковы до и после, потому что ни один узел никогда не отвязывался

Как PDF Library for Delphi заменяет страницу без перенумерации объектов?

Вызов принимает исходный документ, целевую начальную страницу с индексом от 1, выражение диапазона источника и флаг опций. Оба документа должны быть открыты в одном экземпляре, а целевой документ — тот, что выбран. Поскольку число страниц целевого документа никогда не меняется, запрошенный диапазон должен помещаться внутри документа, начиная с TargetStartPage, и это проверяется до того, как что-либо создаётся

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // Документ, чьи закладки и ссылки должны сохраниться
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // Пересмотренная страница пункта, отрисованная тем, что её создало
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Страница 1 источника перезаписывает внешний вид страницы 3 цели.
    // Число страниц, номер объекта страницы 3, закладки и аннотации сохраняются.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

Внутренне исходные страницы нельзя просто прочитать через границу документа, потому что каждая косвенная ссылка внутри них принадлежит нумерации объектов источника. Поэтому исходный диапазон сначала импортируется обычным способом, как временные страницы, добавленные после последней реальной страницы, что запускает полный переотображение графа объектов: потоки содержимого, шрифты, XObject, штриховки и цветовые пространства — все перенумеровываются в целевой документ. Только затем одиннадцать визуальных записей копируются с каждой временной страницы на её целевую страницу, и только затем временные страницы отвязываются от дерева страниц. Работа переотображения происходит там, где это дёшево и безопасно, а деструктивная правка сводится к обмену на уровне словаря на страницах, которые уже существуют

Путь удаления, который разрушил бы то, что вы только что перенесли

Удаление тех временных страниц — шаг, который выглядит тривиальным, а на деле нет. Обычный путь удаления страницы в библиотеке делает больше, чем просто отвязывает узел: он объединяет слои каждой удаляемой страницы, опустошает первый поток содержимого и возвращает ресурсы, которые не разделяет ни одна другая страница. Это верное поведение для реального удаления, и катастрофическое здесь, потому что к моменту удаления временных страниц целевые страницы уже ссылаются ровно на те же потоки содержимого и объекты ресурсов. Их опустошение сделало бы пустой только что заменённую страницу, а зачистка ресурсов собрала бы шрифты и изображения, у которых теперь есть живой владелец

Решение — режим сохранения ссылающихся объектов на внутреннем пути удаления. Когда он установлен, удаление пропускает и зачистку неразделяемых ресурсов, и очистку потока содержимого, ничего не делая, кроме отсоединения страниц от дерева страниц и исправления учёта в дереве. Перенесённые объекты выживают с новым владельцем, и владение объектами после операции — это именно то, что вы бы нарисовали на доске: один поток содержимого, одна владеющая страница, один номер объекта, который никогда не сдвигался. Связанные правила жизненного цикла для создания, удаления и переупорядочивания страниц отдельно рассматриваются в заметках про операции жизненного цикла документа и страниц

PDF Library for Delphi: анатомия словаря страницы — отделение записей идентичности, от которых зависит файл, от одиннадцати визуальных записей, которые ReplacePageRanges меняет со страницы импортированного источника
ReplacePageRanges вычищает одиннадцать визуальных ключей и добавляет их заново из импорта, тогда как номер объекта, поколение, /Parent и /Annots остаются ровно прежними

Порядок, дубликаты и провал по принципу «всё или ничего»

Флаг опций выбирает, как интерпретируется исходный диапазон. 0 сортирует разобранные номера страниц и удаляет дубликаты, что является разумным значением по умолчанию, когда вызывающая сторона передаёт что-то вроде '4-6,2' и просто имеет в виду эти четыре страницы. 1 сохраняет порядок, как вы его написали, и допускает повторение страницы, так что '2,1,2' действительно означает три замены, взятые с двух исходных страниц. Валидация выполняется первой и выполняется полностью: синтаксис диапазона, каждый номер страницы против числа страниц источника, само значение опции и ёмкость цели — всё проверяется до того, как создан хотя бы один объект. Отклонённый вызов устанавливает LastErrorCode в 412, восстанавливает ранее выбранную страницу и оставляет документ в точности таким, каким он был

PDF Library for Delphi: трёхэтапный поток ReplacePageRanges — временный импорт с перемэппингом объектов, копирование визуальных записей и unlink с сохранением ссылок, щадящий перенесённые ресурсы
Импорт источника как временных страниц позволяет сначала отработать обычному перемапированию, поэтому деструктивная правка сжимается до копирования визуальных ключей и отсоединения узлов без освобождения живых ресурсов
var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: порядок источника сохраняется, повторы допускаются, поэтому
  // целевые страницы 5, 6 и 7 получают исходные страницы 2, 1 и 2 соответственно
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // В случае успеха выделенной становится первая заменённая страница
  Assert(Lib.SelectedPage = 5);
end;

Атомарность распространяется за пределы валидации и на сам перенос. Прежде чем импортируется первая исходная страница, одиннадцать визуальных записей каждой целевой страницы в диапазоне снимаются как снимок в закодированном виде. Если импорт не удался или импортированное число страниц не совпало с запрошенным, снимки декодируются обратно на целевые страницы, а временные страницы удаляются, так что сбой в середине процесса всё равно оставляет исходную визуализацию на месте на исходных объектах. Это важнее, чем звучит: наполовину заменённый диапазон страниц в контракте хуже, чем неудавшийся вызов, потому что ничто в файле не помечает его как сделанное наполовину

// Постусловия, которые стоит проверять в регрессионном тесте
Lib.SelectPage(3);
// Геометрия теперь берётся из исходной страницы
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Аннотации, уже бывшие на целевой странице 3, по-прежнему прикреплены
WriteLn(Lib.AnnotationCount);
// Закладка, созданная до замены, по-прежнему разрешается в страницу 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// И документ всё ещё той же длины
WriteLn(Lib.PageCount);

Чего замена на месте всё ещё не делает за вас?

Исходные аннотации, исходные поля форм и исходное оглавление намеренно не импортируются. Перенос виджета без его записи поля /AcroForm или аннотации, несущей маркированное содержимое, без владения деревом структуры производит наполовину импортированный интерактивный объект, о котором не сможет судить ни один просмотрщик, так что операция переносит только внешний вид. Практическое следствие в том, что если заменяющая страница должна нести новые поля форм или новые ссылки, вы добавляете их к целевой странице впоследствии, к объекту целевой страницы, который всё ещё сидит там в ожидании их

Ещё две границы стоит проверить на собственных файлах. Во-первых, /Annots сохраняется, а геометрия страницы — нет, так что замена страницы 220 мм страницей 320 мм оставляет прямоугольники аннотаций на старых координатах внутри иначе размеченного /MediaBox; если геометрия меняется, перепозиционируйте сохранённые аннотации. Во-вторых, записи вне одиннадцати визуальных ключей остаются с целевой страницей по замыслу, что верно для /Trans или /AA и устарело для /Thumb, так что регенерируйте миниатюры после замены. Тегированные документы требуют ещё одной мысли: элементы структуры по-прежнему указывают на правильный объект страницы через /Pg, но их идентификаторы маркированного содержимого описывают содержимое, которого больше нет, так что замена страницы внутри процесса PDF/UA — это правка дерева структуры не в меньшей мере, чем правка содержимого. Если ваша задача на самом деле — композитинг, а не замена, наложение изображения на сохраняемые страницы, — подход сшивания страниц и шаблонов будет более дешёвым инструментом

Всё описанное здесь, включая синтаксис выражения диапазона, значения опций и окружающий API манипуляции страницами, поставляется в стандартной PDF Library for Delphi Delphi PDF Library для Delphi и C++Builder, чья справочная документация содержит полную статью о вызове замены страниц и его кодах ошибок