Удалите семь страниц из руководства на 200 страниц — и каждая закладка укажет не туда. Решение не в том, чтобы заново собрать оглавление из плоского списка заголовков. PDFiumPas предоставляет TPdfOutlineEditor: он загружает настоящее дерево оглавления, позволяет переносить элементы и менять их цели, а затем выполняет ApplyPageMap, смещая каждое явное назначение в соответствии с вашим планом страниц
Почему удаление страниц ломает каждую закладку?
Потому что элемент оглавления не хранит номер страницы. Он хранит ссылку на объект страницы, и когда объекты страниц меняются, ссылка либо указывает на страницу, которая переехала, либо вообще ни на что. ISO 32000-1 §12.3.2.2 определяет явное назначение (explicit destination) как массив, первый элемент которого — косвенная ссылка на словарь страницы, за которой следует имя режима отображения вроде /Fit или /XYZ. Удалите страницу — останется висячая ссылка; переставьте страницы — ссылка по-прежнему валидна, но описывает уже другую главу. PDFiumPas при загрузке разворачивает этот массив обратно в номер страницы, поэтому TPdfOutlineItem.PageNumber даёт индекс страницы с единицы, согласованный с публичным API TPdf, а не номер объекта. В этом весь смысл абстракции: логика переназначения работает в той же системе координат, что и план страниц, который вы уже построили при разделении, перестановке или спуске документа. Если вы только строите такой план, та же нумерация с единицы проходит через разделение PDF-документов на несколько файлов и n-up спуск и переупорядочивание страниц
Оглавление — двусвязное дерево, а не список
Причина, по которой нельзя просто сериализовать плоский массив заголовков, в том, что ISO 32000-1 §12.3.3 связывает каждый элемент оглавления пятью отдельными ссылками: /Parent, /Prev, /Next, /First и /Last. Перенос одного поддерева переписывает старого родителя, нового родителя, соседние элементы по обе стороны от разреза и от точки вставки, а также указатель родителя у самого переносимого узла. Ошибётесь в чём-то одном — соответствующие стандарту просмотрщики покажут усечённое дерево или зациклятся. PDFiumPas хранит состояние редактирования как массив записей TPdfOutlineItem в порядке обхода в глубину со стабильным целочисленным Id, поэтому поддерево — непрерывный срез, а цепочка соседей выводится, а не поддерживается вручную. TPdfOutlineEditor.Move поднимает этот срез, вставляет его под нового родителя по запрошенному индексу среди соседей и переназначает только корень блока. Два перемещения, разрушающие граф, он отклоняет: перенос элемента в собственное поддерево и указание несуществующего родителя
Почему /Count знаковый?
Потому что знак несёт состояние раскрытости, а не размер. Положительный /Count означает, что элемент раскрыт, и число — сколько потомков сейчас видно; отрицательный /Count — элемент свёрнут. PDFiumPas записывает число потомков у каждого элемента с детьми и меняет знак на минус, когда IsOpen равно False, а при загрузке читает состояние обратно как IsOpen := HasCount and (CountValue > 0). Это самая распространённая ошибка самописных генераторов оглавлений: выдать беззнаковое значение и молча раскрыть всё дерево
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000, MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // становится вторым потомком корня
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // записывает отрицательный /Count
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget поддерживает обе формы, которые допускает спецификация. Передайте DestinationInAction как False — и PDFiumPas запишет прямой массив /Dest; передайте True — и будет записано действие Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, согласно ISO 32000-1 §12.6.4.2. В обоих случаях он сначала удаляет из элемента уже существующие /Dest и /A, чтобы они не могли сосуществовать и противоречить друг другу. Суффикс по умолчанию — /Fit, и он обязан начинаться с PDF-имени, поэтому пустой или искажённый суффикс вызывает исключение немедленно, вместо того чтобы породить массив назначения, который не разберёт ни один просмотрщик
Как ApplyPageMap принимает план страниц?
ApplyPageMap принимает ровно тот массив, который ваш план страниц уже проверил: NewPageNumbers, индексируемый как номер старой страницы минус один, со новым номером страницы (отсчёт с единицы) или нулём, если страница не пережила преобразование. Он обходит массив элементов с конца, чтобы удаление поддерева никогда не портило ещё не посещённые индексы, и отчитывается о сделанном через RemappedDestinationCount и RemovedDanglingItemCount
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// Одна ячейка на каждую страницу ИСХОДНОГО документа
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == эта страница была отброшена
NewPageNumbers[0] := 1; // старая страница 1 -> новая страница 1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // старая страница 10 -> новая страница 3
// True: удалить всё висячее поддерево. False: оставить элемент, сняв его цель
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
Флаг DeleteDangling задаёт политику для назначения, отобразившегося в ноль, и обе ветки продуманы. При True PDFiumPas удаляет элемент вместе со всем поддеревом: узел оглавления, чья цель исчезла, обычно возглавляет главу, исчезнувшую вместе с ней. При False элемент выживает с сохранёнными заголовком и иерархией, но с удалёнными /Dest и /A — именно это нужно, когда человек при вычитке сам задаст ему новую цель. По-настоящему искажённые входные данные по-прежнему приводят к громкому отказу, а не к тихой заплатке: отрицательная ячейка или назначение за пределами переданной карты возвращают False с IssueKind равным poviInvalidPageMap
Непрозрачные элементы и честный компромисс
Не у каждого элемента оглавления есть номер страницы, который PDFiumPas способен осмыслить. Три разновидности проходят насквозь нетронутыми: именованные назначения, действия, отличные от /S /GoTo, и неизвестные ключи словаря, добавленные тем, что создало файл. Они загружаются с PageNumber равным нулю, сохраняют исходные байты в элементе и записываются обратно дословно, если вы явно не вызовете для них Retarget
- Именованное назначение — ключ в дерево имён документа, поэтому корректное переназначение означает разрешить это дерево и переписать целевую запись, а не угадывать на уровне оглавления
- Действие
/URI,/Launchили JavaScript вообще не имеет страничной семантики и не должно молча превращаться в Go-To - Ключи конкретных вендоров и структурные назначения сохраняются, потому что отбрасывание непонятного — это то, как теряются данные при круговых преобразованиях
Цена реальна, и о ней стоит сказать прямо: ApplyPageMap пропускает такие элементы целиком, поэтому документ, чьи закладки все используют именованные назначения, пройдёт через удаление страниц с оглавлением, структурно валидным и семантически устаревшим. Это осознанный выбор — устаревшая ссылка, которую заметит проверяющий, лучше уверенно неверной, которую не заметит никто. Если вы разбираете входящие файлы перед правкой, инвентаризация в среде приёмки и обзора PDF покажет, какие документы попадают в эту категорию
Сохранение: инкрементальная редакция, затем независимая перезагрузка
TPdfOutlineEditor.SaveIncremental дописывает разреженную инкрементальную редакцию, а не переписывает файл. Загруженные элементы сохраняют исходную косвенную ссылку на объект, включая точную генерацию, поэтому существующие перекрёстные ссылки остаются валидными; свежие номера получают только добавленные вами элементы — они выделяются начиная с числа на единицу больше максимального номера объекта редакции. Каталог обновляется в той же редакции, а если в источнике вообще не было оглавления, в каталог добавляется недостающая запись /Outlines
То, что происходит после записи, — часть, которую стоит перенять. PDFiumPas заново открывает целевой поток совершенно независимым редактором и сверяет перезагруженное дерево с находящимся в памяти: число элементов, заголовки, номера страниц, суффиксы назначений, форму «действие против прямого назначения», стили, состояние раскрытости и родственные связи. Любое расхождение или любая ошибка загрузки очищают целевой поток и возвращают poviVerificationFailure, вместо того чтобы вручать вам правдоподобно выглядящий файл. Зашифрованные источники отклоняются сразу с poviEncryptedInput, поскольку новые заголовки и назначения создают строковое содержимое, которое нельзя получить копированием трейлера /Encrypt
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
Относитесь к оглавлению как к тому, чем оно является, — связному графу объектов с собственными инвариантами, — и удаление страниц перестаёт быть катастрофой закладок и превращается в карту страниц, которую вы передаёте одному вызову метода. TPdfOutlineEditor, ApplyPageMap и инкрементальный писатель с верификацией входят в PDFiumPas начиная с v3.98.0 для Delphi, C++Builder и Lazarus; полный API и пробную версию можно найти на странице продукта PDFium Delphi Component