Crie um Sumário Clicável em um PDF com JavaScript (React)

2026-10-10 09:24:24 Allen Yang
AI Summarize:
ChatGPT
ChatGPT ✓
Claude ✓
Grok ✓
Perplexity ✓
Quick
Quick
Concise overview
Highlights
Key takeaways
Detailed
Structured explanation
Brief
One sentence summary
Summarize |

Desenhando uma página de índice e vinculando suas entradas no navegador com o Spire.PDF for JavaScript

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

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

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.


Veja também