HotPDF Delphi Component удаляет страницу из загруженного PDF через THotPDF.DeletePage, и начиная с версии 2.751.0 этот вызов ещё и вычищает каждую ссылку уровня документа, которая всё ещё указывает на страницу: именованные назначения в дереве /Names /Dests, устаревший словарь /Dests в каталоге, действия /GoTo закладок, элементы структуры под /StructTreeRoot, ParentTree, записи OBJR для аннотаций и ссылки-аннотации на уцелевших страницах. Дерево страниц перестраивается последним, после того как до удалённого объекта уже ничто не может добраться
Отказ, который это предотвращает, легко воспроизвести и трудно диагностировать. Удалите страницу обложки размеченного отчёта, сохраните и откройте результат: Acrobat покажет правильное число страниц, но закладка «Contents» теперь ведёт в никуда, проверка доступности сообщит об элементе структуры без страницы, а строгий валидатор перечислит ссылку на освобождённый объект. В дереве страниц всё верно. Проблема в том, что страница PDF — не только лист /Pages: это цель, на которую указывает половина каталога, и удаление листа оставляет каждый из этих указателей висячим
Почему удалить страницу из /Kids недостаточно?
Потому что ISO 32000-1 позволяет как минимум семи независимым структурам держать ссылку на объект страницы, и только одна из них — дерево страниц. Убрать страницу из /Kids и уменьшить /Count — это удовлетворяет §7.7.3, а каждая другая ссылка становится указателем на объект, который либо освобождён в xref, либо просто отсутствует в перезаписанном файле. Просмотрщик, идущий по одному из таких указателей, получает 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каталога
Что THotPDF.DeletePage вычищает до того, как тронуть дерево страниц?
THotPDF.DeletePage(PageIndex) для загруженного документа сначала выполняет весь обход ссылок, затем помечает объект страницы удалённым через DeleteObj, отсоединяет аннотации-виджеты от дерева полей AcroForm, сдвигает внутренний массив страниц и наконец вызывает RebuildLoadedPageTree, чтобы переписать /Kids, /Count и /Parent каждой уцелевшей страницы. Обход идёт по каталогу в фиксированном порядке: дерево имён /Names /Dests, словарь /Dests старого образца, /OpenAction, дерево оглавления, /StructTreeRoot вместе с его ParentTree и в конце массивы /Annots всех страниц, которые остаются. Каждый шаг решает, удалить ссылку, перенацелить или оставить, исходя из того, что спецификация позволяет этой структуре без страницы. До всего этого действуют две защиты: DeletePage возбуждает Invalid page number при индексе вне диапазона и отказывается удалять последнюю страницу, потому что узел /Pages без дочерних элементов — не валидный PDF, а DeletePages принимает ту же запись диапазона с нумерацией от единицы "1,3-5,7-", что и другие операции с загруженным документом, и идёт от наибольшего выбранного индекса вниз, чтобы написанные вами индексы оставались валидными по ходу работы
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// Нумерация с нуля: убираем страницу обложки. Именованные
// назначения, закладки, дерево структуры, ParentTree и
// ссылки-аннотации, указывавшие на неё, вычищаются до
// того, как дерево /Pages будет перестроено.
Pdf.DeletePage(0);
// Синтаксис диапазонов с нумерацией от единицы для пакетов;
// внутри идём от старших индексов, чтобы младшие остались валидными.
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
Почему с именованными назначениями и закладками поступают по-разному?
Именованные назначения удаляются, а закладки перенацеливаются, потому что имя, которого больше нет, — приемлемый исход, а закладка без назначения — видимый дефект. В дереве /Names /Dests HotPDF обходит каждый узел, проверяет каждое назначение — и в виде голого массива, и в виде словаря с ключом /D — на удалённую страницу и убирает пару имя/значение, когда первый элемент массива и есть эта страница. Узел, у которого /Names и /Kids оба оказались пустыми, помечается удалённым и отцепляется от родителя, так что в дереве никогда не остаётся пустотелых листьев. Та же проверка выполняется по словарю /Dests старого образца в каталоге, а /OpenAction каталога просто отбрасывается, если документ открывался на удалённой странице. Одна граница здесь есть: когда узел дерева имён теряет записи, HotPDF удаляет его пару /Limits, а не пересчитывает новые минимальный и максимальный ключи, и хотя просмотрщики прекрасно разрешают имена без неё, строгая проверка соответствия по ISO 32000-1 §7.9.6 может отметить не-корневой узел, которому не хватает /Limits
С элементами оглавления всё наоборот. RetargetOutlineDestinations обходит /First и /Next от корня оглавления со списком посещённых и ограничением глубины 128, чтобы испорченное циклическое дерево не могло подвесить вызов, и для каждого массива /Dest или массива /D действия /GoTo, нацеленного на страницу, заменяет первый элемент на NearestRetainedPage: страницу, шедшую после удалённой, или страницу перед ней, если удалённая была последней. Параметры вида после ссылки на страницу остаются как были. Поэтому закладка, указывавшая на удалённую страницу-открывашку главы, приземляется на первую страницу того, что осталось, а не исчезает из боковой панели — именно такого поведения ждут от обрезанного документа те, кто его вычитывает. Впрочем, проверка назначения сопоставляет только явные массивы: элемент оглавления, у которого /Dest — строка-имя, когда-то разрешавшаяся в удалённую страницу, не перенацеливается, потому что запись дерева имён исчезла и ссылка теперь не разрешается ни во что, а не в освобождённый объект, так что просмотрщик считает её мёртвой закладкой. Механика самого дерева оглавления — /First, /Next и неочевидная семантика /Count — разобрана в руководстве по добавлению закладок и именованных назначений в загруженный PDF
// Проверяем вычистку вместо того, чтобы верить ей.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// Закладка, целившаяся в обложку, теперь разрешается в страницу,
// шедшую за ней (нулевой индекс после удаления).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
Что происходит с деревом структуры и ParentTree?
Элементы структуры, существующие только из-за удалённой страницы, удаляются, а элементы, охватывающие несколько страниц, теряют ключ /Pg, но сохраняют дочерние элементы. PruneStructureElement спускается по цепочке /K от /StructTreeRoot на глубину до 128, обрабатывая и массивную форму /K, и форму из одного словаря, которую допускает §14.7.2. Для каждого элемента он сначала вычищает дочерние, а затем оценивает сам элемент: если вычистка опустошила его /K, элемент помечается удалённым, и родитель его отбрасывает. Если собственный /Pg элемента называет удалённую страницу, а у элемента ещё есть дочерние плюс родитель /P, удаляется только /Pg, потому что /Pg у элемента — это страница по умолчанию для его дочерних ссылок на размеченное содержимое, а те могут явно ссылаться на другие страницы. Полностью удаляется лишь элемент, чей /Pg и есть удалённая страница и под которым ничего не осталось
С ParentTree поступают так же, и причина — та самая, что укусила во время разработки: элемент структуры может быть достижим из ParentTree и больше ниоткуда. Дерево чисел отображает целые /StructParents либо в один элемент, либо в массив элементов, и PruneParentTreeNode прогоняет PruneStructureElement по каждому найденному значению, убирает вычищенные значения, удаляет пару /Nums, когда её массив значений пуст, и отцепляет узел, у которого /Nums и /Kids оба пропали. Вычистка только потомков /K оставила бы эти осиротевшие элементы указывающими на освобождённую страницу через /Pg и на освобождённые ссылки размеченного содержимого через их дочерние /MCR. Если вы извлекаете текст в порядке структуры, это важно напрямую: извлечение текста в порядке структуры идёт как раз по этим деревьям, и элемент с нулевым /Pg — это абзац, который молча выпадает из порядка чтения
Какие ссылки-аннотации на уцелевших страницах удаляются?
Любая ссылка-аннотация на сохранённой странице, чей массив /Dest или действие /GoTo указывает на удалённую страницу, удаляется вместе со своим владением в дереве структуры. RemoveRetainedPageDestinationAnnotations обходит массив /Annots каждой страницы, кроме целевой, применяет ту же проверку назначения, что и для оглавления, помечает совпавшую аннотацию удалённой, убирает её из массива и затем вызывает PruneAnnotationReferencesInStructureTree, чтобы словарь OBJR, чей /Obj называл эту аннотацию, был убран из своего элемента структуры, а сам элемент удалён, если OBJR был его единственным дочерним. Оставить OBJR на месте означало бы нарушить §14.7.4.3, который требует, чтобы /Obj ссылался на существующий объект, и всплыло бы в проверке PDF/UA как размеченная ссылка, за которой нет аннотации. Заметьте асимметрию с закладками: ссылки удаляются, а не перенацеливаются. Перекрёстная ссылка в тексте, говорившая «см. страницу 3», становится неверной, как только страница 3 исчезла, и направить её на страницу 4 было бы ложью — в отличие от закладки, приземляющейся на ближайшую главу, так что если вашему процессу нужно сохранить такие ссылки, перенацельте их сами до вызова DeletePage
Почему удалённый /MCR или /OBJR никогда нельзя регистрировать как свободный?
Потому что ссылки на размеченное содержимое и ссылки на объекты обычно представляют собой прямые словари внутри массива /K родительского элемента, а реестр инкрементальных изменений разрешает прямой объект в ближайший косвенный объект, который его содержит. Когда RemoveArrayItem убирает дочерний элемент из массива /K, он освобождает объект в памяти только если тот был THPDFLink или не-косвенным значением, а MarkRemovedObject регистрирует объект для списка свободных только когда его номер объекта больше нуля. Первая версия этой вычистки такого различия не делала, и эффект при инкрементальном сохранении был ровно тем, для чего реестр и предназначен: RegisterIncrementalChange шёл от прямого /MCR вверх к корню его транзакции графа — а им был содержащий его сохранённый элемент структуры, — и записывал этот элемент как null. Документ, потерявший одну страницу, возвращался с молча снятой разметкой на остальных страницах. Единственно верный ход для прямого дочернего элемента — пометить его контейнер грязным через TouchContainer, чтобы контейнер был перезаписан, и не трогать список свободных
// Инкрементальное обновление: в добавленную секцию попадают
// только тронутые контейнеры и освобождённый объект страницы.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Сохранённые элементы структуры, чей /K потерял прямой /MCR,
// перезаписываются на месте и никогда не пишутся как null.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
Та же осторожность определяет и то, что DeletePage намеренно не освобождает в загруженном документе. Потоки содержимого, XObject и аннотации удалённой страницы, не являющиеся виджетами, остаются объектами, потому что загруженный файл может делить любой из них с остающейся страницей, и дешёвого способа доказать обратное в момент удаления нет. Удаления ссылки из дерева страниц достаточно для корректности; байты, которые эти объекты всё ещё занимают, — отдельный вопрос, и граф зависимостей объектов и анализ удерживаемых байтов — тот инструмент, которым измеряют, что обрезанный документ всё ещё несёт
DeletePage или DeleteLoadedPage: что вызывать?
Вызывайте DeletePage для любого удаления страниц, видимого пользователю, а DeleteLoadedPage приберегите для случая, когда весь документ перекомпоновывается заново и ни одна ссылка уровня документа не стоит сохранения. THotPDF.DeleteLoadedPage(PageIndex), добавленный в версии 2.508.0, — это облегчённый вариант: он сдвигает внутренний массив страниц, вызывает RebuildLoadedKidsArray, чтобы переписать /Kids и /Count, сбрасывает кеш отрисованных страниц и возбуждает OnLoadedDocumentModified. Он не обходит дерево имён, оглавление, дерево структуры и аннотации других страниц и не помечает объект страницы удалённым. Это правильный инструмент внутри N-up спуска полос, где HotPDF добавляет только что скомпонованные листы, а затем отбрасывает каждую исходную страницу через DeleteLoadedPage(0): исходные страницы заменяются целиком, и содержимое листа ссылается на их ресурсы, а не на объекты страниц. Для обычной задачи «убрать страницу 7 из этого договора» DeletePage — единственный вызов, оставляющий размеченный документ с закладками и перекрёстными ссылками достаточно согласованным, чтобы пройти валидатор: и при полной перезаписи через SaveLoadedDocument, и при инкрементальном обновлении через SaveIncrementalUpdate. Оба метода поставляются в составе HotPDF Delphi Component для Delphi и C++Builder и не требуют ни внешней среды просмотрщика, ни сторонних зависимостей