Диагностика расширенного 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() |
PdfReader — точка входа
Заголовок раздела «PdfReader — точка входа»Создайте \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 — лексический анализ
Заголовок раздела «PdfTokenizer — лексический анализ»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 — фильтры потоков
Заголовок раздела «StreamDecoder — фильтры потоков»StreamDecoder::decode(string $data, string|array $filter) — статический метод; он применяет один фильтр или цепочку фильтров. Он поддерживает ровно те фильтры, которые выдаёт printToPDF Chrome:
FlateDecode(zlib, с запасным вариантом raw-deflate)ASCIIHexDecodeASCII85Decode
Любое другое имя фильтра приводит к PdfParseException с Unsupported stream filter. Декодер ограничивает распакованный вывод 16 МиБ, чтобы снизить риск архивной бомбы. При слишком большом выводе он выбрасывает исключение, а не выделяет память без ограничения. Когда PdfReader читает поток и декодирование выбрасывает исключение, он откатывается к сырым байтам потока, поэтому один плохой фильтр не прерывает весь разбор.
ResourceCollector — глубокий обход ресурсов
Заголовок раздела «ResourceCollector — глубокий обход ресурсов»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. Пример оформлен для продакшена: он объявляет строгие типы, использует полные подсказки типов, проверяет ввод и перехватывает наиболее конкретное исключение.
- Прочитайте байты PDF в память и отклоните пустой ввод перед созданием читателя.
- Создайте
\NextPDF\Parser\PdfReaderи вызовитеparse(). - Прочитайте
getRevisionCount(). Значение1означает файл с одной ревизией без инкрементных обновлений. - Для каждой ревизии прочитайте её
RevisionXRefTableи проверьтеgetActiveObjectCount(),hasRootUpdate()иgetSize(). - Вычислите диапазоны байтов по каждой ревизии с помощью
RevisionExtractor::getRevisionBoundaries(). - Перехватите
PdfParseException, наиболее конкретное исключение, выбрасываемое парсером, и выведите диагностическое сообщение.
<?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) к самой старой. Чтобы извлечь один более старый снимок как самостоятельные байты — например, чтобы сравнить, что изменило редактирование, — вызовите экстрактор напрямую:
<?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- header | PdfReader::parse() | Байты не являются PDF или ввод был усечён в начале. |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | Хвост файла повреждён, или указатель перекрёстных ссылок находится вне границ. |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | Традиционная таблица перекрёстных ссылок некорректна. |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::parseXRefStream() | В потоке перекрёстных ссылок отсутствуют обязательные записи словаря. |
exceeds limit of (счётчик xref или потока объектов) | CrossRefParser / PdfReader | Поддельный счётчик упёрся в защиту от отказа в обслуживании. |
Unsupported stream filter | StreamDecoder::decode() | Поток использует фильтр вне поддерживаемого набора FlateDecode / ASCIIHexDecode / ASCII85Decode. |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | Сжатые данные некорректны или расширяются за предел 16 МиБ. |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | Сконструированная или патологическая структура сработала на ограничении токенизатора. |
Page index ... not found / out of range in subtree | PdfReader::getPage() | Запрошенный индекс страницы не существует в дереве страниц. |
Revision index ... out of range | PdfReader / RevisionExtractor | Индекс ревизии находится вне диапазона от 0 до getRevisionCount() - 1. |
Когда вы перехватываете исключение, записывайте в журнал сообщение и исходный путь, затем либо пробрасывайте его дальше, либо возвращайте конкретную ошибку. Не отбрасывайте его молча. Пустой блок catch скрывает единственную информацию, которую парсер пытался выдать.
<?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, которое выдаёт
printToPDFChrome. Зашифрованные 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 (лексические соглашения) — спецификация, которую реализуют токенизатор и парсер перекрёстных ссылок. Обращайтесь к опубликованному стандарту для авторитетного формата на уровне байтов.