Ir al contenido

Generar una tabla de contenido dinámica a partir de la estructura del documento en tiempo de ejecución

El contenido toma forma en tiempo de ejecución: capítulos cargados desde una base de datos, secciones construidas a partir de una respuesta de una interfaz de programación de aplicaciones (API), encabezados emitidos por un bucle que no se controla de antemano. El objetivo es que el esquema del documento y una tabla de contenido en la que se pueda hacer clic coincidan exactamente con ese contenido, sin mantener una segunda lista escrita a mano que pueda desincronizarse.

Esta recipe construye el esquema de forma dinámica. A medida que se escribe cada encabezado, se leen del motor el cursor y la página actuales: getPage(), getY() y getNumPages(), y se pasan esos valores a bookmark(). El marcador queda vinculado a la posición leída en ese instante, de modo que el esquema sigue al contenido incluso cuando los saltos de página caen donde no se esperaban. Al final, addTOC() renderiza una página real de tabla de contenido a partir de esas mismas entradas.

Requisitos previos: una instalación de Core (composer require nextpdf/core:^3) y contenido cuya estructura de encabezados se descubre mientras se escribe, no antes.

Esta página cubre el patrón dinámico guiado por la posición. Para el caso estático, en el que cada encabezado y su nivel se conocen de antemano, conviene leer primero Agregar marcadores y una tabla de contenido. Esta recipe se apoya en la misma superficie de bookmark() y addTOC() sin repetirla.

Ventana de terminal
composer require nextpdf/core:^3

No se requiere ninguna extensión opcional. La superficie de navegación (bookmark(), addTOC()) y los métodos de acceso a la posición (getPage(), getY(), getNumPages()) son estables desde 1.2.0 y funcionan en toda la matriz de backport de 8.1 a 8.4.

Una tabla de contenido dinámica tiene dos partes que deben coincidir:

  • El esquema (también llamado marcadores): el árbol que el lector muestra en la barra lateral de navegación, donde cada entrada salta a una posición del documento.
  • La tabla de contenido renderizada: una página generada que enumera las mismas entradas con sus números de página.

NextPDF mantiene ambas partes sincronizadas con una sola llamada. bookmark($title, $level, $y) agrega un elemento al esquema y una entrada a la tabla de contenido, ambos vinculados a la página actual y a la posición vertical actual. No hace falta mantener dos listas.

La parte dinámica es el origen de la posición. Una recipe estática pasa encabezados literales en el orden del código fuente. Aquí se escribe un encabezado y, de inmediato, se consulta al motor dónde quedó el cursor:

  • getPage() devuelve el índice de base cero de la página activa. Antes de agregar la primera página, devuelve -1.
  • getNumPages() devuelve el total de páginas, incluida la página activa que aún no se ha volcado.
  • getY() devuelve el cursor vertical actual en unidades de usuario, medido como la distancia desde la parte superior de la página.
  • getX(), getPageHeight() y getMargins() completan el panorama cuando hace falta decidir si un encabezado y su primera línea de texto del cuerpo caben juntos.

Se leen esos valores y luego se llama a bookmark(). El salto de página automático puede mover el cursor a una página nueva entre dos encabezados, así que volver a leer la posición, en lugar de suponerla, mantiene el destino del esquema en la página correcta.

Una regla de orden gobierna todo el patrón: llamar a bookmark() en el punto exacto donde debe quedar el destino, justo antes de renderizar el texto del encabezado. Si se escribe primero el encabezado y se marca después, el getY() registrado queda directamente debajo del encabezado.

Estos son los métodos en los que se apoya esta recipe, todos en \NextPDF\Core\Document:

  • bookmark(string $title, int $level = 0, float $y = -1): static: agrega un elemento al esquema y una entrada a la tabla de contenido en $level, vinculados a la página actual. Con $y = -1, el destino es la Y actual del cursor; pasar una Y no negativa permite fijar un destino preciso.
  • addTOC(int $pageIndex = 0, string $title = ''): static: renderiza una página de tabla de contenido a partir de las entradas acumuladas y la inserta en $pageIndex. Devuelve sin insertar una página cuando no existe ningún marcador.
  • getPage(): int: índice de base cero de la página activa (-1 antes de la primera página).
  • getNumPages(): int: total de páginas, incluida la página activa aún sin volcar.
  • getY(): float: Y actual del cursor en unidades de usuario (distancia desde la parte superior de la página).
  • getX(): float: X actual del cursor en unidades de usuario.
  • getPageHeight(): float: altura de la página actual en unidades de usuario.
  • getMargins(): \NextPDF\ValueObjects\Margin: los márgenes activos (top, right, bottom, left).
  • setY(float $y): static: mueve el cursor a una Y explícita.
  • setAutoPageBreak(bool $enabled, float $margin = 20): static: controla el salto de página automático y su umbral de margen inferior.

Este ejemplo escribe tres secciones a partir de una lista generada en tiempo de ejecución. Cada iteración vuelve a leer la página actual con getPage() antes de marcar, de modo que el destino del esquema permanece correcto incluso después de un salto de página automático.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
/** @var list<array{title: string, body: string}> $sections */
$sections = [
['title' => 'Origins', 'body' => 'Runtime content for the first section.'],
['title' => 'Method', 'body' => 'Runtime content for the second section.'],
['title' => 'Results', 'body' => 'Runtime content for the third section.'],
];
$doc = Document::createStandalone();
$doc->addPage();
foreach ($sections as $section) {
// Read the live page back, then bookmark BEFORE rendering the heading,
// so the destination points at the heading, not below it.
$pageIndex = $doc->getPage();
$doc->bookmark($section['title'], level: 0);
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 10, $section['title'], newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->multiCell(0, 7, $section['body']);
$doc->ln(6);
echo "Bookmarked '{$section['title']}' on page index {$pageIndex}\n";
}
// Splice the rendered table of contents in as the first page.
$doc->addTOC(pageIndex: 0, title: 'Contents');
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/dynamic-toc.pdf');

Salida esperada en la terminal, con una línea por sección:

Bookmarked 'Origins' on page index 0
Bookmarked 'Method' on page index 0
Bookmarked 'Results' on page index 0

Esta versión gestiona un esquema de dos niveles (capítulos y secciones) a partir de una estructura anidada generada en tiempo de ejecución, mantiene cada encabezado junto con su primera línea de cuerpo leyendo la posición antes de escribir y envuelve la generación en try/catch para manejar las excepciones de NextPDF más específicas. PageLayoutException cubre una falla del lado de la generación, como exceder el límite máximo de páginas. save() lanza InvalidConfigException ante una ruta de salida no escribible o insegura.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\InvalidConfigException;
use NextPDF\Exception\PageLayoutException;
/**
* Render a report whose chapter and section structure is known only at runtime,
* building the outline and table of contents from the live cursor position.
*
* @param list<array{title: string, sections: list<array{title: string, body: string}>}> $chapters
*
* @throws PageLayoutException When page generation exceeds an engine limit.
* @throws InvalidConfigException When the output path cannot be written.
*/
function renderDynamicToc(array $chapters, string $outputPath): void
{
$doc = Document::createStandalone();
$doc->setTitle('Runtime Report');
$doc->setPrintHeader(false);
$doc->setPrintFooter(false);
// A 25 mm bottom threshold so a heading does not strand at the page foot.
$doc->setAutoPageBreak(true, margin: 25);
$doc->addPage();
foreach ($chapters as $chapter) {
// Reserve space so the chapter heading and its first section start
// together: if less than 40 user units remain, break first.
$remaining = $doc->getPageHeight() - $doc->getMargins()->bottom - $doc->getY();
if ($remaining < 40.0) {
$doc->addPage();
}
// Bookmark at the destination point, before the heading is drawn.
$doc->bookmark($chapter['title'], level: 0);
$doc->setFont('helvetica', 'B', 18);
$doc->cell(0, 12, $chapter['title'], newLine: true);
$doc->ln(3);
foreach ($chapter['sections'] as $section) {
$doc->bookmark($section['title'], level: 1);
$doc->setFont('helvetica', 'B', 13);
$doc->cell(0, 9, $section['title'], newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->multiCell(0, 7, $section['body']);
$doc->ln(5);
}
}
// Render the table of contents only when at least one bookmark exists.
// addTOC() is a no-op when the entry list is empty, so an empty report
// produces no contents page rather than a blank one.
$doc->addTOC(pageIndex: 0, title: 'Table of Contents');
$doc->save($outputPath);
}
/** @var list<array{title: string, sections: list<array{title: string, body: string}>}> $chapters */
$chapters = [
[
'title' => 'Chapter 1: Overview',
'sections' => [
['title' => 'Scope', 'body' => 'Runtime body text for the scope section.'],
['title' => 'Audience', 'body' => 'Runtime body text for the audience section.'],
],
],
[
'title' => 'Chapter 2: Detail',
'sections' => [
['title' => 'Inputs', 'body' => 'Runtime body text for the inputs section.'],
],
],
];
$output = getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/dynamic-toc.pdf';
try {
renderDynamicToc($chapters, $output);
echo "Wrote {$output}\n";
} catch (PageLayoutException $e) {
// A structural limit was hit during generation; surface the page context.
fwrite(STDERR, 'Layout failure while building the report: ' . $e->getMessage() . "\n");
exit(1);
} catch (InvalidConfigException $e) {
// The output path was rejected (stream wrapper, missing directory, or
// a null byte). Report it without leaking the resolved path to a client.
fwrite(STDERR, 'Output path rejected: ' . $e->getMessage() . "\n");
exit(1);
}
  • getPage() devuelve -1 antes de la primera página. Agregar la primera página antes de leer la posición o de llamar a bookmark(). Los ejemplos agregan una página de entrada.
  • Marcar antes del encabezado, no después. bookmark() con $y = -1 registra el getY() actual. Llamarlo justo antes de renderizar el encabezado hace que el destino caiga en el encabezado, no en la línea de debajo.
  • Los saltos de página automáticos mueven el destino. Cuando setAutoPageBreak() está activado, una llamada a cell() o multiCell() puede volcar a una página nueva. Volver a leer getPage() en la siguiente iteración en lugar de almacenarlo en caché. El destino sigue al contenido porque bookmark() lee la posición actual cada vez.
  • Reservar espacio para un encabezado y su primera línea juntos. Un encabezado que cabe al pie de la página mientras su cuerpo se ajusta a la página siguiente se lee mal. El ejemplo de producción calcula la altura restante a partir de getPageHeight(), getMargins()->bottom y getY(), y luego fuerza un addPage() anticipado cuando queda menos de un umbral.
  • addTOC() en un documento vacío no hace nada. Si no se ejecutó ninguna llamada a bookmark(), addTOC() retorna sin insertar una página. Por lo tanto, no es necesario proteger el reporte contra una entrada vacía, aunque conviene saber que la página de contenido no aparecerá.
  • La tabla de contenido se renderiza una sola vez, en la posición donde se inserta. addTOC(pageIndex: 0) inserta el contenido como primera página. Los números de página de las entradas renderizadas reflejan la página registrada de cada entrada, así que conviene insertar el contenido después de que se haya ejecutado cada llamada a bookmark().
  • Los saltos de nivel se ven malformados. Aumentar $level como máximo en uno entre marcadores sucesivos. Saltar del nivel 0 al nivel 2 sin un nivel 1 intermedio produce una jerarquía que algunos lectores renderizan de forma incorrecta.

Cada llamada a bookmark() agrega un elemento al esquema y una entrada a la tabla de contenido en tiempo O(1), y cada lectura de posición (getPage(), getY(), getNumPages()) es un acceso de campo en tiempo constante sobre el contexto de renderizado: sin recorridos. El árbol del esquema y la página de contenido se materializan cada uno una sola vez, en addTOC() y en save() respectivamente. Un reporte con cientos de encabezados se mantiene cómodamente dentro de un presupuesto de 2000 ms / 64 MB. La generación se ejecuta en el proceso: sin navegador headless ni llamadas de red.

Los títulos de los marcadores y la página de contenido renderizan los valores que se pasan a bookmark(). Cuando esos títulos incluyen datos de tiempo de ejecución (el nombre de un capítulo de una fila de base de datos o un campo de una API), acotar la longitud y sanear la cadena antes de que llegue a bookmark(), exactamente igual que con cualquier valor que se muestre en el lector. No construir títulos a partir de una entrada de solicitud sin validar.

El motor valida la ruta de salida que se pasa a save(): rechaza los envoltorios de flujo (scheme://) y los bytes nulos incrustados, y resuelve el directorio padre para bloquear el path traversal, lanzando InvalidConfigException ante cualquiera de estos casos. Mantener esa validación operativa pasando una ruta controlada; nunca entregar a save() un nombre de archivo crudo suministrado por el cliente. Al reportar una InvalidConfigException a un llamador, registrar el detalle en el servidor y devolver un mensaje genérico en lugar de la ruta resuelta.

Esta recipe no formula por sí misma ninguna declaración de conformidad con ISO 32000-2. La semántica del esquema y de la tabla de contenido (el esquema del documento como un árbol de elementos del esquema, y los destinos asociados a esos elementos) se describe en Agregar marcadores y una tabla de contenido, que recoge las citas de cláusula pertinentes. El patrón dinámico descrito aquí cambia únicamente de dónde proviene la posición del destino, no la estructura que se escribe.

Perfil de reproducibilidad: estructural. El /ID del tráiler y los átomos de fecha varían en cada guardado; una comparación estructural los descarta. Esta página documenta cómo NextPDF produce el esquema y el contenido a partir del cursor actual; no formula una declaración general de conformidad con estándares.