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

Ошибки порядка страниц PDF в HotPDF: физическая и логическая структура

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

Ошибка крылась вовсе не в коде копирования, а в том, как HotPDF формировал свой внутренний массив страниц при загрузке файла

Концепция порядка страниц PDF: разница между физическим и логическим порядком
Порядок страниц PDF: массив /Kids в дереве Pages определяет логическую последовательность, независимо от того, как нумеруются или хранятся объекты в файле

Два порядка, один источник путаницы

Файл 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