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

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

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

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

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

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

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

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

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

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

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

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

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

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

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

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

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

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

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

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

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

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

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

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

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