
Cuando un informe generado vuelve con quejas, casi nunca son sobre el contenido, sino sobre encontrarlo. Unos cientos de páginas ensambladas por una canalización de informes, sin página de contenido, y el lector se queda arrastrando una barra de desplazamiento o escribiendo palabras en el cuadro de búsqueda del visor para localizar el capítulo cuatro. El documento tiene una estructura; simplemente nunca se la comunicó a nadie.
Este artículo construye esa estructura: una página de contenido dibujada exactamente en la posición que debería ocupar, con entradas de capítulos, puntos conductores y números de página, y con cada entrada clicable para que el lector llegue al capítulo en lugar de a la parte superior del archivo. Utiliza Spire.PDF for JavaScript, que carga, edita y guarda documentos en el navegador sobre WebAssembly, leyendo y escribiendo a través de un sistema de archivos virtual (VFS).
Aquí se cubren dos partes:
Para la instalación y la configuración del proyecto, consulte Integrating Spire.PDF for JavaScript in a React Project. Los ejemplos asumen que el paquete está instalado y que el módulo WebAssembly se ha inicializado.
Por qué los PDF generados se entregan sin una página de contenido
Hay dos cosas a las que se llama índice de contenidos en un PDF, y conviene separarlas antes de escribir cualquier código.
La primera es una página de contenido visible: una lista impresa de capítulos con números de página, situada después de la portada. Es lo que la mayoría de los lectores quiere decir cuando pide una, es lo que perdura cuando el archivo se imprime, y es lo que construye este artículo.
La segunda es el esquema que un visor muestra en una barra lateral, que es una estructura de datos independiente dentro del archivo. Un generador que ensambla páginas a partir de plantillas normalmente no produce ninguna de las dos: sabe cómo maquetar un capítulo, pero no tiene ningún concepto de lista de capítulos, así que la navegación simplemente no se escribe.
Conocer los números de página antes de dibujar la lista es la otra mitad del trabajo. Esos números provienen del propio documento: si necesita deducirlos en lugar de codificarlos de forma fija, obtener el número de páginas del PDF es donde empieza la aritmética.
Insertar la página de contenido en el índice correcto
La página de contenido tiene que existir antes de que se pueda dibujar nada en ella, y tiene que colocarse en una posición concreta: después de la portada, antes del capítulo uno. Pages.Insert({ index }) inserta una página en un índice dado y la devuelve, lista para dibujar en ella.
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
Hay una consecuencia oculta en esa única llamada, y provoca el error más común de todo este ejercicio: la inserción desplaza todas las páginas posteriores una posición hacia abajo. Si la portada era la página 1 y el capítulo uno era la página 2 antes de la inserción, después de insertar la página de contenido, el capítulo uno es la página 3. Cualquier número de página escrito en la página de contenido tiene que reflejar el documento tal como está ahora, no como estaba cuando se recopiló la lista de capítulos.
Dibujar entradas, puntos conductores y números de página
Una página de contenido se dibuja, no se compone tipográficamente: el título, cada entrada, los puntos conductores y los números de página son cada uno una llamada individual a Canvas.DrawString. Las fuentes provienen de una familia integrada, el título se centra con un PdfStringFormat, y las entradas se miden con MeasureString para conocer su ancho antes de colocar algo junto a ellas.
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;
Cada posición x se deriva en lugar de codificarse de forma fija. La entrada comienza en el margen izquierdo de 40, el número de página se coloca en width - 40 - pageSize.Width para que se alinee a la derecha con ese mismo margen, y los puntos conductores van desde justo después de la entrada hasta justo antes del número: mueva el margen y los puntos lo seguirán.
El documento con una página de contenido: la página después de la portada enumera cada capítulo con su número de página

En este punto la página parece terminada, y esa es la trampa. Cada entrada es tinta. Hacer clic en una no hace nada, porque una página de texto dibujado no tiene ningún elemento interactivo.
Hacer que cada entrada sea clicable
La interacción en un PDF proviene de las anotaciones, no del propio texto. Para que una línea de la página de contenido responda a un clic, tiene que estar cubierta por una PdfActionAnnotation cuya acción sea una PdfGoToAction que lleve un PdfDestination: el destino nombra la página a la que saltar.
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;
Dos decisiones ahí son deliberadas. Border se pone a cero, porque un borde visible dibuja un rectángulo alrededor de cada entrada: el lector debería ver una lista, no un formulario encuadrado. Y el destino se obtiene como un objeto de página real con doc.Pages.get_Item(...), ya que un destino necesita una página, no un índice.
Al hacer clic en el título de un capítulo en la página de contenido se salta a esa página

Encontrar el área de clic mediante la búsqueda de texto
La parte interesante de ese bucle es el área de clic. El enfoque obvio es calcularla a partir de las coordenadas de dibujo: la entrada se dibujó en y, la línea tiene la altura de entryFont, así que el rectángulo va ahí. Funciona para la primera fila y se desvía en la última.
La y de dibujo y el rectángulo de la anotación están en marcos de referencia distintos: el texto se dibujó hacia abajo desde la parte superior, mientras que el área de clic debe expresarse en coordenadas de página. Salvarlos significa tener en cuenta la altura de la página y un margen superior, y cualquier error en las métricas de la fuente o en el interlineado se acumula fila a fila, hasta que el clic cae en la fila de abajo.
La solución es dejar de deducir la posición y buscarla. PdfTextFinder busca un fragmento de texto en una página y devuelve los rectángulos que ocupa, ya en coordenadas de página:
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 }));
Buscar el propio texto de la entrada devuelve dónde acabó realmente ese texto, así que no hay que añadir ningún margen superior ni debe cumplirse ninguna suposición sobre el interlineado. Luego el rectángulo se ensancha para abarcar todo el ancho de la página, de modo que toda la fila sea clicable y no solo los glifos, y la altura del resultado de la búsqueda establece la altura de la fila. Si no se encuentra una entrada, Find devuelve una lista vacía y el bucle la omite: un continue en lugar de un enlace que no apunta a ningún sitio.
Esto también es la razón de que dibujar y enlazar sean pasadas separadas: la búsqueda necesita que las entradas existan como texto antes de poder encontrarlas.
Ampliar a un contenido de varios niveles
Un manual real tiene capítulos y secciones, no una única lista plana. La mecánica anterior escala con tres ajustes.
| Cambio | Qué ajustar |
|---|---|
| Subentradas con sangría | Desplace la x de la subentrada hacia la derecha y comience los puntos conductores desde el nuevo final del texto |
| Subentradas más discretas | Dibújelas en un tamaño menor o con un pincel gris para que la jerarquía se lea de un vistazo |
| Espaciado más ajustado | Reduzca el paso de y para las filas de sección de modo que un capítulo y sus secciones se lean como un grupo |
Con ello vienen dos advertencias. Los puntos conductores comienzan desde el ancho medido de la entrada, así que una subentrada con sangría necesita que sus puntos se calculen desde el margen con sangría, o se superpondrán al texto. Y el área de clic se encuentra buscando el texto, así que dos entradas que digan lo mismo se resuelven en la primera aparición: numerar las secciones (2.1, 2.2) evita eso.
Si la lista supera una página, inserte una segunda página de contenido y busque cada entrada en la página en la que se dibujó: el buscador se crea por página.
Problemas comunes
Los números de página están todos desfasados en uno. La página de contenido se insertó en el documento, así que todas las páginas posteriores bajaron una posición. Escriba los números tal como están después de la inserción. Si la portada era la página 1 y el capítulo uno era la página 2, una vez que la página de contenido ocupa el índice 1, el capítulo uno es la página 3.
Al hacer clic en una entrada se salta al capítulo equivocado, o no ocurre nada. El área de clic se dedujo de las coordenadas de dibujo en lugar de buscarla. Los desplazamientos medidos se acumulan a lo largo de la página, así que el rectángulo se desvía de su fila. Busque en la página de contenido el texto de la entrada y use el rectángulo devuelto: ya está en coordenadas de página.
Apareció un cuadro visible alrededor de cada entrada.
Ese es el borde de la anotación. Establezca action.Border en un PdfAnnotationBorder con borderWidth: 0 para que el área de clic permanezca invisible.
El primer clic no hace nada. El módulo WebAssembly aún no había terminado de cargarse. Proteja la referencia al módulo o mantenga el control deshabilitado hasta que esté listo.
Preguntas frecuentes
¿Se pueden rellenar los números de página automáticamente? Hay que indicar a la lista en qué página comienza cada capítulo: la página de contenido no puede inferir por sí sola la estructura de su documento. Lea el número de páginas del documento y construya la lista de entradas a partir de sus propios metadatos de capítulos, recordando tener en cuenta las páginas que añade la propia página de contenido.
¿Los enlaces sobreviven a la impresión? No, y no se supone que lo hagan. La impresión produce la página de contenido visible con sus números de página; el comportamiento de clic solo existe en el archivo digital, donde un lector puede usarlo.
¿Puedo cambiar el aspecto de la página de contenido?
Sí. El título y las entradas usan PdfFont, así que la familia, el tamaño y el estilo son suyos para configurarlos, y los colores provienen de PdfBrushes: los puntos conductores del ejemplo se dibujan en gris para que queden en segundo plano respecto a las entradas.
¿La entrada clicable necesita un subrayado o un color visibles? No. El área de clic es una anotación invisible que cubre la fila, así que la entrada parece texto normal y aun así responde al clic. Por eso el ancho del borde se pone a cero.
¿Algo de esto necesita un servidor? No. El documento se carga, se edita y se guarda en el navegador a través del sistema de archivos virtual, y el archivo resultante se lee de vuelta para descargarlo.