
Wenn ein generierter Bericht mit Beschwerden zurückkommt, geht es fast nie um den Inhalt – es geht darum, ihn zu finden. Ein paar hundert Seiten, die von einer Reporting-Pipeline zusammengestellt wurden, kein Inhaltsverzeichnis, und der Leser bleibt zurück und zieht eine Bildlaufleiste oder tippt Wörter in das Suchfeld des Viewers, um Kapitel vier zu finden. Das Dokument hat eine Struktur; es hat nur niemandem davon erzählt.
Dieser Artikel baut diese Struktur auf: eine Inhaltsseite, die genau an der Position gezeichnet wird, die sie einnehmen soll, mit Kapitel-Einträgen, Führungspunkten und Seitenzahlen und mit jedem Eintrag anklickbar, sodass ein Leser beim Kapitel landet statt am Anfang der Datei. Er verwendet Spire.PDF for JavaScript, das Dokumente im Browser auf WebAssembly lädt, bearbeitet und speichert und dabei über ein virtuelles Dateisystem (VFS) liest und schreibt.
Zwei Teile werden hier behandelt:
Zur Installation und Projektkonfiguration siehe Integrieren von Spire.PDF for JavaScript in ein React-Projekt. Die Beispiele gehen davon aus, dass das Paket installiert und das WebAssembly-Modul initialisiert wurde.
Warum generierte PDFs ohne Inhaltsverzeichnis ausgeliefert werden
Zwei Dinge werden in einer PDF als Inhaltsverzeichnis bezeichnet, und es lohnt sich, sie zu unterscheiden, bevor Code geschrieben wird.
Das erste ist eine sichtbare Inhaltsseite – eine gedruckte Liste der Kapitel mit Seitenzahlen, die nach dem Deckblatt steht. Das ist es, was die meisten Leser meinen, wenn sie danach fragen, es ist das, was beim Drucken der Datei erhalten bleibt, und es ist das, was dieser Artikel erstellt.
Das zweite ist die Gliederung, die ein Viewer in einer Seitenleiste anzeigt, eine separate Datenstruktur innerhalb der Datei. Ein Generator, der Seiten aus Vorlagen zusammenstellt, erzeugt normalerweise keines von beiden: Er weiß, wie ein Kapitel zu setzen ist, hat aber kein Konzept einer Kapitelliste, daher wird die Navigation einfach nicht geschrieben.
Die Inhaltsseite am richtigen Index einfügen
Die Inhaltsseite muss existieren, bevor etwas darauf gezeichnet werden kann, und sie muss an einer bestimmten Stelle landen: nach dem Deckblatt, vor Kapitel eins. Pages.Insert({ index }) fügt eine Seite an einem bestimmten Index ein und gibt sie zurück, bereit zum Zeichnen.
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
Eine Konsequenz steckt in diesem einen Aufruf, und sie verursacht den häufigsten Fehler in dieser ganzen Übung: Das Einfügen verschiebt jede Seite danach um eins nach unten. Wenn das Deckblatt vor dem Einfügen Seite 1 und Kapitel eins Seite 2 war, dann ist Kapitel eins nach dem Einfügen der Inhaltsseite Seite 3. Jede Seitenzahl, die auf die Inhaltsseite geschrieben wird, muss das Dokument so widerspiegeln, wie es jetzt ist, nicht so, wie es war, als die Kapitelliste zusammengestellt wurde.
Einträge, Führungspunkte und Seitenzahlen zeichnen
Eine Inhaltsseite wird gezeichnet, nicht gesetzt – der Titel, jeder Eintrag, die Führungspunkte und die Seitenzahlen sind jeweils ein einzelner Canvas.DrawString-Aufruf. Schriftarten stammen aus einer integrierten Familie, der Titel wird mit einem PdfStringFormat zentriert, und Einträge werden mit MeasureString gemessen, damit ihre Breite bekannt ist, bevor etwas daneben platziert wird.
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;
Jede x-Position wird abgeleitet statt fest codiert. Der Eintrag beginnt am linken Rand von 40, die Seitenzahl landet bei width - 40 - pageSize.Width, sodass sie rechtsbündig an demselben Rand ausgerichtet ist, und die Führungspunkte verlaufen von kurz hinter dem Eintrag bis kurz vor die Zahl – verschieben Sie den Rand, und die Punkte folgen.
Das Dokument mit einer Inhaltsseite: Die Seite nach dem Deckblatt listet jedes Kapitel mit seiner Seitenzahl auf

An diesem Punkt sieht die Seite fertig aus, und das ist die Falle. Jeder Eintrag ist Tinte. Ein Klick darauf bewirkt nichts, denn eine Seite mit gezeichnetem Text hat überhaupt keine interaktiven Elemente.
Jeden Eintrag anklickbar machen
Interaktion in einer PDF kommt von Annotationen, nicht vom Text selbst. Damit eine Zeile der Inhaltsseite auf einen Klick reagiert, muss sie von einer PdfActionAnnotation abgedeckt sein, deren Aktion eine PdfGoToAction ist, die ein PdfDestination trägt – das Ziel benennt die Seite, zu der gesprungen werden soll.
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;
Zwei Entscheidungen darin sind absichtlich. Border wird auf null gesetzt, weil ein sichtbarer Rahmen ein Rechteck um jeden Eintrag zeichnet – der Leser sollte eine Liste sehen, kein umrandetes Formular. Und das Ziel wird als echtes Seitenobjekt mit doc.Pages.get_Item(...) abgerufen, da ein Ziel eine Seite braucht, keinen Index.
Ein Klick auf einen Kapiteltitel in der Inhaltsseite springt zu dieser Seite

Die Trefferfläche per Textsuche finden
Der interessante Teil dieser Schleife ist die Trefferfläche. Der naheliegende Ansatz ist, sie aus den Zeichenkoordinaten zu berechnen – der Eintrag wurde bei y gezeichnet, die Zeile ist entryFont hoch, also kommt das Rechteck dorthin. Das funktioniert für die erste Zeile und verschiebt sich bis zur letzten.
Das Zeichnen von y und das Annotationsrechteck liegen in unterschiedlichen Bezugssystemen: Der Text wurde von oben nach unten gezeichnet, während die Trefferfläche in Seitenkoordinaten ausgedrückt werden muss. Sie zu überbrücken bedeutet, Seitenhöhe und einen oberen Rand zu berücksichtigen, und jeder Fehler bei Schriftmetriken oder Zeilenabstand summiert sich Zeile für Zeile – bis der Klick in der darunterliegenden Zeile landet.
Die Lösung besteht darin, die Position nicht mehr abzuleiten und sie stattdessen nachzuschlagen. PdfTextFinder durchsucht eine Seite nach einem Textstück und gibt die Rechtecke zurück, die es einnimmt, bereits in Seitenkoordinaten:
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 }));
Die Suche nach dem Text des Eintrags selbst liefert zurück, wo dieser Text tatsächlich gelandet ist, sodass kein oberer Rand hinzugefügt werden muss und keine Annahme zum Zeilenabstand gelten muss. Das Rechteck wird dann auf die volle Seitenbreite erweitert, sodass die ganze Zeile anklickbar ist und nicht nur die Glyphen, und die Höhe des Suchergebnisses legt die Zeilenhöhe fest. Wenn ein Eintrag nicht gefunden wird, gibt Find eine leere Liste zurück und die Schleife überspringt ihn – ein continue statt eines Links, der nirgendwohin zeigt.
Das ist auch der Grund, warum Zeichnen und Verlinken separate Durchgänge sind: Die Suche benötigt die Einträge als Text, bevor sie sie finden kann.
Erweiterung zu einem mehrstufigen Inhaltsverzeichnis
Ein echtes Handbuch hat Kapitel und Abschnitte, keine einzige flache Liste. Die obige Mechanik lässt sich mit drei Anpassungen skalieren.
| Änderung | Was angepasst werden muss |
|---|---|
| Untereinträge einrücken | Verschieben Sie das x des Untereintrags nach rechts und starten Sie die Führungspunkte am neuen Textende |
| Untereinträge dezenter | Zeichnen Sie sie in einer kleineren Größe oder mit einem grauen Pinsel, damit die Hierarchie auf einen Blick erkennbar ist |
| Engerer Abstand | Reduzieren Sie den y-Schritt für Abschnittszeilen, damit ein Kapitel und seine Abschnitte als Gruppe gelesen werden |
Zwei Warnungen gehen damit einher. Führungspunkte beginnen an der gemessenen Breite des Eintrags, daher müssen bei einem eingerückten Untereintrag die Punkte vom eingerückten Rand aus berechnet werden, sonst überlappen sie den Text. Und die Trefferfläche wird durch Suchen nach dem Text gefunden, daher verweisen zwei Einträge mit demselben Wortlaut auf das erste Vorkommen – das Nummerieren von Abschnitten (2.1, 2.2) umgeht das.
Wenn die Liste eine Seite sprengt, fügen Sie eine zweite Inhaltsseite ein und suchen Sie jeden Eintrag auf der Seite, auf der er gezeichnet wurde – der Finder wird pro Seite erstellt.
Häufige Probleme
Die Seitenzahlen sind alle um eins verschoben. Die Inhaltsseite wurde in das Dokument eingefügt, daher wurde jede Seite danach um eins nach unten verschoben. Schreiben Sie die Zahlen so, wie sie nach dem Einfügen stehen. Wenn das Deckblatt Seite 1 und Kapitel eins Seite 2 war, dann ist Kapitel eins, sobald die Inhaltsseite bei Index 1 liegt, Seite 3.
Ein Klick auf einen Eintrag springt zum falschen Kapitel, oder es passiert nichts. Die Trefferfläche wurde aus den Zeichenkoordinaten abgeleitet, anstatt gesucht zu werden. Gemessene Versätze summieren sich über die Seite, daher driftet das Rechteck von seiner Zeile weg. Durchsuchen Sie die Inhaltsseite nach dem Text des Eintrags und verwenden Sie das zurückgegebene Rechteck – es liegt bereits in Seitenkoordinaten vor.
Um jeden Eintrag ist ein sichtbarer Rahmen erschienen.
Das ist der Annotationsrahmen. Setzen Sie action.Border auf eine PdfAnnotationBorder mit borderWidth: 0, damit die Trefferfläche unsichtbar bleibt.
Der erste Klick bewirkt nichts. Das WebAssembly-Modul hatte noch nicht fertig geladen. Prüfen Sie die Modulreferenz, oder lassen Sie das Steuerelement deaktiviert, bis es bereit ist.
Häufig gestellte Fragen
Können die Seitenzahlen automatisch ausgefüllt werden? Der Liste muss gesagt werden, auf welcher Seite jedes Kapitel beginnt – die Inhaltsseite kann die Struktur Ihres Dokuments nicht von selbst ableiten. Lesen Sie die Seitenanzahl des Dokuments und erstellen Sie die Eintragsliste aus Ihren eigenen Kapitelmetadaten; denken Sie daran, die Seiten zu berücksichtigen, die die Inhaltsseite selbst hinzufügt.
Überleben die Links das Drucken? Nein, und das sollen sie auch nicht. Beim Drucken entsteht die sichtbare Inhaltsseite mit ihren Seitenzahlen; das Klickverhalten existiert nur in der digitalen Datei, wo ein Leser darauf reagieren kann.
Kann ich das Aussehen der Inhaltsseite ändern?
Ja. Titel und Einträge verwenden PdfFont, daher können Sie Familie, Größe und Stil festlegen, und die Farben stammen aus PdfBrushes – die Führungspunkte im Beispiel werden in Grau gezeichnet, damit sie hinter den Einträgen zurücktreten.
Braucht der anklickbare Eintrag eine sichtbare Unterstreichung oder Farbe? Nein. Die Trefferfläche ist eine unsichtbare Annotation, die die Zeile abdeckt, sodass der Eintrag wie normaler Text aussieht und trotzdem auf einen Klick reagiert. Deshalb wird die Rahmenbreite auf null gesetzt.
Braucht irgendetwas davon einen Server? Nein. Das Dokument wird im Browser über das virtuelle Dateisystem geladen, bearbeitet und gespeichert, und die resultierende Datei wird zum Herunterladen wieder ausgelesen.
Siehe auch
- So erhalten Sie die Seitenanzahl einer PDF in JavaScript (React)
- So kopieren Sie Seiten in einer PDF in JavaScript (React)
- Reduzieren der PDF-Dateigröße: Bilder, Schriftarten & Inhaltsströme in JavaScript (React)
- Integrieren von Spire.PDF for JavaScript in ein React-Projekt
- Spire.PDF for JavaScript Produktseite