Salta ai contenuti

Generare a runtime un indice dalla struttura del documento

Il contenuto prende forma a runtime: capitoli caricati da un database, sezioni costruite dalla risposta di un’interfaccia di programmazione delle applicazioni (API), titoli prodotti da un ciclo che non si controlla in anticipo. Occorre che la struttura del documento e un indice cliccabile corrispondano esattamente a quel contenuto, senza mantenere un secondo elenco scritto a mano destinato a perdere la sincronizzazione.

Questa ricetta costruisce la struttura dinamicamente. Mentre si scrive ciascun titolo, si leggono dal motore la pagina corrente, la posizione del cursore e il numero di pagine - getPage(), getY() e getNumPages() - quindi si usano questi valori con bookmark(). Il segnalibro viene associato alla posizione letta in quell’istante; di conseguenza la struttura segue il contenuto anche quando le interruzioni di pagina cadono in punti inattesi. Alla fine, addTOC() renderizza una vera pagina di indice a partire dalle stesse voci.

Prerequisiti: un’installazione di Core (composer require nextpdf/core:^3) e contenuto la cui struttura dei titoli emerga durante la scrittura, non prima.

Questa pagina tratta il modello dinamico basato sulla posizione. Per il caso statico, in cui ogni titolo e il relativo livello sono noti in anticipo, leggere prima Aggiungere segnalibri e un indice. Questa ricetta usa la stessa interfaccia bookmark() e addTOC() e non la ripete.

Terminal window
composer require nextpdf/core:^3

Non è richiesta alcuna estensione opzionale. L’interfaccia di navigazione (bookmark(), addTOC()) e i metodi di accesso alla posizione (getPage(), getY(), getNumPages()) sono stabili dalla versione 1.2.0 e funzionano in tutta la matrice di backport dalla 8.1 alla 8.4.

Un indice dinamico comprende due parti che devono rimanere coerenti:

  • La struttura (chiamata anche segnalibri): l’albero che il lettore vede nella barra laterale di navigazione, dove ogni voce porta a una posizione nel documento.
  • L’indice renderizzato: una pagina generata che elenca le stesse voci con i relativi numeri di pagina.

NextPDF mantiene entrambi sincronizzati tramite un’unica chiamata. bookmark($title, $level, $y) aggiunge una voce di struttura e una voce di indice, entrambe associate alla pagina corrente e alla posizione verticale corrente. Non occorre mai mantenere due elenchi.

La parte dinamica sta nella provenienza della posizione. Una ricetta statica passa titoli letterali nell’ordine di origine. Qui, invece, si scrive un titolo e si chiede subito al motore dove è arrivato il cursore:

  • getPage() restituisce l’indice a base zero della pagina attiva. Prima dell’aggiunta della prima pagina restituisce -1.
  • getNumPages() restituisce il numero totale di pagine, inclusa la pagina attiva che non è ancora stata scaricata.
  • getY() restituisce la posizione verticale corrente del cursore in unità utente, misurata come distanza dal margine superiore della pagina.
  • getX(), getPageHeight() e getMargins() completano il quadro quando occorre decidere se un titolo e la sua prima riga di testo del corpo stanno insieme.

Dopo aver letto questi valori, si chiama bookmark(). L’interruzione automatica di pagina può spostare il cursore su una nuova pagina tra due titoli; quindi rileggere la posizione - anziché presumerla - mantiene la destinazione della struttura sulla pagina corretta.

Un’unica regola di ordinamento guida l’intero modello: chiamare bookmark() nel punto esatto in cui si desidera la destinazione, ovvero immediatamente prima di eseguire il rendering del testo del titolo. Se si scrive prima il titolo e si crea il segnalibro dopo, il valore getY() registrato si colloca direttamente sotto il titolo.

I metodi su cui si basa questa ricetta, tutti su \NextPDF\Core\Document:

  • bookmark(string $title, int $level = 0, float $y = -1): static - aggiunge una voce di struttura e una voce di indice al livello $level, associate alla pagina corrente. Con $y = -1 la destinazione usa la Y corrente del cursore; passare una Y non negativa per fissare una destinazione precisa.
  • addTOC(int $pageIndex = 0, string $title = ''): static - renderizza una pagina di indice a partire dalle voci accumulate e la inserisce in corrispondenza di $pageIndex. Restituisce senza inserire una pagina quando non esiste alcun segnalibro.
  • getPage(): int - indice a base zero della pagina attiva (-1 prima della prima pagina).
  • getNumPages(): int - numero totale di pagine, inclusa la pagina attiva non ancora scaricata.
  • getY(): float - Y corrente del cursore in unità utente (distanza dal margine superiore della pagina).
  • getX(): float - X corrente del cursore in unità utente.
  • getPageHeight(): float - altezza della pagina corrente in unità utente.
  • getMargins(): \NextPDF\ValueObjects\Margin - i margini attivi (top, right, bottom, left).
  • setY(float $y): static - sposta il cursore a una Y esplicita.
  • setAutoPageBreak(bool $enabled, float $margin = 20): static - controlla l’interruzione automatica di pagina e la relativa soglia del margine inferiore.

Questo esempio scrive tre sezioni da un elenco a runtime. A ogni iterazione rilegge la pagina corrente con getPage() prima di creare il segnalibro, così la destinazione della struttura rimane corretta anche dopo un’interruzione automatica di pagina.

<?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');

Output atteso nel terminale, una riga per sezione:

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

Questa versione genera una struttura a due livelli (capitoli e sezioni) a partire da una struttura annidata disponibile a runtime, mantiene un titolo insieme alla prima riga del corpo leggendo la posizione prima di scrivere e racchiude la generazione in un blocco try/catch per gestire le eccezioni NextPDF più specifiche. PageLayoutException copre un errore lato generazione, ad esempio il superamento del limite massimo di pagine. save() genera InvalidConfigException per un percorso di output non scrivibile o non sicuro.

<?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() restituisce -1 prima della prima pagina. Aggiungere la prima pagina prima di leggere la posizione o di chiamare bookmark(). Gli esempi aggiungono una pagina all’inizio.
  • Creare il segnalibro prima del titolo, non dopo. bookmark() con $y = -1 registra il valore getY() corrente. Chiamarlo immediatamente prima di eseguire il rendering del titolo, così la destinazione cade sul titolo e non sulla riga sottostante.
  • Le interruzioni automatiche di pagina spostano la destinazione. Quando setAutoPageBreak() è attivo, una chiamata a cell() o multiCell() può spostare il contenuto su una nuova pagina. Rileggere getPage() nell’iterazione successiva anziché memorizzarlo in cache. La destinazione segue il contenuto perché bookmark() legge ogni volta la posizione corrente.
  • Riservare spazio per un titolo e la sua prima riga insieme. Un titolo che rimane in fondo alla pagina mentre il corpo prosegue nella pagina successiva risulta poco leggibile. L’esempio di produzione calcola l’altezza rimanente da getPageHeight(), getMargins()->bottom e getY(), quindi forza un addPage() anticipato quando rimane meno di una soglia.
  • addTOC() su un documento vuoto non fa nulla. Se non è stata effettuata alcuna chiamata a bookmark(), addTOC() restituisce senza inserire una pagina. Non è quindi necessario proteggere il report da input vuoto, anche se è utile sapere che la pagina dell’indice non comparirà.
  • L’indice viene renderizzato una sola volta, nella posizione in cui lo si inserisce. addTOC(pageIndex: 0) inserisce l’indice come prima pagina. I numeri di pagina nelle voci renderizzate riflettono la pagina registrata di ciascuna voce, quindi inserire l’indice dopo aver eseguito tutte le chiamate a bookmark().
  • I salti di livello risultano malformati. Aumentare $level al massimo di uno tra segnalibri successivi. Passare dal livello 0 al livello 2 senza un livello 1 intermedio produce una gerarchia che alcuni lettori visualizzano in modo errato.

Ogni chiamata a bookmark() aggiunge una voce di struttura e una voce di indice in tempo O(1), e ogni lettura di posizione (getPage(), getY(), getNumPages()) è un accesso a un campo in tempo costante nel contesto di rendering, senza attraversamenti. L’albero della struttura e la pagina dell’indice vengono materializzati una sola volta, rispettivamente in addTOC() e in save(). Un report con centinaia di titoli resta ampiamente entro un budget di 2000 ms / 64 MB. La generazione avviene nel processo: nessun browser headless e nessuna chiamata di rete.

I titoli dei segnalibri e la pagina dell’indice renderizzano i valori passati a bookmark(). Quando questi titoli trasportano dati a runtime - il nome di un capitolo recuperato da una riga di database o un campo di un’API - limitarne la lunghezza e sanitizzare la stringa prima che raggiunga bookmark(), esattamente come si farebbe per qualsiasi valore visualizzato nel reader. Non costruire titoli a partire da input di richiesta non convalidato.

Il motore convalida il percorso di output passato a save(): rifiuta gli stream wrapper (scheme://) e i byte null incorporati, e risolve la directory padre per bloccare il path traversal, generando InvalidConfigException in tutti questi casi. Mantenere attiva questa convalida passando un percorso sotto il proprio controllo; non passare mai a save() un nome di file grezzo fornito dal client. Quando si segnala un’eccezione InvalidConfigException a un chiamante, registrare il dettaglio lato server e restituire un messaggio generico anziché il percorso risolto.

Questa ricetta non rivendica alcuna dichiarazione di conformità ISO 32000-2 propria. La semantica della struttura e dell’indice - la struttura del documento come albero di voci di struttura e le destinazioni associate a tali voci - è descritta in Aggiungere segnalibri e un indice, che riporta le citazioni delle clausole pertinenti. Il modello dinamico qui descritto modifica solo da dove proviene la posizione di destinazione, non la struttura che viene scritta.

Profilo di riproducibilità - strutturale. Il /ID del trailer e gli atomi di data variano a ogni salvataggio; un confronto strutturale li rimuove. Questa pagina documenta come NextPDF produce la struttura e l’indice a partire dalla posizione corrente del cursore; non rivendica una dichiarazione generale di conformità agli standard.