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

Удаление страниц PDF в Delphi без висячих ссылок

Размеченный отчёт с вырезанной страницей обложки может открыться с правильным числом страниц и всё равно оказаться сломан в трёх местах: закладка «Contents» не реагирует на щелчок, проверка доступности сообщает об элементе структуры, который не принадлежит ни одной странице, а строгий валидатор помечает ссылку на свободный объект. Дерево страниц при этом в порядке. ISO 32000-1 §7.7.3 требует лишь, чтобы страница покинула /Kids, а /Count уменьшился на единицу, и файл может удовлетворять этому пункту, пока половина каталога всё ещё указывает на отсутствующую страницу

Семь структур могут держать ссылку на одну страницу

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

  • дерево имён под /Names /Dests (§7.7.4, §12.3.2.3) отображает имена в массивы назначений, первый элемент которых — страница
  • словарь /Dests из версий до 1.2 прямо в каталоге хранит такие же массивы, ключом служит имя
  • элементы оглавления (§12.3.3) добираются до страницы через встроенный /Dest или через действие /A с /S /GoTo и массивом /D
  • элементы структуры (§14.7.2) несут ключ /Pg, называющий страницу, на которой живёт их размеченное содержимое, а их потомки /K могут быть ссылками на размеченное содержимое и ссылками на объекты (§14.7.4.3), привязанными к той же странице
  • ParentTree (§14.7.4.4) отображает номера /StructParents страниц и аннотаций обратно в элементы структуры, причём элемент может сидеть там, не встречаясь нигде на цепочке /K от корня
  • ссылки-аннотации на других страницах (§12.5.6.5) несут /Dest или действие /GoTo, нацеленные на страницу, и каталоговый /OpenAction может делать то же самое
Почему удаления страницы HotPDF из /Kids недостаточно: ISO 32000-1 позволяет дереву имён /Names /Dests, устаревшему словарю /Dests в каталоге, элементам оглавления, элементам структуры с /Pg, ParentTree, ссылкам-аннотациям и /OpenAction держать ссылку на один и тот же объект страницы, а перестраивается только дерево страниц
Страница PDF — это цель, на которую указывает половина каталога: удаление листа удовлетворяет дерево страниц, тогда как все остальные указатели разрешаются в null, поэтому обрезанный отчёт теряет закладку Contents и не проходит проверку доступности

Что THotPDF.DeletePage делает до того, как тронет /Kids

THotPDF.DeletePage(PageIndex) на загруженном документе сначала вычищает каждую из этих структур и лишь последним переписывает дерево страниц. Индексация начинается с нуля. До всякого изменения срабатывают две защиты: индекс вне диапазона вызывает Invalid page number, а попытка убрать единственную оставшуюся страницу тоже завершается исключением, потому что узел /Pages без единого потомка не описывает корректный документ

Порядок вычистки фиксированный: дерево /Names /Dests, устаревший словарь /Dests в каталоге, /OpenAction, оглавление, /StructTreeRoot вместе с его ParentTree и наконец массивы /Annots каждой остающейся страницы. Только после этого объект страницы помечается удалённым, его аннотации-виджеты отсоединяются от дерева полей AcroForm, а /Kids, /Count и /Parent каждой уцелевшей страницы перезаписываются. На стороне формы родительское поле, оставшееся без потомков после ухода виджета, тоже удаляется — вплоть до /Fields, так что в обрезанной форме не остаётся записи поля, за которой на экране ничего не стоит

Фиксированный обход ссылок, который THotPDF.DeletePage выполняет до касания дерева страниц: защиты отклоняют индекс вне диапазона или последнюю страницу, затем вычищаются /Names /Dests и устаревший /Dests, отбрасывается /OpenAction, оглавление перенацеливается на NearestRetainedPage, вычищаются StructTreeRoot и ParentTree, удаляются ссылки уцелевших страниц, и последним идёт RebuildLoadedPageTree
Каждая структура получает то, что позволяет спецификация: имена исчезают, закладки приземляются на ближайшую уцелевшую страницу, элементы структуры теряют /Pg или пропадают, а перезапись /Kids идёт только после того, как до удалённого объекта уже нечем добраться

DeletePages принимает строку диапазона с нумерацией с единицы, например '1,3-5,7-', — ту же нотацию, что используют другие операции над страницами загруженного документа, — и удаляет страницы от самой старшей выбранной вниз. Движение сверху вниз сохраняет написанные вами номера актуальными, пока страницы исчезают у них из-под ног. Диапазон, накрывающий все страницы, вызывает исключение по той же причине, по которой DeletePage отказывает последнюю, а функция возвращает число удалённых страниц

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile возвращает число загруженных страниц
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // Нумерация с нуля: страница 0 — обложка
      Pdf.DeletePage(0);
      // Диапазон с единицы: нумеруем по документу в его текущем виде
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Имена исчезают, закладки переезжают

Именованные назначения, нацеленные на удалённую страницу, удаляются. Закладки, нацеленные на неё, перенацеливаются. В дереве /Names /Dests посещается каждый узел, и каждое назначение проверяется в обеих допускаемых спецификацией формах — голый массив и словарь с ключом /D; пара имя/значение уходит, когда первый элемент массива — удалённая страница. Промежуточный узел, оставшийся без /Names и без /Kids, отцепляется от родителя, так что дерево не хранит полых листьев. Устаревший словарь /Dests в каталоге проходит ту же проверку, ключ за ключом, а каталоговый /OpenAction, открывавшийся на удалённой странице, отбрасывается — начальный вид остаётся на усмотрении просмотрщика

Узел, потерявший записи, теряет и свой массив /Limits. Новые наименьший и наибольший ключи не перевычисляются, а просмотрщики разрешают имена и без них. §7.9.6 действительно требует /Limits на промежуточных и листовых узлах, так что если префлайт-шаг в вашем конвейере строго следит за этим правилом, прогоняйте обрезанный файл через него в рамках той же задачи

Оглавление обходится по /First и /Next со списком посещённых и ограничением глубины 128, так что испорченная цепочка /Next, зацикленная сама на себя, не сможет повесить вызов. Для каждого массива /Dest или массива /D действия /GoTo, нацеленного на удалённую страницу, первый элемент заменяется страницей, шедшей следом, или предыдущей страницей, если удалённая была последней. Параметры вида после ссылки на страницу остаются как были, поэтому прыжок /XYZ сохраняет свои left, top и zoom и применяет их к новой странице

Асимметрия намеренная. Имя, разрешающееся в пустоту, законно и безвредно; GoTo с несуществующим назначением просто ничего не делает. Закладка с null-назначением хуже: она по-прежнему сидит в боковой панели, выглядит кликабельной и не работает. Приземление на ближайшую уцелевшую страницу — лучшее из двух неидеальных исходов. Перенацеливание покрывает только явные массивы назначений. Элемент оглавления, чей /Dest — строка имени, разрешается через дерево имён, и когда вычистка удалила это имя, закладка ведёт в никуда, так что документы, активно пользующиеся именованными закладками, заслуживают проверки после удаления. Проверка ниже читает имена через ResolveLoadedNamedDestination, которая смотрит только на массив /Names корневого узла; для плоского дерева имён ответ осмысленный, а дерево, разбитое на /Kids, возвращает -1 для любого имени. Сама структура оглавления, включая неочевидную семантику /Count, разобрана в руководстве по добавлению закладок и именованных назначений в загруженный PDF

// Проверяйте результат, а не полагайтесь на него.
// Memo1 — TMemo на вашей собственной форме.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination читает только корневой массив /Names,
// поэтому эта проверка осмысленна для плоского дерева имён; на дереве
// с /Kids она вернёт -1 для любого имени, удалённого или нет
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Закладка, указывавшая на обложку, теперь разрешается
// на следующую за ней страницу — после удаления это индекс 0
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Обрезка дерева структуры и ParentTree

Элементы структуры, существовавшие только ради удалённой страницы, удаляются, а элементы, охватывающие несколько страниц, теряют ключ /Pg, но сохраняют потомков. Обход стартует с /StructTreeRoot, спускается по /K на глубину до 128 и принимает как форму массива, так и форму единственного словаря /K, которые допускает §14.7.2. Потомки каждого элемента обрезаются раньше, чем судят о самом элементе. Если обрезка опустошила его /K, элемент удаляется, и родитель его сбрасывает. Если его собственный /Pg называет удалённую страницу, но у элемента ещё есть потомки и родитель /P, уходит только /Pg, потому что /Pg у элемента — лишь страница по умолчанию для дочерних ссылок на размеченное содержимое, не называющих свою, а эти потомки вполне могут жить на других страницах. Элемент, чей /Pg — удалённая страница и под которым ничего нет, удаляется целиком; словари ссылок на размеченное содержимое, чей /Pg называет страницу, подпадают под то же последнее правило

С ParentTree поступают так же, и причину легко упустить: элемент может быть достижим из ParentTree и больше ниоткуда. Это дерево чисел отображает целые /StructParents в единственный элемент или массив элементов. Каждое значение обрезается по правилам выше, удалённые значения выпадают из своих массивов, пара /Nums с опустевшим массивом удаляется, а узел без /Nums и без /Kids отцепляется. Пропустите этот проход, и сироты продолжат указывать на освобождённую страницу через /Pg. Для тех, кто извлекает текст в порядке тегов, это напрямую: извлечение текста в порядке структуры обходит именно такие деревья, а элемент с null в /Pg — абзац, выпадающий из порядка чтения

Почему ссылки на уцелевших страницах удаляются, а не перенацеливаются?

Ссылка-аннотация на сохранённой странице, чей массив /Dest или действие /GoTo нацелены на удалённую страницу, удаляется из /Annots этой страницы, и её тег уходит вместе с ней. Словарь OBJR, чей /Obj называл аннотацию, извлекается из своего элемента структуры; если этот OBJR был единственным потомком элемента, элемент тоже удаляется, а ParentTree обрезается снова. Оставить OBJR значило бы сломать §14.7.4.3, ожидающий от /Obj ссылки на реальный объект, а проверка PDF/UA увидела бы элемент Link, чья ссылка на объект ведёт в никуда

Закладки переезжают; ссылки — нет. Перекрёстная ссылка в тексте, обещающая «смотри страницу 3», неверна, как только страницы 3 не стало, а тихое перенаправление на страницу 4 превращает сломанную ссылку в вводящую в заблуждение. Закладка, приземляющаяся на следующую главу, — это навигация; ссылка в тексте — утверждение о содержимом. Удаление — более безопасное умолчание. Если вашему процессу нужно, чтобы такие ссылки выживали, перепишите их назначения до вызова DeletePage, потому что после вызова их уже нет в массивах /Annots

Инкрементальные сохранения и ловушка прямых потомков

DeletePage ведёт себя одинаково между BeginIncrementalUpdate и SaveIncrementalUpdate. В добавленную секцию попадают объекты, изменённые вычисткой, плюс свободные записи для удалённых объектов, имевших собственные номера объектов, — таких как страница и удалённые ссылки-аннотации. Всё выше исходного %%EOF остаётся байт в байт, как того требует §7.5.6

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Элементы структуры, потерявшие прямой потомок /MCR,
  // дописываются в переписанном виде с более коротким массивом /K
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Именно здесь ошибается самописный обрезщик. Ссылки на размеченное содержимое (/Type /MCR) и ссылки на объекты (/Type /OBJR) — обычно прямые словари, сидящие внутри массива /K родительского элемента. У прямого объекта нет ни номера объекта, ни записи в xref, поэтому освободить его самого нельзя. Напрашивающийся ход — всё же «освободить» каждый удалённый потомок, и любой писатель, отображающий прямой объект в косвенный объект-носитель, освободит затем носителя — то есть уцелевший элемент структуры. Тот элемент уйдёт в файл как null, и размеченное содержимое на страницах, которых никто не трогал, потеряет разметку. HotPDF отправляет удалённый объект в список свободных только при наличии у него номера объекта; для прямого потомка он помечает содержащий элемент как изменённый, так что элемент переписывается уже без потомка, а список свободных остаётся чистым

Почему удалённый потомок /MCR или OBJR нельзя регистрировать как свободный в инкрементальном обновлении: это прямой словарь внутри массива /K своего элемента, поэтому освобождение означало бы освобождение содержащего элемента структуры, который был бы записан как null и снял бы разметку с уцелевших страниц, тогда как правильный ход — переписать контейнер и не трогать список свободных
В список свободных попадают только объекты с собственным номером объекта, поэтому инкрементальное сохранение дописывает переписанные контейнеры плюс свободные записи для страницы и прочих удалённых косвенных объектов

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

DeletePage или DeleteLoadedPage?

Используйте DeletePage для любого удаления страницы, которое запросил пользователь. THotPDF.DeleteLoadedPage(PageIndex) — облегчённый вариант: он применяет те же две защиты, сдвигает список загруженных страниц, перезаписывает /Kids и /Count, инвалидирует кэш отрисованных страниц и возбуждает OnLoadedDocumentModified. Он не трогает дерево имён, оглавление, дерево структуры или аннотации других страниц и не помечает объект страницы удалённым. ComposeLoadedPagesNUp — как раз его работа: он дописывает свежесобранные листы, рисующие исходные страницы как form XObjects, а затем сбрасывает каждый оригинал через DeleteLoadedPage(0), поскольку листы ссылаются на содержимое страниц, а не на объекты страниц. Для «удалите страницу 7 из этого договора» на размеченном файле с закладками и перекрёстными ссылками DeletePage — тот вызов, который оставляет согласованной каждую ссылку, сохраняете ли вы через SaveLoadedDocument или дописываете через SaveIncrementalUpdate

Оба метода, диапазонный DeletePages и показанный выше путь инкрементального обновления — часть HotPDF Delphi Component, которая правит загруженные PDF из кода на Delphi и C++Builder без рантайма просмотрщика на машине