Размеченный отчёт с вырезанной страницей обложки может открыться с правильным числом страниц и всё равно оказаться сломан в трёх местах: закладка «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может делать то же самое
Что THotPDF.DeletePage делает до того, как тронет /Kids
THotPDF.DeletePage(PageIndex) на загруженном документе сначала вычищает каждую из этих структур и лишь последним переписывает дерево страниц. Индексация начинается с нуля. До всякого изменения срабатывают две защиты: индекс вне диапазона вызывает Invalid page number, а попытка убрать единственную оставшуюся страницу тоже завершается исключением, потому что узел /Pages без единого потомка не описывает корректный документ
Порядок вычистки фиксированный: дерево /Names /Dests, устаревший словарь /Dests в каталоге, /OpenAction, оглавление, /StructTreeRoot вместе с его ParentTree и наконец массивы /Annots каждой остающейся страницы. Только после этого объект страницы помечается удалённым, его аннотации-виджеты отсоединяются от дерева полей AcroForm, а /Kids, /Count и /Parent каждой уцелевшей страницы перезаписываются. На стороне формы родительское поле, оставшееся без потомков после ухода виджета, тоже удаляется — вплоть до /Fields, так что в обрезанной форме не остаётся записи поля, за которой на экране ничего не стоит
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 отправляет удалённый объект в список свободных только при наличии у него номера объекта; для прямого потомка он помечает содержащий элемент как изменённый, так что элемент переписывается уже без потомка, а список свободных остаётся чистым
Та же осторожность решает, что 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 без рантайма просмотрщика на машине