
Quando un report generato torna indietro con delle lamentele, queste non riguardano quasi mai il contenuto — riguardano la sua reperibilità. Qualche centinaio di pagine assemblate da una pipeline di reporting, nessuna pagina di indice, e il lettore è costretto a trascinare una barra di scorrimento o a digitare parole nella casella di ricerca del visualizzatore per individuare il capitolo quattro. Il documento ha una struttura; semplicemente non l'ha mai comunicata a nessuno.
Questo articolo costruisce proprio quella struttura: una pagina di indice disegnata esattamente nella posizione che dovrebbe occupare, con le voci dei capitoli, i puntini di riempimento e i numeri di pagina, e con ogni voce cliccabile in modo che il lettore arrivi al capitolo invece che all'inizio del file. Utilizza Spire.PDF for JavaScript, che carica, modifica e salva documenti nel browser su WebAssembly, leggendo e scrivendo tramite un file system virtuale (VFS).
Qui vengono trattati due aspetti:
Per l'installazione e la configurazione del progetto, fare riferimento a Integrare Spire.PDF for JavaScript in un progetto React. Gli esempi presuppongono che il pacchetto sia installato e che il modulo WebAssembly sia stato inizializzato.
Perché i PDF generati vengono distribuiti senza una pagina di indice
Due cose vengono chiamate indice in un PDF, e vale la pena separarle prima di scrivere qualsiasi codice.
La prima è una pagina di indice visibile — un elenco stampato di capitoli con i numeri di pagina, posto dopo la copertina. È ciò che la maggior parte dei lettori intende quando ne chiede una, è ciò che sopravvive quando il file viene stampato, ed è ciò che questo articolo costruisce.
La seconda è la struttura di navigazione che un visualizzatore mostra in una barra laterale, che è una struttura dati separata all'interno del file. Un generatore che assembla pagine da modelli in genere non produce nessuna delle due: sa come impaginare un capitolo ma non ha alcun concetto di elenco dei capitoli, quindi la navigazione semplicemente non viene scritta.
Conoscere i numeri di pagina prima di disegnare l'elenco è l'altra metà del lavoro. Quei numeri provengono dal documento stesso — se hai bisogno di ricavarli invece di codificarli a mano, ottenere il numero di pagine del PDF è il punto da cui parte l'aritmetica.
Inserire la pagina di indice all'indice corretto
La pagina di indice deve esistere prima che vi si possa disegnare qualcosa, e deve finire in uno slot specifico: dopo la copertina, prima del capitolo uno. Pages.Insert({ index }) inserisce una pagina a un dato indice e la restituisce, pronta per essere disegnata.
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
C'è una conseguenza nascosta in quella singola chiamata, e causa il bug più comune in tutto questo esercizio: l'inserimento sposta ogni pagina successiva di uno verso il basso. Se la copertina era la pagina 1 e il capitolo uno era la pagina 2 prima dell'inserimento, dopo aver inserito la pagina di indice il capitolo uno è la pagina 3. Qualsiasi numero di pagina scritto sulla pagina di indice deve riflettere il documento così come si presenta ora, non come era quando è stato compilato l'elenco dei capitoli.
Disegnare le voci, i puntini di riempimento e i numeri di pagina
Una pagina di indice viene disegnata, non composta tipograficamente — il titolo, ogni voce, i puntini di riempimento e i numeri di pagina sono ciascuno una singola chiamata Canvas.DrawString. I font provengono da una famiglia integrata, il titolo è centrato con un PdfStringFormat, e le voci vengono misurate con MeasureString in modo che la loro larghezza sia nota prima di posizionare qualsiasi cosa rispetto ad esse.
function App() {
const createTocPage = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check whether the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load the PDF to process into the VFS
const inputFileName = 'Chapter_Document.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
// Create a PdfDocument object and load the PDF
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
// Fonts for the title and the entries, using the built-in Helvetica (no font file to load)
const titleFont = new pdfModule.PdfFont({ fontFamily: pdfModule.PdfFontFamily.Helvetica, size: 20, style: pdfModule.PdfFontStyle.Bold });
const entryFont = new pdfModule.PdfFont({ fontFamily: pdfModule.PdfFontFamily.Helvetica, size: 14 });
const centerFormat = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center });
// Draw the centered contents title
const title = 'Contents';
tocPage.Canvas.DrawString({
s: title,
font: titleFont,
brush: pdfModule.PdfBrushes.get_Black(),
point: new pdfModule.PointF(tocPage.Canvas.ClientSize.Width / 2, 50),
format: centerFormat
});
// Chapter titles and their page numbers after the contents page is inserted
const chapters = [
{ title: 'Chapter 1 Overview', page: 3 },
{ title: 'Chapter 2 Architecture', page: 4 },
{ title: 'Chapter 3 Deployment', page: 5 },
{ title: 'Chapter 4 Maintenance', page: 6 }
];
const width = tocPage.Canvas.ClientSize.Width;
let y = 110;
for (const chapter of chapters) {
// Entry text
const titleSize = entryFont.MeasureString({ text: chapter.title });
tocPage.Canvas.DrawString({ s: chapter.title, font: entryFont, brush: pdfModule.PdfBrushes.get_Black(), x: 40, y: y });
// Right-aligned page number
const pageText = chapter.page.toString();
const pageSize = entryFont.MeasureString({ text: pageText });
tocPage.Canvas.DrawString({ s: pageText, font: entryFont, brush: pdfModule.PdfBrushes.get_Black(), x: width - 40 - pageSize.Width, y: y });
// Leader dots: fill from the end of the entry to the start of the page number
const dotStart = 40 + titleSize.Width + 6;
const dotEnd = width - 40 - pageSize.Width - 6;
for (let x = dotStart; x < dotEnd; x += 6) {
tocPage.Canvas.DrawString({ s: '.', font: entryFont, brush: pdfModule.PdfBrushes.get_Gray(), x: x, y: y });
}
y += 24;
}
// Define the output file name and save
const outputFileName = 'Document-with-TOC.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Create a Table of Contents Page</h1>
<button id="btn-1" onClick={createTocPage}>
Create TOC
</button>
</div>
);
}
export default App;
Ogni posizione x è derivata anziché codificata a mano. La voce inizia dal margine sinistro di 40, il numero di pagina finisce a width - 40 - pageSize.Width in modo da allinearsi a destra su quello stesso margine, e i puntini di riempimento vanno da poco dopo la voce a poco prima del numero — sposta il margine e i puntini lo seguono.
Il documento con una pagina di indice: la pagina dopo la copertina elenca ogni capitolo con il suo numero di pagina

A questo punto la pagina sembra finita, e questa è la trappola. Ogni voce è inchiostro. Cliccarne una non fa nulla, perché una pagina di testo disegnato non ha alcun elemento interattivo.
Rendere cliccabile ogni voce
L'interazione in un PDF deriva dalle annotazioni, non dal testo stesso. Per rendere una riga della pagina di indice sensibile a un clic, deve essere coperta da una PdfActionAnnotation la cui azione è una PdfGoToAction che porta una PdfDestination — la destinazione indica la pagina a cui saltare.
function App() {
const addTocNavigation = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check whether the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load the document generated in the previous step
const inputFileName = 'Document-with-TOC.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// The contents page is page 2 of the document (index 1)
const tocPage = doc.Pages.get_Item(1);
// The entry text and the page each one should jump to
const chapters = [
{ title: 'Chapter 1 Overview', page: 3 },
{ title: 'Chapter 2 Architecture', page: 4 },
{ title: 'Chapter 3 Deployment', page: 5 },
{ title: 'Chapter 4 Maintenance', page: 6 }
];
// Search the contents page by keyword
const finder = new pdfModule.PdfTextFinder(tocPage);
for (const chapter of chapters) {
const found = finder.Find(chapter.title);
if (found.length === 0) {
continue;
}
// Define the hit area based on the keyword position
const lineBounds = found.get(0).Bounds[0];
const bounds = new pdfModule.RectangleF({
location: new pdfModule.PointF(0, lineBounds.Y),
size: new pdfModule.SizeF({ width: tocPage.Canvas.ClientSize.Width, height: lineBounds.Height })
});
// The jump target is the chapter's page, aligned to the top-left corner of the body
const targetPage = doc.Pages.get_Item(chapter.page - 1);
const destination = new pdfModule.PdfDestination({
page: targetPage,
location: new pdfModule.PointF(0, 0)
});
// Attach the jump action and set the border width to 0
const action = new pdfModule.PdfActionAnnotation(bounds, new pdfModule.PdfGoToAction({ destination }));
action.Border = new pdfModule.PdfAnnotationBorder({ borderWidth: 0 });
tocPage.Annotations.Add(action);
}
// Define the output file name and save
const outputFileName = 'Clickable-TOC.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Add Navigation to Table of Contents Entries</h1>
<button id="btn-2" onClick={addTocNavigation}>
Add Navigation
</button>
</div>
);
}
export default App;
Due scelte al suo interno sono deliberate. Border è azzerato, perché un bordo visibile disegna un rettangolo attorno a ogni voce — il lettore dovrebbe vedere un elenco, non un modulo incorniciato. E la destinazione viene recuperata come un vero oggetto pagina con doc.Pages.get_Item(...), poiché una destinazione richiede una pagina, non un indice.
Cliccando il titolo di un capitolo nella pagina di indice si salta a quella pagina

Trovare l'area di clic tramite la ricerca testuale
La parte interessante di quel ciclo è l'area di clic. L'approccio ovvio è calcolarla dalle coordinate di disegno — la voce è stata disegnata a y, la riga è alta quanto entryFont, quindi il rettangolo va lì. Funziona per la prima riga e va fuori registro per l'ultima.
Il y di disegno e il rettangolo dell'annotazione si trovano in sistemi di riferimento diversi: il testo è stato disegnato verso il basso dall'alto, mentre l'area di clic deve essere espressa in coordinate di pagina. Collegarli significa tenere conto dell'altezza della pagina e di un margine superiore, e qualsiasi errore nelle metriche dei font o nell'interlinea si accumula riga dopo riga — finché il clic non finisce sulla riga sottostante.
La soluzione è smettere di derivare la posizione e andarla a cercare. PdfTextFinder cerca un frammento di testo in una pagina e restituisce i rettangoli che occupa, già in coordinate di pagina:
const finder = new pdfModule.PdfTextFinder(tocPage);
const found = finder.Find(chapter.title);
const lineBounds = found.get(0).Bounds[0];
const bounds = new pdfModule.RectangleF({
location: new pdfModule.PointF(0, lineBounds.Y),
size: new pdfModule.SizeF({ width: tocPage.Canvas.ClientSize.Width, height: lineBounds.Height })
});
const targetPage = doc.Pages.get_Item(chapter.page - 1);
const action = new pdfModule.PdfActionAnnotation(bounds, new pdfModule.PdfGoToAction({ destination }));
Cercare il testo stesso della voce restituisce dove quel testo è effettivamente finito, quindi non deve essere aggiunto alcun margine superiore e non deve valere alcuna assunzione sull'interlinea. Il rettangolo viene poi ampliato per coprire l'intera larghezza della pagina, in modo che l'intera riga sia cliccabile e non solo i glifi, e l'altezza del risultato della ricerca determina l'altezza della riga. Se una voce non viene trovata, Find restituisce un elenco vuoto e il ciclo la salta — un continue invece di un link che non punta a nulla.
Questo è anche il motivo per cui disegno e collegamento sono passaggi separati: la ricerca ha bisogno che le voci esistano come testo prima di poterle trovare.
Estendere a un indice multilivello
Un vero manuale ha capitoli e sezioni, non un unico elenco piatto. La meccanica di cui sopra si estende con tre accorgimenti.
| Modifica | Cosa regolare |
|---|---|
| Sottovoci rientrate | Sposta la x della sottovoce verso destra, e fai partire i puntini di riempimento dalla nuova fine del testo |
| Sottovoci più discrete | Disegnale con una dimensione più piccola o un pennello grigio, così la gerarchia si legge a colpo d'occhio |
| Spaziatura più stretta | Riduci il passo di y per le righe delle sezioni, così un capitolo e le sue sezioni si leggono come un gruppo |
Ciò comporta due avvertenze. I puntini di riempimento partono dalla larghezza misurata della voce, quindi una sottovoce rientrata necessita che i suoi puntini siano calcolati dal margine rientrato, altrimenti si sovrapporranno al testo. E l'area di clic viene trovata cercando il testo, quindi due voci che riportano la stessa cosa si risolvono alla prima occorrenza — numerare le sezioni (2.1, 2.2) aggira il problema.
Se l'elenco supera una pagina, inserisci una seconda pagina di indice e cerca ogni voce sulla pagina in cui è stata disegnata — il finder viene creato per pagina.
Problemi comuni
I numeri di pagina sono tutti sfalsati di uno. La pagina di indice è stata inserita nel documento, quindi ogni pagina successiva si è spostata di uno verso il basso. Scrivi i numeri come sono dopo l'inserimento. Se la copertina era la pagina 1 e il capitolo uno era la pagina 2, una volta che la pagina di indice si trova all'indice 1, il capitolo uno è la pagina 3.
Cliccando una voce si salta al capitolo sbagliato, o non succede nulla. L'area di clic è stata derivata dalle coordinate di disegno invece di essere cercata. Gli scostamenti misurati si accumulano scendendo lungo la pagina, quindi il rettangolo va fuori registro rispetto alla sua riga. Cerca il testo della voce nella pagina di indice e usa il rettangolo restituito — è già in coordinate di pagina.
È comparso un riquadro visibile attorno a ogni voce.
Quello è il bordo dell'annotazione. Imposta action.Border su una PdfAnnotationBorder con borderWidth: 0 in modo che l'area di clic resti invisibile.
Il primo clic non fa nulla. Il modulo WebAssembly non aveva finito di caricarsi. Proteggi con un controllo sul riferimento del modulo, oppure mantieni il controllo disabilitato finché non è pronto.
FAQ
I numeri di pagina possono essere inseriti automaticamente? Bisogna indicare all'elenco su quale pagina inizia ogni capitolo — la pagina di indice non può dedurre da sola la struttura del tuo documento. Leggi il numero di pagine del documento e costruisci l'elenco delle voci dai tuoi metadati dei capitoli, ricordando di tenere conto delle pagine che la pagina di indice stessa aggiunge.
I link sopravvivono alla stampa? No, e non è previsto che lo facciano. La stampa produce la pagina di indice visibile con i suoi numeri di pagina; il comportamento al clic esiste solo nel file digitale, dove un lettore può agire su di esso.
Posso cambiare l'aspetto della pagina di indice?
Sì. Il titolo e le voci usano PdfFont, quindi famiglia, dimensione e stile sono a tua disposizione, e i colori provengono da PdfBrushes — i puntini di riempimento nell'esempio sono disegnati in grigio in modo che si attenuino dietro le voci.
La voce cliccabile ha bisogno di una sottolineatura o di un colore visibile? No. L'area di clic è un'annotazione invisibile che copre la riga, quindi la voce appare come testo normale e risponde comunque al clic. Ecco perché la larghezza del bordo è azzerata.
Qualcuno di questi aspetti richiede un server? No. Il documento viene caricato, modificato e salvato nel browser attraverso il file system virtuale, e il file risultante viene riletto per il download.
Vedi anche
- Come ottenere il numero di pagine di un PDF in JavaScript (React)
- Come copiare pagine in un PDF in JavaScript (React)
- Ridurre le dimensioni di un file PDF: immagini, font e stream di contenuto in JavaScript (React)
- Integrare Spire.PDF for JavaScript in un progetto React
- Pagina del prodotto Spire.PDF for JavaScript