Pular para o conteúdo

Diagnóstico avançado do parser de PDF

O caminho de importação do Artisan lê um arquivo Portable Document Format (PDF) gerado pelo Chrome e traz uma página para um documento NextPDF. Quando uma entrada difícil quebra essa importação, investigue abaixo de PageImporter::import(), nas classes do parser que leem o arquivo byte a byte.

Este guia cobre a superfície do parser de baixo nível no namespace NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor e os objetos de valor PdfObject e RevisionXRefTable. Todos os símbolos mostrados aqui existem em nextpdf/artisan. O guia descreve o parser como ele foi construído, não uma interface idealizada.

Use este guia tanto como explicação quanto como passo a passo. Ele mostra como as peças se encaixam e, em seguida, conduz você pela inspeção de uma revisão de atualização incremental. Para o limite de importação acima desta camada, consulte o guia do desenvolvedor do Artisan.

Use a superfície do parser somente quando o caminho de importação normal já tiver falhado e você precisar encontrar a causa. Os gatilhos típicos incluem:

  • PageImporter::import() lança NextPDF\Artisan\Exception\PdfParseException, e você precisa saber se a responsável é a tabela de referências cruzadas, um filtro de fluxo ou a árvore de páginas.
  • Uma atualização do Chrome altera o formato de saída, como quando uma tabela de referências cruzadas tradicional se torna um fluxo de referências cruzadas, ou vice-versa, e suas fixtures deixam de corresponder.
  • Você recebe um PDF de terceiros que o Chrome não produziu e quer confirmar se o parser consegue lê-lo.
  • Você está analisando um documento atualizado incrementalmente e precisa dos intervalos de bytes por revisão ou da visibilidade dos objetos.

Se você está escrevendo uma integração de renderização normal, não precisa desta superfície. O parser é uma ferramenta de diagnóstico interna, não uma biblioteca PDF de propósito geral. Ele não oferece suporte a PDFs criptografados, tabelas de dicas linearizadas nem atualizações incrementais com redefinições de objeto conflitantes.

O parser é um pequeno conjunto de classes de responsabilidade única. PdfReader é o ponto de entrada. As demais classes são colaboradoras que ele constrói ou chama.

ClasseResponsabilidadeMétodos principais
PdfReaderLê a estrutura do arquivo, resolve objetos e percorre a árvore de páginas.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerAnalisa a sintaxe léxica conforme ISO 32000-2:2020 §7.2: nomes, strings, números, dicionários, arrays e referências.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserAnalisa tabelas de referências cruzadas tradicionais e fluxos de referências cruzadas.parseXRefTable(), parseXRefStream()
StreamDecoderDecodifica os bytes do fluxo por /Filter.decode() (estático)
ResourceCollectorPercorre uma árvore de Resources recursivamente e coleta todos os objetos indiretos alcançáveis.traverse(), getCollected()
RevisionExtractorDivide um arquivo atualizado incrementalmente em intervalos de bytes por revisão.extractRevision() (static), getRevisionBoundaries() (static)
PdfObjectObjeto indireto analisado e imutável (dicionário mais fluxo opcional).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableSnapshot imutável de referências cruzadas por revisão.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Construa \NextPDF\Parser\PdfReader com os bytes brutos do PDF e, em seguida, chame parse() antes de chamar qualquer outro método. parse() verifica o cabeçalho %PDF-, localiza startxref no final do arquivo e percorre a cadeia de referências cruzadas seguindo os vínculos /Prev.

Após parse(), o leitor expõe três grupos de métodos:

  • Acesso a objetos. getObject(int $objNum) retorna um PdfObject, resolvendo automaticamente entradas de Type 2 (objetos armazenados dentro de um fluxo de objetos). getObjectNumbers() retorna uma list<int> ordenada com todos os números de objeto não livres. resolveRef(mixed $value) segue uma referência indireta. Um valor direto é repassado sem alterações.
  • Acesso a páginas. getPage(int $pageIndex) resolve o catálogo, percorre /Pages e retorna a página no índice baseado em zero. getPageContentStream(), getPageResources() e getPageMediaBox() extraem as partes de que PageImporter precisa. collectPageResources() retorna array<int, PdfObject> para cada objeto alcançável a partir de Resources e Contents da página.
  • Acesso a revisões. getRevisionCount() retorna o número de revisões incrementais. Um arquivo de revisão única retorna 1. getRevisionXRef(int $index) retorna um RevisionXRefTable (o índice 0 é o mais recente). getRevisions() retorna a list<RevisionXRefTable> completa.

PdfTokenizer lê o fluxo de bytes. Você raramente o constrói diretamente, porque PdfReader e CrossRefParser são donos de suas instâncias. Inspecione esta camada quando uma análise falhar em um token malformado. Dois comportamentos importam para o diagnóstico:

  • Os limites de segurança são constantes, não configuração. O tokenizer limita o aninhamento de strings literais, o aninhamento de dicionários e arrays, o comprimento das palavras-chave e a quantidade de elementos de array. Quando a entrada excede um limite, ele lança PdfParseException e nomeia o limite na mensagem. Uma entrada criada para acionar um desses limites é uma defesa funcionando como projetada, não um bug do parser.
  • readValue() direciona a análise. Ele inspeciona o próximo byte e delega para readName(), readLiteralString(), readHexString(), readArray(), readDictionary() ou um leitor de number/reference. Uma referência indireta N G R é retornada como a forma de array ['type' => 'ref', 'num' => N, 'gen' => G]. PdfObject::getRef() e PdfReader::resolveRef() reconhecem essa forma.

CrossRefParser analisa os dois formatos que o Chrome pode emitir:

  • parseXRefTable() lê uma tabela xref tradicional (estilo PDF 1.x): cabeçalhos de subseção, entradas de largura fixa de 20 bytes e, em seguida, um dicionário trailer.
  • parseXRefStream() lê um fluxo de referências cruzadas (PDF 2.0, ISO 32000-2:2020 §7.5.8): um objeto indireto com /Type /XRef, um array de largura de campos /W e um fluxo binário de entradas.

Ambos retornam a mesma forma: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() decide qual parser chamar espiando os quatro bytes no deslocamento da referência cruzada: xref seleciona o parser de tabela, e qualquer outra coisa é tratada como um objeto de fluxo. Ambos os parsers impõem um teto de um milhão de entradas por subseção para rejeitar contagens forjadas que, de outra forma, fariam o parser trabalhar em excesso.

StreamDecoder::decode(string $data, string|array $filter) é estático e aplica um filtro ou uma lista encadeada de filtros. Ele oferece suporte exatamente aos filtros que o printToPDF do Chrome emite:

  • FlateDecode (zlib, com um fallback de raw-deflate)
  • ASCIIHexDecode
  • ASCII85Decode

Qualquer outro nome de filtro lança PdfParseException com Unsupported stream filter. O decodificador limita a saída descompactada a 16 MiB para conter o risco de bomba de descompressão. Uma saída grande demais lança um erro em vez de alocar memória sem limite. Quando PdfReader lê um fluxo e a decodificação lança um erro, ele recorre aos bytes brutos do fluxo, de modo que um filtro inválido não aborta toda a análise.

ResourceCollector é construído com o PdfReader e chamado por meio de PdfReader::collectPageResources(). Seu método traverse() percorre um valor recursivamente, segue cada referência ['type' => 'ref'] por meio de getObject() e registra cada objeto resolvido uma única vez em um array<int, PdfObject> indexado pelo número do objeto. Ele limita a profundidade da recursão e ignora silenciosamente as referências que não consegue resolver, de modo que uma referência pendente produz uma coleta parcial em vez de uma falha grave.

RevisionExtractor — incremental updates and revisions

Seção intitulada “RevisionExtractor — incremental updates and revisions”

Um PDF que foi assinado, anotado ou editado de outra forma após a criação carrega atualizações incrementais. Cada edição acrescenta uma nova seção de referências cruzadas e um trailer, terminando em um marcador %%EOF. RevisionExtractor funciona inteiramente a partir de métodos estáticos sobre um PdfReader já analisado:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) retorna o arquivo truncado no limite %%EOF da revisão solicitada. A revisão 0 (mais recente) retorna o arquivo inteiro; índices mais altos retornam snapshots progressivamente mais antigos.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) retorna uma list<array{revision, startByte, endByte, sizeBytes}> descrevendo o intervalo de bytes que cada revisão contribuiu.

Esse isolamento é deliberado. Extrair uma revisão mais antiga expõe somente os objetos visíveis até aquele ponto, o que bloqueia ataques de referências cruzadas híbridas em que uma revisão posterior redefine um objeto anterior.

Este procedimento inspeciona o histórico de revisões de um PDF que pode ter sido editado depois que o Chrome o produziu. O exemplo foi moldado para produção: ele declara tipos estritos, usa type hints completos, valida sua entrada e captura a exceção mais específica.

  1. Leia os bytes do PDF para a memória e rejeite a entrada vazia antes de construir o leitor.
  2. Construa \NextPDF\Parser\PdfReader e chame parse().
  3. Leia getRevisionCount(). Um valor de 1 significa um arquivo de revisão única, sem atualizações incrementais.
  4. Para cada revisão, leia seu RevisionXRefTable e inspecione getActiveObjectCount(), hasRootUpdate() e getSize().
  5. Calcule os intervalos de bytes por revisão com RevisionExtractor::getRevisionBoundaries().
  6. Capture PdfParseException, a exceção mais específica que o parser lança, e exiba uma mensagem de diagnóstico.
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;
}

O leitor ordena as revisões da mais nova (index0) para a mais antiga. Para extrair um snapshot mais antigo como bytes independentes, por exemplo, para comparar o que uma edição alterou, chame o extrator diretamente:

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);
}

Toda falha do parser se manifesta como NextPDF\Artisan\Exception\PdfParseException. A mensagem identifica a causa. Use a tabela abaixo para mapear um fragmento de mensagem para o estágio que o gerou.

Fragmento de mensagemEstágioO que significa
missing %PDF- headerPdfReader::parse()Os bytes não são um PDF, ou a entrada foi truncada no início.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()O final do arquivo está corrompido, ou o ponteiro de referências cruzadas está fora dos limites.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Uma tabela de referências cruzadas tradicional está malformada.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()Um fluxo de referências cruzadas está sem entradas de dicionário obrigatórias.
exceeds limit of (contagem de xref ou de fluxo de objetos)CrossRefParser / PdfReaderUma contagem forjada acionou uma proteção contra negação de serviço.
Unsupported stream filterStreamDecoder::decode()O fluxo usa um filtro fora do conjunto suportado FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderOs dados compactados são inválidos ou se expandem além do limite de 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerUma estrutura criada ou patológica acionou um limite do tokenizer.
Page index ... not found / out of range in subtreePdfReader::getPage()O índice de página solicitado não existe na árvore de páginas.
Revision index ... out of rangePdfReader / RevisionExtractorO índice de revisão está fora do intervalo de 0 a getRevisionCount() - 1.

Quando você captura a exceção, registre a mensagem e o caminho de origem e, em seguida, relance ou retorne um erro definido. Não a descarte silenciosamente. Um bloco catch vazio oculta a única informação que o parser se esforçou para produzir.

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;
}
  • Sempre chame parse() primeiro. Todo acessor de PdfReader pressupõe que a cadeia de referências cruzadas esteja carregada. Chamar getObject() ou getPage() antes de parse() não retorna nada útil.
  • Trate o parser como somente leitura e moldado para o Chrome. Ele tem como alvo o subconjunto da sintaxe PDF que o printToPDF do Chrome emite. PDFs criptografados, tabelas de dicas linearizadas e atualizações incrementais conflitantes estão fora do escopo por design. Não o estenda para uma ferramenta de reparo de PDF de propósito geral.
  • Mantenha os limites de segurança em vigor. Os limites de aninhamento, comprimento de palavra-chave, tamanho de array, contagem de referências cruzadas e descompressão restringem o uso de recursos diante de uma entrada hostil. Uma PdfParseException proveniente de um limite é o resultado correto para um arquivo criado para isso. Aumentar um limite para aceitar tal arquivo amplia a superfície de ataque.
  • Use a página 0 como padrão. getPage() e PageImporter::import() usam a primeira página como padrão. Escolha outro índice somente quando o fluxo de trabalho precisar dele deliberadamente.
  • Valide a entrada antes de construir o leitor. Rejeite bytes vazios ou ilegíveis cedo, como fazem os exemplos acima, para que um erro claro no nível da aplicação apareça antes de qualquer exceção do parser.
  • Capture PdfParseException, nunca um \Exception genérico. É o único tipo específico que o parser lança. Capturá-la evita que falhas não relacionadas sejam mascaradas.
  • Guia do desenvolvedor do Artisan — o limite de importação acima do parser, incluindo ChromeHtmlRenderer, PageImporter e as camadas de arquitetura.
  • Referência da API do Artisan — as tabelas de métodos publicadas para a superfície pública do pacote.
  • Solução de problemas do Artisan — orientação a partir do sintoma para falhas de renderização e importação.
  • Configuração do renderizador Chrome — configuração do renderizador que produz os PDFs que este parser lê.
  • ISO 32000-2:2020 §7.5 (estrutura de arquivo, referências cruzadas, atualizações incrementais) e §7.2 (convenções léxicas) — a especificação que o tokenizer e o parser de referências cruzadas implementam. Consulte o padrão publicado para o formato autoritativo no nível de bytes.