
Quando um relatório gerado volta com reclamações, elas quase nunca são sobre o conteúdo — são sobre encontrá-lo. Algumas centenas de páginas montadas por um pipeline de relatórios, sem página de índice, e o leitor fica arrastando a barra de rolagem ou digitando palavras na caixa de busca do visualizador para localizar o capítulo quatro. O documento tem uma estrutura; ele apenas nunca contou isso a ninguém.
Este artigo constrói essa estrutura: uma página de índice desenhada exatamente na posição que deveria ocupar, com entradas de capítulos, pontos de preenchimento e números de página, e com cada entrada clicável para que o leitor chegue ao capítulo em vez de ao topo do arquivo. Ele usa o Spire.PDF for JavaScript, que carrega, edita e salva documentos no navegador via WebAssembly, lendo e gravando por meio de um sistema de arquivos virtual (VFS).
Dois pontos são abordados aqui:
Para instalação e configuração do projeto, consulte Integrating Spire.PDF for JavaScript in a React Project. Os exemplos pressupõem que o pacote está instalado e que o módulo WebAssembly foi inicializado.
Por que os PDFs gerados são entregues sem uma página de índice
Duas coisas são chamadas de índice em um PDF, e vale a pena separá-las antes de escrever qualquer código.
A primeira é uma página de índice visível — uma lista impressa de capítulos com números de página, situada depois da capa. É isso que a maioria dos leitores quer dizer quando pede um índice, é o que sobrevive quando o arquivo é impresso e é o que este artigo constrói.
A segunda é o sumário (outline) que um visualizador exibe em uma barra lateral, que é uma estrutura de dados separada dentro do arquivo. Um gerador que monta páginas a partir de modelos normalmente não produz nenhum dos dois: ele sabe como diagramar um capítulo, mas não tem o conceito de lista de capítulos, então a navegação simplesmente não é escrita.
Saber os números de página antes de desenhar a lista é a outra metade do trabalho. Esses números vêm do próprio documento — se você precisar derivá-los em vez de codificá-los manualmente, obter a contagem de páginas do PDF é onde a aritmética começa.
Inserindo a página de índice na posição correta
A página de índice precisa existir antes que qualquer coisa possa ser desenhada nela, e precisa cair em um local específico: depois da capa, antes do capítulo um. Pages.Insert({ index }) insere uma página em um determinado índice e a retorna, pronta para receber desenhos.
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
Há uma consequência escondida nessa única chamada, e ela causa o bug mais comum de todo este exercício: a inserção desloca todas as páginas seguintes em uma posição. Se a capa era a página 1 e o capítulo um era a página 2 antes da inserção, depois de inserir a página de índice o capítulo um passa a ser a página 3. Qualquer número de página escrito na página de índice precisa refletir o documento como ele está agora, e não como estava quando a lista de capítulos foi compilada.
Desenhando entradas, pontos de preenchimento e números de página
Uma página de índice é desenhada, não composta tipograficamente — o título, cada entrada, os pontos de preenchimento e os números de página são cada um uma chamada individual de Canvas.DrawString. As fontes vêm de uma família incorporada, o título é centralizado com um PdfStringFormat e as entradas são medidas com MeasureString para que sua largura seja conhecida antes de qualquer coisa ser posicionada em relação a elas.
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 posição x é derivada em vez de codificada manualmente. A entrada começa na margem esquerda de 40, o número de página fica em width - 40 - pageSize.Width para se alinhar à direita com essa mesma margem, e os pontos de preenchimento vão de logo após a entrada até pouco antes do número — mude a margem e os pontos acompanham.
O documento com uma página de índice: a página após a capa lista cada capítulo com seu número de página

Neste ponto a página parece pronta, e essa é a armadilha. Cada entrada é tinta. Clicar em uma não faz nada, porque uma página de texto desenhado não tem nenhum elemento interativo.
Tornando cada entrada clicável
A interação em um PDF vem de anotações, não do texto em si. Para fazer uma linha da página de índice responder a um clique, ela precisa ser coberta por um PdfActionAnnotation cuja ação seja um PdfGoToAction que carregue um PdfDestination — o destino nomeia a página para a qual 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;
Duas escolhas ali são deliberadas. Border é zerado, porque uma borda visível desenha um retângulo ao redor de cada entrada — o leitor deve ver uma lista, não um formulário com caixas. E o destino é obtido como um objeto de página real com doc.Pages.get_Item(...), já que um destino precisa de uma página, não de um índice.
Clicar no título de um capítulo na página de índice salta para essa página

Encontrando a área de clique por meio da busca de texto
A parte interessante desse laço é a área de clique. A abordagem óbvia é calculá-la a partir das coordenadas de desenho — a entrada foi desenhada em y, a linha tem a altura de entryFont, então o retângulo vai ali. Funciona para a primeira linha e vai se desviando até a última.
O y do desenho e o retângulo da anotação estão em referenciais diferentes: o texto foi desenhado de cima para baixo, enquanto a área de clique precisa ser expressa em coordenadas de página. Conciliar os dois significa levar em conta a altura da página e uma margem superior, e qualquer erro nas métricas da fonte ou no espaçamento entre linhas se acumula linha a linha — até o clique cair na linha de baixo.
A solução é parar de derivar a posição e ir consultá-la. O PdfTextFinder busca um trecho de texto em uma página e retorna os retângulos que ele ocupa, já em 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 pelo próprio texto da entrada retorna onde esse texto realmente acabou ficando, então nenhuma margem superior precisa ser adicionada e nenhuma suposição de espaçamento entre linhas precisa se manter. O retângulo é então ampliado para abranger toda a largura da página, de modo que a linha inteira seja clicável em vez de apenas os glifos, e a altura do resultado da busca define a altura da linha. Se uma entrada não for encontrada, Find retorna uma lista vazia e o laço a ignora — um continue em vez de um link apontando para lugar nenhum.
É também por isso que desenhar e vincular são passagens separadas: a busca precisa que as entradas existam como texto antes de poder encontrá-las.
Estendendo para um índice com vários níveis
Um manual de verdade tem capítulos e seções, não uma lista plana. A mecânica acima escala com três ajustes.
| Alteração | O que ajustar |
|---|---|
| Subentradas recuadas | Desloque o x da subentrada para a direita e inicie os pontos de preenchimento a partir do novo fim do texto |
| Subentradas mais discretas | Desenhe-as com um tamanho menor ou um pincel cinza para que a hierarquia seja percebida de imediato |
| Espaçamento mais compacto | Reduza o passo de y nas linhas de seção para que um capítulo e suas seções sejam lidos como um grupo |
Duas advertências vêm junto. Os pontos de preenchimento começam a partir da largura medida da entrada, então uma subentrada recuada precisa ter seus pontos calculados a partir da margem recuada, ou eles vão se sobrepor ao texto. E a área de clique é encontrada buscando pelo texto, então duas entradas com o mesmo texto resultam na primeira ocorrência — numerar as seções (2.1, 2.2) contorna isso.
Se a lista passar de uma página, insira uma segunda página de índice e busque cada entrada na página em que ela foi desenhada — o localizador é criado por página.
Problemas comuns
Os números de página estão todos errados por um. A página de índice foi inserida no documento, então todas as páginas seguintes desceram uma posição. Escreva os números como eles estão depois da inserção. Se a capa era a página 1 e o capítulo um era a página 2, então, depois que a página de índice fica no índice 1, o capítulo um passa a ser a página 3.
Clicar em uma entrada salta para o capítulo errado, ou nada acontece. A área de clique foi derivada das coordenadas de desenho em vez de ser buscada. Deslocamentos medidos se acumulam ao longo da página, então o retângulo se desvia da sua linha. Busque na página de índice pelo texto da entrada e use o retângulo retornado — ele já está em coordenadas de página.
Apareceu uma caixa visível ao redor de cada entrada.
Essa é a borda da anotação. Defina action.Border com um PdfAnnotationBorder com borderWidth: 0 para que a área de clique permaneça invisível.
O primeiro clique não faz nada. O módulo WebAssembly ainda não havia terminado de carregar. Faça uma verificação na referência do módulo, ou mantenha o controle desabilitado até que ele esteja pronto.
Perguntas frequentes
Os números de página podem ser preenchidos automaticamente? A lista precisa ser informada de em qual página cada capítulo começa — a página de índice não consegue inferir a estrutura do seu documento sozinha. Leia a contagem de páginas do documento e monte a lista de entradas a partir dos seus próprios metadados de capítulos, lembrando de levar em conta as páginas que a própria página de índice acrescenta.
Os links sobrevivem à impressão? Não, e não deveriam. A impressão produz a página de índice visível com seus números de página; o comportamento de clique só existe no arquivo digital, onde o leitor pode usá-lo.
Posso mudar a aparência da página de índice?
Sim. O título e as entradas usam PdfFont, então família, tamanho e estilo são você quem define, e as cores vêm de PdfBrushes — os pontos de preenchimento do exemplo são desenhados em cinza para recuarem atrás das entradas.
A entrada clicável precisa de um sublinhado ou cor visível? Não. A área de clique é uma anotação invisível que cobre a linha, então a entrada parece texto comum e ainda assim responde a um clique. É por isso que a largura da borda é zerada.
Algo disso precisa de um servidor? Não. O documento é carregado, editado e salvo no navegador por meio do sistema de arquivos virtual, e o arquivo resultante é lido de volta para download.