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

PDF без словаря Pages: последствия для парсинга

Словарь каталога PDF имеет ровно один обязательный навигационный ключ: /Pages. Этот ключ должен указывать на косвенный объект типа /Pages, который, в свою очередь, содержит массив /Kids и общее количество страниц /Count. Уберите этот указатель, и ни одна программа чтения, соответствующая стандарту, не сможет найти ни одной страницы в файле. Спецификация ISO 32000-1 §7.7.2 в этом вопросе недвусмысленна: каталог должен иметь запись /Pages, а ссылаемый объект должен иметь тип /Pages. Файлы, нарушающие это требование, не просто не соответствуют стандарту; они структурно повреждены таким образом, что большинство парсеров с ними плохо справляются

Что на самом деле говорит спецификация

Минимальный соответствующий стандарту PDF-файл содержит по меньшей мере три объекта. Объект 1 — это Каталог, объект 2 — корень Pages, а объект 3 и последующие — отдельные словари Page (страницы). Каталог указывает на корень Pages; корень Pages перечисляет своих потомков в /Kids; каждая страница содержит обратную ссылку /Parent. Вся цепочка по замыслу является двунаправленной, поэтому парсер может начать с любого конца и перейти к любой странице за время O(log n) для сбалансированных деревьев

% Minimal conforming structure (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj

3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << >> >>
endobj

4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

Дерево страниц может быть вложенным. Документ с тысячами страниц обычно группирует страницы в промежуточные объекты-узлы, которые также имеют тип /Pages, каждый со своим /Kids и значением /Count, отражающим поддерево под ним. Значение /Count корневого узла всегда равно общему количеству страниц. Именно это количество программы просмотра отображают в поле номера страницы до того, как они проанализируют хотя бы одну страницу, потому что чтение одного целого числа из объекта 2 обходится гораздо дешевле, чем обход всего дерева

Как выглядит файл без Pages

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

% Non-conforming: Catalog with no /Pages reference
1 0 obj
<< /Type /Catalog >>
endobj

% Page objects exist but are unreachable from the Catalog
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj

25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj

Парсер, следующий спецификации, прочитает Каталог, попытается разрешить /Pages, ничего не найдет (или найдет мертвую ссылку) и либо выдаст ошибку, либо сообщит о нуле страниц. Чего он не должен делать, так это продолжать работу, как будто в файле ноль страниц, и молча завершать ее успешно; это приводит к пустому выводу, который выглядит правильным для автоматизированных инструментов и неправильным для любого человека, который его открывает

Почему парсеры падают

Большинство PDF-парсеров распределяют свою внутреннюю таблицу страниц во время загрузки на основе значения /Count из корня Pages. Когда этого корня нет, парсер либо считывает ноль, ничего не выделяет, а затем разыменовывает нулевой указатель в первый раз, когда какой-либо код запрашивает страницу 1, либо он считывает мусор и выделяет совершенно неверный буфер. Ни один из результатов не является изящным. Нарушение прав доступа (access violation) по адресу 0x008E5D78, которое появляется в журналах сбоев при обработке такого файла, — это именно оно: разыменование нулевого указателя внутри пути доступа к странице, вызванное отсутствием структуры, которую, как предполагал парсер, всегда можно будет найти

Основное предположение при проектировании вполне разумно. Подавляющее большинство существующих PDF-файлов имеет словарь Pages. Парсеры, которые пропускают проверку его наличия ради экономии нескольких инструкций, не действуют безрассудно; они оптимизируют код для наиболее частого случая. Файлы, которые наказывают за такую оптимизацию, встречаются достаточно редко, чтобы производственный код мог никогда не столкнуться с ними, пока это не произойдет, после чего сбой становится воспроизводимым и сбивающим с толку, если инженер не читал §7.7.2

Восстановление без дерева Pages

Если парсер должен обрабатывать такие файлы, а не отвергать их, восстановление идет по предсказуемому пути: просканировать каждый косвенный объект в таблице перекрестных ссылок, собрать те, у которых /Type /Page, и отсортировать их по номеру объекта. Порядок номеров объектов не гарантирует совпадения с порядком чтения в спецификации, но на практике генераторы, опускающие дерево Pages, имеют тенденцию выдавать страницы последовательно, поэтому порядок номеров объектов чаще оказывается правильным, чем нет

Сама по себе проверка обходится дешево. Перед тем как пройти по указателю /Pages в Каталоге, убедитесь, что этот указатель существует, что он разрешается в реальный объект и что /Type разрешенного объекта равен /Pages. Если любое из этих трех условий не выполняется, переходите к линейному сканированию. Сканирование работает медленнее, чем обход дерева для больших документов, поскольку оно читает каждый заголовок объекта, а не следует по сбалансированному пути, но оно работает, а для файла, который уже деформирован, правильность важнее скорости

Один пограничный случай, который линейное сканирование не решает автоматически: порядок страниц. Без массива /Kids, определяющего последовательность, "правильный" порядок не определен спецификацией. Порядок номеров объектов — прагматичное значение по умолчанию; если файл достаточно важен для тщательной обработки, стоит проверить, содержат ли объекты Page явный /StructParents или ссылки на аннотации, подразумевающие последовательность чтения, — это оправдывает дополнительные усилия

Последствия для генераторов PDF

Для всех, кто пишет генератор PDF, а не парсер, урок узок: всегда выводите корень Pages перед закрытием файла. Каталог без записи /Pages не является действительным PDF ни по одной из редакций спецификации. Генераторы, которые создают объекты страниц на лету и собирают дерево при финализации (подход, который используют большинство потоковых писателей), работают нормально, пока финализация действительно запускается. Обычный режим отказа — это исключение или ранний возврат, прерывающий запись до завершения трейлера, оставляя после себя файл, который открывается в некоторых программах просмотра (имеющих эвристику восстановления) и не открывается в других (у которых ее нет)

PDF/A и PDF/UA налагают дополнительные ограничения на дерево страниц сверх требований базовой спецификации, но ни один из них не ослабляет требование /Pages. Валидатор, проверяющий соответствие ISO 19005 или ISO 14289, отловит отсутствие словаря Pages как нарушение базовой спецификации еще до того, как дойдет до специфичных для профиля правил