Ir al contenido

Diagnóstico avanzado del analizador de PDF

La ruta de importación de Artisan lee un archivo Portable Document Format (PDF) generado por Chrome y traslada una página a un documento de NextPDF. Cuando esa importación falla con una entrada compleja, conviene bajar de PageImporter::import() a las clases del analizador que leen el archivo byte a byte.

Esta guía documenta la superficie de bajo nivel del analizador en el espacio de nombres NextPDF\Parser: PdfReader, PdfTokenizer, CrossRefParser, StreamDecoder, ResourceCollector, RevisionExtractor y los objetos de valor PdfObject y RevisionXRefTable. Cada símbolo que se muestra aquí existe en nextpdf/artisan. La guía documenta el analizador tal como está construido, no una superficie idealizada.

Esta guía combina explicación y práctica: muestra cómo encajan las piezas y luego guía la inspección de una revisión de actualización incremental. Para conocer el límite de importación situado por encima de esta capa, consultar la guía para desarrolladores de Artisan.

La superficie del analizador debe usarse solo cuando la ruta de importación normal ya ha fallado y hace falta localizar la causa. Desencadenantes típicos:

  • PageImporter::import() lanza NextPDF\Artisan\Exception\PdfParseException y hay que determinar si el problema está en la tabla de referencias cruzadas, en un filtro de stream o en el árbol de páginas.
  • Una actualización de Chrome cambia el formato de salida (una tabla de referencias cruzadas tradicional pasa a ser un stream de referencias cruzadas, o viceversa) y tus fixtures dejan de coincidir.
  • Se recibe un PDF de terceros que no fue producido por Chrome y se quiere confirmar si el analizador puede llegar a leerlo.
  • Se realiza un análisis forense de un documento actualizado de forma incremental y hacen falta los rangos de bytes por revisión o la visibilidad de los objetos.

Al escribir una integración normal de renderer, esta superficie no es necesaria. El analizador es una herramienta de diagnóstico interna, no una biblioteca de PDF de propósito general: no admite PDF cifrados, tablas de pistas linearizadas ni actualizaciones incrementales con redefiniciones de objetos en conflicto.

El analizador es un pequeño conjunto de clases con responsabilidad única. PdfReader es el punto de entrada; el resto son colaboradores que este construye o invoca.

ClaseResponsabilidadMétodos clave
PdfReaderLee la estructura del archivo, resuelve objetos y recorre el árbol de páginas.parse(), getObject(), getTrailer(), getObjectNumbers(), getPage(), getPageContentStream(), getPageResources(), getPageMediaBox(), resolveRef(), collectPageResources(), getRevisionCount(), getRevisionXRef(), getRevisions()
PdfTokenizerRealiza el análisis léxico según ISO 32000-2:2020 §7.2: nombres, cadenas, números, diccionarios, arrays y referencias.readToken(), readValue(), readName(), readNumber(), readDictionary(), readArray(), readStreamData(), peek(), skipWhitespace(), getOffset(), setOffset()
CrossRefParserAnaliza tablas de referencias cruzadas tradicionales y streams de referencias cruzadas.parseXRefTable(), parseXRefStream()
StreamDecoderDecodifica los bytes del stream según /Filter.decode() (estático)
ResourceCollectorRecorre en profundidad un árbol de recursos (Resources) y recolecta cada objeto indirecto alcanzable.traverse(), getCollected()
RevisionExtractorDivide un archivo actualizado de forma incremental en rangos de bytes por revisión.extractRevision() (estático), getRevisionBoundaries() (estático)
PdfObjectObjeto indirecto analizado e inmutable (diccionario más un stream opcional).get(), getRef(), getArray(), getType(), getSubtype(), hasStream(), getDictionary(), getRawStreamData(), getRawDictionaryBytes()
RevisionXRefTableInstantánea inmutable de referencias cruzadas por revisión.getObjectNumbers(), getActiveObjectCount(), hasRootUpdate(), getSize()

Construir \NextPDF\Parser\PdfReader con los bytes crudos del PDF y llamar a parse() antes de usar cualquier otro método. parse() comprueba la cabecera %PDF-, encuentra startxref al final del archivo y recorre la cadena de referencias cruzadas siguiendo los enlaces /Prev.

Después de parse(), el lector expone tres grupos de métodos:

  • Acceso a objetos. getObject(int $objNum) devuelve un PdfObject y resuelve de forma transparente las entradas de tipo 2 (objetos almacenados dentro de un stream de objetos). getObjectNumbers() devuelve una list<int> ordenada con cada número de objeto que no esté libre. resolveRef(mixed $value) sigue una única referencia indirecta; un valor directo pasa sin cambios.
  • Acceso a páginas. getPage(int $pageIndex) resuelve el catálogo, recorre /Pages y devuelve la página en el índice de base cero. getPageContentStream(), getPageResources() y getPageMediaBox() extraen las partes que PageImporter necesita. collectPageResources() devuelve un array<int, PdfObject> con cada objeto alcanzable desde los Resources y Contents de la página.
  • Acceso a revisiones. getRevisionCount() devuelve el número de revisiones incrementales (un archivo de una sola revisión devuelve 1). getRevisionXRef(int $index) devuelve una RevisionXRefTable (el índice 0 es el más reciente). getRevisions() devuelve la list<RevisionXRefTable> completa.

PdfTokenizer lee el stream de bytes. Normalmente no hace falta construirlo directamente: PdfReader y CrossRefParser poseen sus instancias, pero es la capa que conviene inspeccionar cuando un análisis falla con un token mal formado. Para el diagnóstico importan dos comportamientos:

  • Los límites de seguridad son constantes, no parámetros de configuración. El tokenizador limita el anidamiento de cadenas literales, el anidamiento de diccionarios y arrays, la longitud de las palabras clave y el número de elementos de un array. Cuando se supera un límite, lanza PdfParseException con el límite indicado en el mensaje. Una entrada manipulada que activa uno de estos límites es una defensa funcionando según lo previsto, no un fallo del analizador.
  • readValue() es el despachador. Inspecciona el siguiente byte y delega en readName(), readLiteralString(), readHexString(), readArray(), readDictionary() o en un lector de number/reference. Una referencia indirecta N G R se devuelve como un array con la forma ['type' => 'ref', 'num' => N, 'gen' => G]. Esta forma es la que reconocen PdfObject::getRef() y PdfReader::resolveRef().

CrossRefParser: resolución de referencias cruzadas

Sección titulada «CrossRefParser: resolución de referencias cruzadas»

CrossRefParser analiza ambos formatos que Chrome puede emitir:

  • parseXRefTable() lee una tabla xref tradicional (al estilo PDF 1.x): encabezados de subsección seguidos de entradas de ancho fijo de 20 bytes y, después, un diccionario trailer.
  • parseXRefStream() lee un stream de referencias cruzadas (PDF 2.0, ISO 32000-2:2020 §7.5.8): un objeto indirecto con /Type /XRef, un array de anchos de campo /W y un stream binario de entradas.

Ambos devuelven la misma forma: array{xref: array<int, ...>, trailer: array<string, mixed>, prevOffset: int|null}. PdfReader::parse() decide a cuál llamar inspeccionando los cuatro bytes que hay en el offset de referencias cruzadas: xref selecciona el analizador de tablas; cualquier otro contenido se trata como un objeto de stream. Ambos analizadores aplican un límite máximo de un millón de entradas por subsección para rechazar recuentos falsificados que, de otro modo, harían que el analizador se ejecutara de forma excesiva.

StreamDecoder::decode(string $data, string|array $filter) es un método estático y aplica un filtro o una lista encadenada de filtros. Admite exactamente los filtros que emite el printToPDF de Chrome:

  • FlateDecode (zlib, con un mecanismo de respaldo de deflate crudo)
  • ASCIIHexDecode
  • ASCII85Decode

Cualquier otro nombre de filtro lanza PdfParseException con Unsupported stream filter. El decodificador limita la salida descomprimida a 16 MiB para acotar el riesgo de una bomba de descompresión; una salida demasiado grande lanza una excepción en lugar de reservar memoria sin límite. Cuando PdfReader lee un stream y la decodificación lanza una excepción, usa como alternativa los bytes crudos del stream para que un único filtro defectuoso no aborte todo el análisis.

ResourceCollector: recorrido profundo de recursos

Sección titulada «ResourceCollector: recorrido profundo de recursos»

ResourceCollector se construye con el PdfReader y se invoca mediante PdfReader::collectPageResources(). Su método traverse() recorre recursivamente un valor, sigue cada referencia ['type' => 'ref'] a través de getObject() y registra cada objeto resuelto una sola vez en un array<int, PdfObject> indexado por número de objeto. Limita la profundidad de recursión y omite en silencio las referencias que no puede resolver, de modo que una única referencia colgante produce una recolección parcial en lugar de un fallo total.

RevisionExtractor: actualizaciones incrementales y revisiones

Sección titulada «RevisionExtractor: actualizaciones incrementales y revisiones»

Un PDF que se firmó, anotó o editó de algún otro modo tras su creación contiene actualizaciones incrementales: cada edición añade una nueva sección de referencias cruzadas y un trailer, que terminan en un marcador %%EOF. RevisionExtractor trabaja únicamente mediante métodos estáticos sobre un PdfReader ya analizado:

  • extractRevision(string $pdfData, PdfReader $reader, int $revision) devuelve el archivo truncado en el límite %%EOF de la revisión solicitada. La revisión 0 (la más reciente) devuelve el archivo completo; los índices más altos devuelven instantáneas progresivamente más antiguas.
  • getRevisionBoundaries(string $pdfData, PdfReader $reader) devuelve una list<array{revision, startByte, endByte, sizeBytes}> que describe el rango de bytes aportado por cada revisión.

Este aislamiento es deliberado: extraer una revisión más antigua expone solo los objetos visibles hasta ese punto, lo que bloquea ataques híbridos de referencias cruzadas en los que una revisión posterior redefine un objeto anterior.

Recorrido guiado: inspeccionar una revisión

Sección titulada «Recorrido guiado: inspeccionar una revisión»

Este procedimiento inspecciona el historial de revisiones de un PDF que puede haber sido editado después de que Chrome lo produjera. El ejemplo está planteado para producción: declara tipos estrictos, usa indicadores de tipo completos, valida su entrada y captura la excepción más específica.

  1. Leer los bytes del PDF en memoria y rechazar la entrada vacía antes de construir el lector.
  2. Construir \NextPDF\Parser\PdfReader y llamar a parse().
  3. Leer getRevisionCount(). Un valor de 1 significa que es un archivo de una sola revisión sin actualizaciones incrementales.
  4. Para cada revisión, leer su RevisionXRefTable e inspeccionar getActiveObjectCount(), hasRootUpdate() y getSize().
  5. Calcular los rangos de bytes por revisión con RevisionExtractor::getRevisionBoundaries().
  6. Capturar PdfParseException, la excepción más específica que lanza el analizador, y mostrar un mensaje 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;
}

El lector ordena las revisiones de la más nueva (index0) a la más antigua. Para extraer una instantánea más antigua como bytes independientes, por ejemplo, para comparar lo que cambió una edición, se llama directamente al extractor:

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

Cada fallo del analizador se manifiesta como NextPDF\Artisan\Exception\PdfParseException. El mensaje concreta la causa. La tabla siguiente permite relacionar un fragmento de mensaje con la etapa que lo originó.

Fragmento del mensajeEtapaQué significa
missing %PDF- headerPdfReader::parse()Los bytes no corresponden a un PDF o la entrada se truncó en la cabecera.
Cannot find startxref marker / Invalid startxref offsetPdfReader::parse()La cola del archivo está corrupta o el puntero de referencias cruzadas está fuera de los límites.
Expected 'xref' keyword / Invalid xref subsection headerCrossRefParser::parseXRefTable()Una tabla de referencias cruzadas tradicional está mal formada.
XRef stream ... /Type /XRef / invalid /W arrayCrossRefParser::parseXRefStream()A un stream de referencias cruzadas le faltan entradas de diccionario obligatorias.
exceeds limit of (recuento de xref o de stream de objetos)CrossRefParser / PdfReaderUn recuento falsificado activó una protección contra denegación de servicio.
Unsupported stream filterStreamDecoder::decode()El stream usa un filtro fuera del conjunto admitido FlateDecode / ASCIIHexDecode / ASCII85Decode.
FlateDecode decompression failed / output exceeds ... bytes limitStreamDecoderLos datos comprimidos no son válidos o se expanden más allá del tope de 16 MiB.
Maximum nesting depth ... exceeded / Keyword exceeds maximum lengthPdfTokenizerUna estructura manipulada o patológica activó un límite del tokenizador.
Page index ... not found / out of range in subtreePdfReader::getPage()El índice de página solicitado no existe en el árbol de páginas.
Revision index ... out of rangePdfReader / RevisionExtractorEl índice de revisión está fuera del rango de 0 a getRevisionCount() - 1.

Al capturar la excepción, registrar el mensaje y la ruta de origen, y luego volver a lanzarla o devolver un error definido. No debe descartarse en silencio: un bloque catch vacío oculta la única información que el analizador se esforzó por producir.

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;
}
  • Llamar siempre primero a parse(). Cada accesor de PdfReader asume que la cadena de referencias cruzadas está cargada. Llamar a getObject() o getPage() antes de parse() no devuelve nada útil.
  • Tratar el analizador como de solo lectura y con forma de Chrome. Su objetivo es el subconjunto de la sintaxis de PDF que emite el printToPDF de Chrome. Los PDF cifrados, las tablas de pistas linearizadas y las actualizaciones incrementales en conflicto quedan fuera del alcance por diseño. No convertirlo en una herramienta general de reparación de PDF.
  • Mantener los límites de seguridad en su lugar. Los topes de anidamiento, longitud de palabras clave, tamaño de array, recuento de referencias cruzadas y descompresión existen para acotar el uso de recursos ante entradas hostiles. Una PdfParseException producida por un límite es el resultado correcto para un archivo manipulado; aumentar un límite para aceptar ese archivo amplía la superficie de ataque.
  • Usar de forma predeterminada la página 0. getPage() y PageImporter::import() usan de forma predeterminada la primera página. Elegir otro índice solo cuando el flujo de trabajo lo necesite deliberadamente.
  • Validar la entrada antes de construir el lector. Rechazar pronto los bytes vacíos o ilegibles, como hacen los ejemplos anteriores, para que un error claro a nivel de aplicación preceda a cualquier excepción del analizador.
  • Capturar PdfParseException, nunca \Exception a secas. Es el único tipo específico que lanza el analizador; hacerlo evita que se enmascaren fallos no relacionados.
  • Guía para desarrolladores de Artisan — el límite de importación situado por encima del analizador, incluidos ChromeHtmlRenderer, PageImporter y la disposición en capas de la arquitectura.
  • Referencia de la API de Artisan — las tablas de métodos documentadas para la superficie pública del paquete.
  • Resolución de problemas de Artisan — orientación centrada en los síntomas para los fallos del renderer y de la importación.
  • Configuración del renderer de Chrome — cómo configurar el renderer que produce los PDF que lee este analizador.
  • ISO 32000-2:2020 §7.5 (estructura del archivo, referencias cruzadas, actualizaciones incrementales) y §7.2 (convenciones léxicas) — la especificación que implementan el tokenizador y el analizador de referencias cruzadas. Consultar el estándar publicado para conocer el formato autoritativo en el nivel de bytes.