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

Два порядка, один источник путаницы
Файл PDF — это коллекция косвенных объектов, каждый из которых идентифицируется номером. Структура файла не обязывает эти номера отражать порядок чтения. Объект 1 может содержать страницу 2; объект 20 может содержать страницу 1. То, что на самом деле определяет порядок чтения, — это дерево страниц: иерархия словарей /Pages, чьи массивы /Kids перечисляют ссылки на страницы в той последовательности, в которой программа просмотра должна их отображать (ISO 32000-1 §7.7.3)
Документ, вызвавший ошибку, имел следующую структуру дерева страниц:
{ Pages tree root, object 16 }
16 0 obj
<<
/Type /Pages
/Count 3
/Kids [20 0 R { logical page 1 }
1 0 R { logical page 2 }
4 0 R] { logical page 3 }
>>
endobj
Случилось так, что объект 1 и объект 4 в потоке байтов файла находились перед объектом 20. Любой парсер, перебирающий косвенные объекты в физическом порядке файла и записывающий их в PageArr по мере обнаружения словарей типа page, поместит объект 1 под индексом 0, объект 4 под индексом 1 и объект 20 под индексом 2. Логическая страница 1 оказывается в PageArr[2]. Запрос страницы с индексом 0 извлекает вместо нее логическую страницу 2
Именно это и делали оба внутренних пути парсинга HotPDF. Традиционный путь, используемый для файлов PDF 1.3/1.4, и современный путь, используемый для документов с потоками объектов (PDF 1.5+), формировали PageArr, перебирая косвенные объекты в физическом порядке файла, а не следуя цепочке /Kids
Подтверждение гипотезы
Прежде чем приступать к исправлению, нужно было доказать несоответствие, а не просто предполагать его наличие. Утилита командной строки qpdf делает это проще простого:
{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }
qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }
Извлечение каждой страницы по отдельности и проверка размеров файлов подтвердили соответствие: то, что выдавал PageArr[0], было содержимым, принадлежащим логической странице 2, а PageArr[2] содержал логическую страницу 1. Это круговое смещение было неопровержимым доказательством. Оно же объясняло, почему проблема возникала в разных исходных документах: любой PDF-файл, где объекты страниц случайно получали меньшие номера объектов, чем предыдущая логическая страница, вызывал эту ошибку
Есть простая причина, по которой PDF-файлы оказываются в таком состоянии. Инкрементные сохранения добавляют обновленные объекты с новыми номерами, оставляя старые слоты в таблице перекрестных ссылок указывающими в никуда. Редакторы, добавляющие титульную страницу, вставляют ее с большим номером объекта независимо от ее позиции в массиве Kids. Некоторые генераторы просто записывают страницы в порядке, удобном для потоковой передачи контента, а не в логической последовательности страниц. Формат PDF не требует от них иного
Решение: следовать массиву Kids
Правильный подход заключается в формировании PageArr путем прохода по цепочке /Kids от корневого каталога, а не путем сканирования косвенных объектов. После завершения первого прохода обоих путей парсинга, шаг постобработки восстанавливает логический порядок:
procedure THotPDF.ReorderPageArrByPagesTree;
var
PagesObj : THPDFDictionaryObject;
KidsArray : THPDFArrayObject;
NewPageArr: array of THPDFDictArrItem;
I, J, PageIndex, KidsIndex: Integer;
RefObj : THPDFLink;
PageObjNum: Integer;
Found : Boolean;
begin
{ Locate root /Pages dictionary via FRootIndex }
PagesObj := FindPagesRootFromCatalog;
if PagesObj = nil then Exit;
KidsIndex := PagesObj.FindValue('Kids');
if KidsIndex < 0 then Exit;
KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));
SetLength(NewPageArr, KidsArray.Items.Count);
PageIndex := 0;
for I := 0 to KidsArray.Items.Count - 1 do
begin
RefObj := THPDFLink(KidsArray.GetIndexedItem(I));
PageObjNum := RefObj.Value.ObjectNumber;
Found := False;
for J := 0 to Length(PageArr) - 1 do
begin
if PageArr[J].PageLink.ObjectNumber = PageObjNum then
begin
NewPageArr[PageIndex] := PageArr[J];
Inc(PageIndex);
Found := True;
Break;
end;
end;
{ Non-page Kids (intermediate /Pages nodes) produce no match; skip }
end;
if PageIndex > 0 then
begin
SetLength(PageArr, PageIndex);
for I := 0 to PageIndex - 1 do
PageArr[I] := NewPageArr[I];
end;
end;
Вызов происходит в конце каждого пути парсинга, после каталогизации всех объектов, но до обработки каких-либо операций со страницами:
{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;
{ Modern path (object streams) }
if TryParseModernPDF then
begin
Result := ModernPageCount;
ReorderPageArrByPagesTree;
Exit;
end;
Шаг переупорядочивания имеет сложность O(n * m), где n — количество Kids, а m — текущая длина PageArr, но для любого документа с плоским деревом страниц (все листья на глубине 1, что охватывает подавляющее большинство реальных PDF-файлов) оба значения одинаковы, и затраты ничтожны. Глубоко вложенные деревья страниц требуют рекурсивного обхода, а не одноуровневого подхода, показанного здесь; рабочая реализация обрабатывает этот случай отдельно
Использование CopyPageFromDocument после исправления
С установленным ReorderPageArrByPagesTree логические индексы страниц работают как ожидается. Высокоуровневая функция CopyPageFromDocument принимает логический индекс, начинающийся с нуля, и копирует правильную страницу в целевой документ:
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
Source.LoadFromFile('source.pdf');
Dest.FileName := 'extracted.pdf';
Dest.BeginDoc;
{ Copy logical page 0 (first page the user sees) }
Dest.CopyPageFromDocument(Source, 0, 0);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
Внутри CopyPageFromDocument запрашивает порядок дерева страниц, а не полагается на сырой индекс PageArr, поэтому функция ведет себя корректно даже с документами, где физический и логический порядки расходятся. Для пакетных операций InsertPagesFromDocument принимает массив логических индексов и копирует их за один проход
О чем это говорит при парсинге PDF
Спецификация PDF однозначна: логический порядок страниц определяется массивом /Kids дерева страниц, а не номерами объектов или смещениями байтов (ISO 32000-1 §7.7.3.2). Любой парсер, использующий иной порядок в качестве ярлыка, будет выдавать правильные результаты для большинства документов, с которыми сталкивается, поскольку большинство генераторов записывают страницы в естественном порядке и присваивают последовательные номера объектов. Ошибка скрывается до тех пор, пока кто-нибудь не загрузит PDF-файл, который был инкрементно отредактирован, реорганизован другим инструментом или сгенерирован программой, выбравшей другую компоновку
Тестирование только на самостоятельно сгенерированных PDF-файлах полностью упускает этот класс проблем. Следовательно, для устранения регрессии порядка страниц требуется набор документов из различных источников: инкрементные сохранения, отсканированные документы со вставленными титульными листами, PDF-файлы, созданные инструментами, которые иначе линеаризуют или оптимизируют граф объектов. Документ, вызвавший исходную ошибку, должен навсегда остаться в наборе регрессионного тестирования
Страница компонента HotPDF Component описывает весь API для операций со страницами, включая CopyPageFromDocument, InsertPagesFromDocument и MovePage