Diagnóstico avançado do parser de PDF
Visão geral
Seção intitulada “Visão geral”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.
Quando você precisa disto
Seção intitulada “Quando você precisa disto”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çaNextPDF\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.
Superfície do parser
Seção intitulada “Superfície do parser”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.
| Classe | Responsabilidade | Métodos principais |
|---|---|---|
PdfReader | Lê 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() |
PdfTokenizer | Analisa 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() |
CrossRefParser | Analisa tabelas de referências cruzadas tradicionais e fluxos de referências cruzadas. | parseXRefTable(), parseXRefStream() |
StreamDecoder | Decodifica os bytes do fluxo por /Filter. | decode() (estático) |
ResourceCollector | Percorre uma árvore de Resources recursivamente e coleta todos os objetos indiretos alcançáveis. | traverse(), getCollected() |
RevisionExtractor | Divide um arquivo atualizado incrementalmente em intervalos de bytes por revisão. | extractRevision() (static), getRevisionBoundaries() (static) |
PdfObject | Objeto indireto analisado e imutável (dicionário mais fluxo opcional). | get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes() |
RevisionXRefTable | Snapshot imutável de referências cruzadas por revisão. | getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize() |
PdfReader — the entry point
Seção intitulada “PdfReader — the entry point”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 umPdfObject, resolvendo automaticamente entradas de Type 2 (objetos armazenados dentro de um fluxo de objetos).getObjectNumbers()retorna umalist<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/Pagese retorna a página no índice baseado em zero.getPageContentStream(),getPageResources()egetPageMediaBox()extraem as partes de quePageImporterprecisa.collectPageResources()retornaarray<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 retorna1.getRevisionXRef(int $index)retorna umRevisionXRefTable(o índice0é o mais recente).getRevisions()retorna alist<RevisionXRefTable>completa.
PdfTokenizer — lexical analysis
Seção intitulada “PdfTokenizer — lexical analysis”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
PdfParseExceptione 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 parareadName(),readLiteralString(),readHexString(),readArray(),readDictionary()ou um leitor de number/reference. Uma referência indiretaN G Ré retornada como a forma de array['type' => 'ref', 'num' => N, 'gen' => G].PdfObject::getRef()ePdfReader::resolveRef()reconhecem essa forma.
CrossRefParser — cross-reference resolution
Seção intitulada “CrossRefParser — cross-reference resolution”CrossRefParser analisa os dois formatos que o Chrome pode emitir:
parseXRefTable()lê uma tabelaxreftradicional (estilo PDF 1.x): cabeçalhos de subseção, entradas de largura fixa de 20 bytes e, em seguida, um dicionáriotrailer.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/We 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 — stream filters
Seção intitulada “StreamDecoder — stream filters”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)ASCIIHexDecodeASCII85Decode
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 — deep resource traversal
Seção intitulada “ResourceCollector — deep resource traversal”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%%EOFda revisão solicitada. A revisão0(mais recente) retorna o arquivo inteiro; índices mais altos retornam snapshots progressivamente mais antigos.getRevisionBoundaries(string $pdfData, PdfReader $reader)retorna umalist<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.
Passo a passo: inspecionando uma revisão
Seção intitulada “Passo a passo: inspecionando uma revisão”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.
- Leia os bytes do PDF para a memória e rejeite a entrada vazia antes de construir o leitor.
- Construa
\NextPDF\Parser\PdfReadere chameparse(). - Leia
getRevisionCount(). Um valor de1significa um arquivo de revisão única, sem atualizações incrementais. - Para cada revisão, leia seu
RevisionXRefTablee inspecionegetActiveObjectCount(),hasRootUpdate()egetSize(). - Calcule os intervalos de bytes por revisão com
RevisionExtractor::getRevisionBoundaries(). - Capture
PdfParseException, a exceção mais específica que o parser lança, e exiba uma mensagem de diagnóstico.
<?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:
<?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);}Tratamento de falhas
Seção intitulada “Tratamento de falhas”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 mensagem | Estágio | O que significa |
|---|---|---|
missing %PDF- header | PdfReader::parse() | Os bytes não são um PDF, ou a entrada foi truncada no início. |
Cannot find startxref marker / Invalid startxref offset | PdfReader::parse() | O final do arquivo está corrompido, ou o ponteiro de referências cruzadas está fora dos limites. |
Expected 'xref' keyword / Invalid xref subsection header | CrossRefParser::parseXRefTable() | Uma tabela de referências cruzadas tradicional está malformada. |
XRef stream ... /Type /XRef / invalid /W array | CrossRefParser::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 / PdfReader | Uma contagem forjada acionou uma proteção contra negação de serviço. |
Unsupported stream filter | StreamDecoder::decode() | O fluxo usa um filtro fora do conjunto suportado FlateDecode / ASCIIHexDecode / ASCII85Decode. |
FlateDecode decompression failed / output exceeds ... bytes limit | StreamDecoder | Os dados compactados são inválidos ou se expandem além do limite de 16 MiB. |
Maximum nesting depth ... exceeded / Keyword exceeds maximum length | PdfTokenizer | Uma estrutura criada ou patológica acionou um limite do tokenizer. |
Page index ... not found / out of range in subtree | PdfReader::getPage() | O índice de página solicitado não existe na árvore de páginas. |
Revision index ... out of range | PdfReader / RevisionExtractor | O í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.
<?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;}Padrões seguros
Seção intitulada “Padrões seguros”- Sempre chame
parse()primeiro. Todo acessor dePdfReaderpressupõe que a cadeia de referências cruzadas esteja carregada. ChamargetObject()ougetPage()antes deparse()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
printToPDFdo 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
PdfParseExceptionproveniente 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
0como padrão.getPage()ePageImporter::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\Exceptiongenérico. É o único tipo específico que o parser lança. Capturá-la evita que falhas não relacionadas sejam mascaradas.
Veja também
Seção intitulada “Veja também”- Guia do desenvolvedor do Artisan — o limite de importação acima do parser, incluindo
ChromeHtmlRenderer,PageImportere 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.