Перейти к содержимому

Диагностика расширенного PDF-парсера

Путь импорта Artisan читает файл Portable Document Format (PDF), сгенерированный Chrome, и переносит одну страницу в документ NextPDF. Если сложный ввод ломает этот импорт, опуститесь ниже PageImporter::import() — к классам парсера, которые читают файл байт за байтом.

Это руководство описывает низкоуровневый интерфейс парсера в пространстве имён NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor и объекты-значения PdfObject и RevisionXRefTable. Каждый символ, показанный здесь, существует в nextpdf/artisan. Руководство описывает парсер таким, как он реализован, а не как идеализированный интерфейс.

Используйте это руководство и как объяснение, и как практическую инструкцию. Оно показывает, как части работают вместе, а затем проводит вас через проверку ревизии инкрементного обновления. О границе импорта над этим слоем см. руководство разработчика Artisan.

Используйте интерфейс парсера только тогда, когда обычный путь импорта уже завершился сбоем и нужно найти причину. Типичные триггеры:

  • PageImporter::import() выбрасывает NextPDF\Artisan\Exception\PdfParseException, и вам нужно понять, виноваты ли таблица перекрёстных ссылок, фильтр потока или дерево страниц.
  • Обновление Chrome меняет формат вывода — например, когда традиционная таблица перекрёстных ссылок становится потоком перекрёстных ссылок или наоборот, и ваши фикстуры перестают совпадать.
  • Вы получаете сторонний PDF, который не был создан Chrome, и хотите подтвердить, может ли парсер вообще его прочитать.
  • Вы анализируете инкрементно обновлённый документ, и вам нужны диапазоны байтов по каждой ревизии или видимость объектов.

Если вы пишете обычную интеграцию рендерера, этот интерфейс вам не нужен. Парсер — внутренний диагностический инструмент, а не PDF-библиотека общего назначения. Он не поддерживает зашифрованные PDF, таблицы подсказок линеаризации и инкрементные обновления с конфликтующими переопределениями объектов.

Парсер — небольшой набор классов с единственной ответственностью. PdfReader — точка входа. Остальные классы — вспомогательные: он создаёт их или вызывает.

КлассОтветственностьКлючевые методы
PdfReaderЧитает структуру файла, разрешает объекты и обходит дерево страниц.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerАнализирует лексический синтаксис согласно ISO 32000-2:2020 §7.2: имена, строки, числа, словари, массивы и ссылки.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserРазбирает традиционные таблицы перекрёстных ссылок и потоки перекрёстных ссылок.parseXRefTable(), parseXRefStream()
StreamDecoderДекодирует байты потока по /Filter.decode() (статический)
ResourceCollectorРекурсивно обходит дерево Resources и собирает каждый достижимый косвенный объект.traverse(), getCollected()
RevisionExtractorРазрезает инкрементно обновлённый файл на диапазоны байтов по каждой ревизии.extractRevision() (статический), getRevisionBoundaries() (статический)
PdfObjectНеизменяемый разобранный косвенный объект (словарь и необязательный поток).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableНеизменяемый снимок перекрёстных ссылок по каждой ревизии.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Создайте \NextPDF\Parser\PdfReader из сырых байтов PDF, затем вызовите parse() до любого другого метода. parse() проверяет заголовок %PDF-, находит startxref в хвосте файла и обходит цепочку перекрёстных ссылок по ссылкам /Prev.

После parse() читатель предоставляет три группы методов:

  • Доступ к объектам. getObject(int $objNum) возвращает PdfObject и автоматически разрешает записи типа 2 (объекты, хранящиеся внутри потока объектов). getObjectNumbers() возвращает отсортированный list<int> всех несвободных номеров объектов. resolveRef(mixed $value) проходит по одной косвенной ссылке. Прямое значение возвращается без изменений.
  • Доступ к страницам. getPage(int $pageIndex) разрешает каталог, обходит /Pages и возвращает страницу по индексу, начинающемуся с нуля. getPageContentStream(), getPageResources() и getPageMediaBox() извлекают части, нужные PageImporter. collectPageResources() возвращает array<int, PdfObject> для каждого объекта, достижимого из Resources и Contents страницы.
  • Доступ к ревизиям. getRevisionCount() возвращает количество инкрементных ревизий. Файл с одной ревизией возвращает 1. getRevisionXRef(int $index) возвращает один RevisionXRefTable (индекс 0 — самый недавний). getRevisions() возвращает полный list<RevisionXRefTable>.

PdfTokenizer читает поток байтов. Вы редко создаёте его сами: PdfReader и CrossRefParser управляют собственными экземплярами. Проверяйте этот слой, когда разбор завершается сбоем на некорректном токене. Для диагностики важны два поведения:

  • Ограничения безопасности — это константы, а не конфигурация. Токенизатор ограничивает вложенность литеральных строк, словарей и массивов, длину ключевых слов и количество элементов массива. Когда ввод превышает ограничение, он выбрасывает PdfParseException и называет это ограничение в сообщении. Сконструированный ввод, срабатывающий на одном из этих ограничений, — защита, работающая как задумано, а не ошибка парсера.
  • readValue() направляет разбор. Он проверяет следующий байт и делегирует чтение в readName(), readLiteralString(), readHexString(), readArray(), readDictionary() или в обработчик чисел/ссылок. Косвенная ссылка N G R возвращается как массив формы ['type' => 'ref', 'num' => N, 'gen' => G]. PdfObject::getRef() и PdfReader::resolveRef() распознают эту форму.

CrossRefParser — разрешение перекрёстных ссылок

Заголовок раздела «CrossRefParser — разрешение перекрёстных ссылок»

CrossRefParser разбирает оба формата, которые может выдавать Chrome:

  • parseXRefTable() читает традиционную таблицу xref (стиль PDF 1.x): заголовки подразделов, записи фиксированной ширины по 20 байт, а затем словарь trailer.
  • parseXRefStream() читает поток перекрёстных ссылок (PDF 2.0, ISO 32000-2:2020 §7.5.8): косвенный объект с /Type /XRef, массив ширин полей /W и двоичный поток записей.

Оба возвращают одну и ту же форму: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() решает, какой парсер вызвать, просматривая четыре байта по смещению перекрёстных ссылок: xref выбирает парсер таблицы, а всё остальное обрабатывается как объект-поток. Оба парсера применяют потолок в один миллион записей на подраздел, чтобы отклонять поддельные счётчики, которые иначе заставили бы парсер выполнять лишнюю работу.

StreamDecoder::decode(string $data, string|array $filter) — статический метод; он применяет один фильтр или цепочку фильтров. Он поддерживает ровно те фильтры, которые выдаёт printToPDF Chrome:

  • FlateDecode (zlib, с запасным вариантом raw-deflate)
  • ASCIIHexDecode
  • ASCII85Decode

Любое другое имя фильтра приводит к PdfParseException с Unsupported stream filter. Декодер ограничивает распакованный вывод 16 МиБ, чтобы снизить риск архивной бомбы. При слишком большом выводе он выбрасывает исключение, а не выделяет память без ограничения. Когда PdfReader читает поток и декодирование выбрасывает исключение, он откатывается к сырым байтам потока, поэтому один плохой фильтр не прерывает весь разбор.

ResourceCollector создаётся с PdfReader и вызывается через PdfReader::collectPageResources(). Его метод traverse() рекурсивно обходит значение, проходит по каждой ссылке ['type' => 'ref'] через getObject() и один раз записывает каждый разрешённый объект в array<int, PdfObject> с ключом по номеру объекта. Он ограничивает глубину рекурсии и молча пропускает ссылки, которые не может разрешить, поэтому одна висячая ссылка даёт частичную коллекцию вместо жёсткого сбоя.

RevisionExtractor — инкрементные обновления и ревизии

Заголовок раздела «RevisionExtractor — инкрементные обновления и ревизии»

PDF, который был подписан, аннотирован или иначе отредактирован после создания, содержит инкрементные обновления. Каждое редактирование добавляет новую секцию перекрёстных ссылок и трейлер, завершаясь маркером %%EOF. RevisionExtractor полностью работает через статические методы поверх разобранного PdfReader:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) возвращает файл, усечённый на границе %%EOF запрошенной ревизии. Ревизия 0 (самая недавняя) возвращает весь файл; более высокие индексы возвращают всё более старые снимки.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) возвращает list<array{revision, startByte, endByte, sizeBytes}>, описывающий диапазон байтов, который внесла каждая ревизия.

Такая изоляция сделана намеренно. Извлечение более старой ревизии раскрывает только объекты, видимые до этой точки, что блокирует гибридные атаки на перекрёстные ссылки, при которых более поздняя ревизия переопределяет более ранний объект.

Эта процедура проверяет историю ревизий PDF, который могли отредактировать после создания в Chrome. Пример оформлен для продакшена: он объявляет строгие типы, использует полные подсказки типов, проверяет ввод и перехватывает наиболее конкретное исключение.

  1. Прочитайте байты PDF в память и отклоните пустой ввод перед созданием читателя.
  2. Создайте \NextPDF\Parser\PdfReader и вызовите parse().
  3. Прочитайте getRevisionCount(). Значение 1 означает файл с одной ревизией без инкрементных обновлений.
  4. Для каждой ревизии прочитайте её RevisionXRefTable и проверьте getActiveObjectCount(), hasRootUpdate() и getSize().
  5. Вычислите диапазоны байтов по каждой ревизии с помощью RevisionExtractor::getRevisionBoundaries().
  6. Перехватите PdfParseException, наиболее конкретное исключение, выбрасываемое парсером, и выведите диагностическое сообщение.
examples/inspect-revisions.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
use NextPDF\Parser\RevisionXRefTable;
/**
* Inspect the incremental-update history of a PDF file.
*
* @return list<array{revision: int, activeObjects: int, rootUpdate: bool, size: int, startByte: int, endByte: int, sizeBytes: int}>
*
* @throws PdfParseException If the file is not a readable PDF.
*/
function inspectRevisions(string $path): array
{
$pdfData = \file_get_contents($path);
if ($pdfData === false || $pdfData === '') {
throw new PdfParseException("Cannot read PDF bytes from path: {$path}");
}
$reader = new PdfReader($pdfData);
$reader->parse();
$boundaries = RevisionExtractor::getRevisionBoundaries($pdfData, $reader);
$report = [];
foreach ($reader->getRevisions() as $table) {
\assert($table instanceof RevisionXRefTable);
$index = $table->index;
$boundary = $boundaries[$index];
$report[] = [
'revision' => $index,
'activeObjects' => $table->getActiveObjectCount(),
'rootUpdate' => $table->hasRootUpdate(),
'size' => $table->getSize(),
'startByte' => $boundary['startByte'],
'endByte' => $boundary['endByte'],
'sizeBytes' => $boundary['sizeBytes'],
];
}
return $report;
}

Читатель упорядочивает ревизии от самой новой (index0) к самой старой. Чтобы извлечь один более старый снимок как самостоятельные байты — например, чтобы сравнить, что изменило редактирование, — вызовите экстрактор напрямую:

examples/extract-revision.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use NextPDF\Parser\RevisionExtractor;
/**
* Extract one revision of a PDF as standalone bytes.
*
* @throws PdfParseException If the file is unreadable or the revision index is out of range.
*/
function extractRevision(string $pdfData, int $revision): string
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
$reader->parse();
// Throws PdfParseException with an "out of range" message for an invalid index.
return RevisionExtractor::extractRevision($pdfData, $reader, $revision);
}

Каждый сбой парсера проявляется как NextPDF\Artisan\Exception\PdfParseException. Сообщение указывает причину. Используйте таблицу ниже, чтобы сопоставить фрагмент сообщения со стадией, которая его вызвала.

Фрагмент сообщенияСтадияЧто это означает
missing %PDF- headerPdfReader::parse()Байты не являются PDF или ввод был усечён в начале.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()Хвост файла повреждён, или указатель перекрёстных ссылок находится вне границ.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Традиционная таблица перекрёстных ссылок некорректна.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()В потоке перекрёстных ссылок отсутствуют обязательные записи словаря.
exceeds limit of (счётчик xref или потока объектов)CrossRefParser / PdfReaderПоддельный счётчик упёрся в защиту от отказа в обслуживании.
Unsupported stream filterStreamDecoder::decode()Поток использует фильтр вне поддерживаемого набора FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderСжатые данные некорректны или расширяются за предел 16 МиБ.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerСконструированная или патологическая структура сработала на ограничении токенизатора.
Page index ... not found / out of range in subtreePdfReader::getPage()Запрошенный индекс страницы не существует в дереве страниц.
Revision index ... out of rangePdfReader / RevisionExtractorИндекс ревизии находится вне диапазона от 0 до getRevisionCount() - 1.

Когда вы перехватываете исключение, записывайте в журнал сообщение и исходный путь, затем либо пробрасывайте его дальше, либо возвращайте конкретную ошибку. Не отбрасывайте его молча. Пустой блок catch скрывает единственную информацию, которую парсер пытался выдать.

examples/parse-with-diagnostics.php
<?php
declare(strict_types=1);
namespace App\Pdf\Diagnostics;
use NextPDF\Artisan\Exception\PdfParseException;
use NextPDF\Parser\PdfReader;
use Psr\Log\LoggerInterface;
/**
* Parse a PDF, logging the precise parser-stage message on failure.
*
* @throws PdfParseException Rethrown after logging so the caller can decide policy.
*/
function parseWithDiagnostics(string $pdfData, LoggerInterface $logger): PdfReader
{
if ($pdfData === '') {
throw new PdfParseException('Empty PDF input');
}
$reader = new PdfReader($pdfData);
try {
$reader->parse();
} catch (PdfParseException $exception) {
$logger->error('PDF parse failed', [
'reason' => $exception->getMessage(),
'bytes' => \strlen($pdfData),
]);
throw $exception;
}
return $reader;
}
  • Всегда сначала вызывайте parse(). Каждый аксессор PdfReader предполагает, что цепочка перекрёстных ссылок загружена. Вызов getObject() или getPage() до parse() не возвращает ничего полезного.
  • Считайте парсер доступным только для чтения и настроенным под Chrome. Он рассчитан на подмножество синтаксиса PDF, которое выдаёт printToPDF Chrome. Зашифрованные PDF, таблицы подсказок линеаризации и конфликтующие инкрементные обновления намеренно находятся вне области применения. Не превращайте его в инструмент общего восстановления PDF.
  • Оставляйте ограничения безопасности на месте. Ограничения на вложенность, длину ключевых слов, размер массива, количество перекрёстных ссылок и распаковку сдерживают использование ресурсов при враждебном вводе. PdfParseException из-за ограничения — правильный исход для сконструированного файла. Повышение ограничения, чтобы принять такой файл, расширяет поверхность атаки.
  • По умолчанию используйте страницу 0. getPage() и PageImporter::import() по умолчанию используют первую страницу. Выбирайте другой индекс только тогда, когда рабочий процесс намеренно этого требует.
  • Проверяйте ввод перед созданием читателя. Отклоняйте пустые или нечитаемые байты заранее, как делают примеры выше, чтобы понятная ошибка уровня приложения появилась до любого исключения парсера.
  • Перехватывайте PdfParseException, а не голое \Exception. Это единственный конкретный тип, который выбрасывает парсер. Его перехват не даёт несвязанным сбоям маскироваться.
  • Руководство разработчика Artisan — граница импорта над парсером, включая ChromeHtmlRenderer, PageImporter и слои архитектуры.
  • Справочник API Artisan — опубликованные таблицы методов для публичной поверхности пакета.
  • Устранение неполадок Artisan — руководство по симптомам сбоев рендерера и импорта.
  • Настройка рендерера Chrome — настройка рендерера, который создаёт PDF, читаемые этим парсером.
  • ISO 32000-2:2020 §7.5 (структура файла, перекрёстные ссылки, инкрементные обновления) и §7.2 (лексические соглашения) — спецификация, которую реализуют токенизатор и парсер перекрёстных ссылок. Обращайтесь к опубликованному стандарту для авторитетного формата на уровне байтов.