Generar una tabla de contenido dinámica a partir de la estructura del documento en tiempo de ejecución
De un vistazo
Sección titulada «De un vistazo»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.
Instalación
Sección titulada «Instalación»composer require nextpdf/core:^3No 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.
Panorama conceptual
Sección titulada «Panorama conceptual»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()ygetMargins()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.
Superficie de la API
Sección titulada «Superficie de la API»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 (-1antes 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.
Ejemplo de código — Inicio rápido
Sección titulada «Ejemplo de código — Inicio rápido»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 0Bookmarked 'Method' on page index 0Bookmarked 'Results' on page index 0Ejemplo de código — Producción
Sección titulada «Ejemplo de código — Producción»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);}Casos límite y trampas
Sección titulada «Casos límite y trampas»getPage()devuelve-1antes de la primera página. Agregar la primera página antes de leer la posición o de llamar abookmark(). Los ejemplos agregan una página de entrada.- Marcar antes del encabezado, no después.
bookmark()con$y = -1registra elgetY()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 acell()omultiCell()puede volcar a una página nueva. Volver a leergetPage()en la siguiente iteración en lugar de almacenarlo en caché. El destino sigue al contenido porquebookmark()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()->bottomygetY(), y luego fuerza unaddPage()anticipado cuando queda menos de un umbral. addTOC()en un documento vacío no hace nada. Si no se ejecutó ninguna llamada abookmark(),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 abookmark(). - Los saltos de nivel se ven malformados. Aumentar
$levelcomo 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.
Rendimiento
Sección titulada «Rendimiento»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.
Notas de seguridad
Sección titulada «Notas de seguridad»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.
Conformidad
Sección titulada «Conformidad»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.
Véase también
Sección titulada «Véase también»- Agregar marcadores y una tabla de contenido: la contraparte estática de esta recipe
- Módulo de navegación
- Concern HasPages: la superficie de página y posición
- Construir un documento de varias páginas
- Encabezados y pies de página