
Lorsqu'un rapport généré revient avec des plaintes, celles-ci ne portent presque jamais sur le contenu — elles portent sur la façon de le trouver. Quelques centaines de pages assemblées par un pipeline de reporting, aucune table des matières, et le lecteur doit faire glisser une barre de défilement ou taper des mots dans la zone de recherche du visualiseur pour localiser le chapitre quatre. Le document a une structure ; il ne l'a simplement jamais fait savoir.
Cet article construit cette structure : une table des matières dessinée exactement à la position qu'elle doit occuper, avec des entrées de chapitre, des points de conduite et des numéros de page, et avec chaque entrée cliquable afin que le lecteur arrive sur le chapitre plutôt qu'en haut du fichier. Il utilise Spire.PDF for JavaScript, qui charge, modifie et enregistre des documents dans le navigateur via WebAssembly, en lisant et écrivant dans un système de fichiers virtuel (VFS).
Deux éléments sont traités ici :
Pour l'installation et la configuration du projet, reportez-vous à Intégration de Spire.PDF for JavaScript dans un projet React. Les exemples supposent que le package est installé et que le module WebAssembly a été initialisé.
Pourquoi les PDF générés sont livrés sans table des matières
Deux choses sont appelées table des matières dans un PDF, et il vaut la peine de les distinguer avant d'écrire du code.
La première est une page de table des matières visible — une liste imprimée de chapitres avec numéros de page, située après la couverture. C'est ce que la plupart des lecteurs entendent lorsqu'ils en demandent une, c'est ce qui subsiste lorsque le fichier est imprimé, et c'est ce que cet article construit.
La seconde est le plan que le visualiseur affiche dans une barre latérale, qui est une structure de données distincte à l'intérieur du fichier. Un générateur qui assemble des pages à partir de modèles ne produit généralement ni l'une ni l'autre : il sait comment mettre en page un chapitre mais n'a aucune notion de liste de chapitres, donc la navigation n'est tout simplement pas écrite.
Connaître les numéros de page avant de dessiner la liste est l'autre moitié du travail. Ces numéros proviennent du document lui-même — si vous devez les déduire plutôt que les coder en dur, obtenir le nombre de pages du PDF est le point de départ du calcul.
Insérer la table des matières au bon index
La table des matières doit exister avant que quoi que ce soit puisse y être dessiné, et elle doit atterrir à un emplacement précis : après la couverture, avant le chapitre un. Pages.Insert({ index }) insère une page à un index donné et la renvoie, prête à recevoir des dessins.
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
Il y a une conséquence cachée dans cet appel, et elle cause le bug le plus courant de tout cet exercice : l'insertion décale chaque page suivante d'un rang. Si la couverture était la page 1 et le chapitre un la page 2 avant l'insertion, alors après l'insertion de la table des matières, le chapitre un est la page 3. Tout numéro de page écrit sur la table des matières doit refléter le document tel qu'il se présente maintenant, et non tel qu'il était lorsque la liste des chapitres a été compilée.
Dessiner les entrées, les points de conduite et les numéros de page
Une table des matières est dessinée, pas composée — le titre, chaque entrée, les points de conduite et les numéros de page sont chacun un appel individuel à Canvas.DrawString. Les polices proviennent d'une famille intégrée, le titre est centré avec un PdfStringFormat, et les entrées sont mesurées avec MeasureString afin que leur largeur soit connue avant que quoi que ce soit ne soit placé à leur contact.
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;
Chaque position x est déduite plutôt que codée en dur. L'entrée commence à la marge gauche de 40, le numéro de page arrive à width - 40 - pageSize.Width de façon à s'aligner à droite sur cette même marge, et les points de conduite vont de juste après l'entrée à juste avant le numéro — déplacez la marge et les points suivent.
Le document avec une table des matières : la page après la couverture liste chaque chapitre avec son numéro de page

À ce stade, la page semble terminée, et c'est le piège. Chaque entrée n'est que de l'encre. Cliquer sur l'une d'elles ne fait rien, car une page de texte dessiné n'a aucun élément interactif.
Rendre chaque entrée cliquable
L'interaction dans un PDF provient des annotations, et non du texte lui-même. Pour qu'une ligne de la table des matières réagisse à un clic, elle doit être couverte par une PdfActionAnnotation dont l'action est une PdfGoToAction portant une PdfDestination — la destination nomme la page vers laquelle sauter.
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;
Deux choix y sont délibérés. Border est mis à zéro, car une bordure visible dessine un rectangle autour de chaque entrée — le lecteur doit voir une liste, pas un formulaire encadré. Et la cible est récupérée en tant qu'objet page réel avec doc.Pages.get_Item(...), car une destination a besoin d'une page, pas d'un index.
Cliquer sur un titre de chapitre dans la table des matières saute à cette page

Trouver la zone de clic par recherche de texte
La partie intéressante de cette boucle est la zone de clic. L'approche évidente consiste à la calculer à partir des coordonnées de dessin — l'entrée a été dessinée à y, la ligne a la hauteur de entryFont, donc le rectangle va là. Cela fonctionne pour la première ligne et dérive pour la dernière.
Le y de dessin et le rectangle d'annotation se situent dans des référentiels différents : le texte a été dessiné vers le bas depuis le haut, tandis que la zone de clic doit être exprimée en coordonnées de page. Faire le lien entre eux implique de tenir compte de la hauteur de page et d'une marge supérieure, et toute erreur dans les métriques de police ou l'interligne s'accumule ligne après ligne — jusqu'à ce que le clic atterrisse sur la ligne du dessous.
La solution consiste à arrêter de déduire la position et à aller la chercher. PdfTextFinder recherche un morceau de texte dans une page et renvoie les rectangles qu'il occupe, déjà en coordonnées de page :
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 }));
Rechercher le texte de l'entrée elle-même renvoie l'endroit où ce texte a réellement abouti, donc aucune marge supérieure n'a besoin d'être ajoutée et aucune hypothèse d'interligne n'a besoin d'être vérifiée. Le rectangle est ensuite élargi pour couvrir toute la largeur de la page, afin que toute la ligne soit cliquable et pas seulement les glyphes, et la hauteur du résultat de recherche définit la hauteur de ligne. Si une entrée n'est pas trouvée, Find renvoie une liste vide et la boucle l'ignore — un continue au lieu d'un lien ne pointant nulle part.
C'est aussi pourquoi le dessin et la liaison sont deux passes séparées : la recherche a besoin que les entrées existent sous forme de texte avant de pouvoir les trouver.
Étendre à une table des matières à plusieurs niveaux
Un vrai manuel comporte des chapitres et des sections, pas une simple liste plate. La mécanique ci-dessus s'adapte avec trois ajustements.
| Modification | Ce qu'il faut ajuster |
|---|---|
| Sous-entrées en retrait | Décalez le x de la sous-entrée vers la droite et commencez les points de conduite à partir de la nouvelle fin du texte |
| Sous-entrées plus discrètes | Dessinez-les dans une taille plus petite ou avec un pinceau gris afin que la hiérarchie se lise d'un coup d'œil |
| Espacement plus serré | Réduisez le pas de y pour les lignes de section afin qu'un chapitre et ses sections se lisent comme un groupe |
Deux mises en garde l'accompagnent. Les points de conduite commencent à partir de la largeur mesurée de l'entrée, donc une sous-entrée en retrait doit voir ses points calculés à partir de la marge en retrait, sinon ils chevaucheront le texte. Et la zone de clic est trouvée en recherchant le texte, donc deux entrées identiques se résolvent à la première occurrence — numéroter les sections (2.1, 2.2) permet d'éviter cela.
Si la liste dépasse une page, insérez une seconde page de table des matières et recherchez chaque entrée sur la page où elle a été dessinée — le chercheur est créé par page.
Problèmes courants
Les numéros de page sont tous décalés de un. La table des matières a été insérée dans le document, donc chaque page après elle a été décalée de un. Écrivez les numéros tels qu'ils sont après l'insertion. Si la couverture était la page 1 et le chapitre un la page 2, alors une fois la table des matières placée à l'index 1, le chapitre un est la page 3.
Cliquer sur une entrée saute au mauvais chapitre, ou rien ne se passe. La zone de clic a été déduite des coordonnées de dessin au lieu d'être recherchée. Les décalages mesurés s'accumulent au fil de la page, donc le rectangle dérive par rapport à sa ligne. Recherchez le texte de l'entrée dans la table des matières et utilisez le rectangle renvoyé — il est déjà en coordonnées de page.
Une boîte visible est apparue autour de chaque entrée.
C'est la bordure de l'annotation. Définissez action.Border sur un PdfAnnotationBorder avec borderWidth: 0 afin que la zone de clic reste invisible.
Le premier clic ne fait rien. Le module WebAssembly n'avait pas fini de charger. Protégez-vous avec la référence du module, ou gardez le contrôle désactivé jusqu'à ce qu'il soit prêt.
FAQ
Les numéros de page peuvent-ils être remplis automatiquement ? La liste doit indiquer sur quelle page commence chaque chapitre — la table des matières ne peut pas déduire la structure de votre document par elle-même. Lisez le nombre de pages du document et construisez la liste des entrées à partir de vos propres métadonnées de chapitre, en n'oubliant pas de tenir compte des pages que la table des matières ajoute elle-même.
Les liens survivent-ils à l'impression ? Non, et ils ne sont pas censés le faire. L'impression produit la table des matières visible avec ses numéros de page ; le comportement au clic n'existe que dans le fichier numérique, où un lecteur peut l'utiliser.
Puis-je modifier l'apparence de la table des matières ?
Oui. Le titre et les entrées utilisent PdfFont, donc la famille, la taille et le style sont à votre main, et les couleurs proviennent de PdfBrushes — les points de conduite de l'exemple sont dessinés en gris afin de s'effacer derrière les entrées.
L'entrée cliquable a-t-elle besoin d'un soulignement ou d'une couleur visibles ? Non. La zone de clic est une annotation invisible couvrant la ligne, donc l'entrée ressemble à du texte ordinaire et répond toujours au clic. C'est pourquoi la largeur de bordure est mise à zéro.
Tout cela nécessite-t-il un serveur ? Non. Le document est chargé, modifié et enregistré dans le navigateur via le système de fichiers virtuel, et le fichier résultant est relu pour le téléchargement.
Voir aussi
- Comment obtenir le nombre de pages d'un PDF en JavaScript (React)
- Comment copier des pages dans un PDF en JavaScript (React)
- Réduire la taille d'un fichier PDF : images, polices et flux de contenu en JavaScript (React)
- Intégration de Spire.PDF for JavaScript dans un projet React
- Page produit de Spire.PDF for JavaScript