Категория

Создание кликабельного оглавления в PDF с помощью JavaScript (React)

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

Drawing a contents page and linking its entries in the browser with Spire.PDF for JavaScript

Когда по сгенерированному отчёту возвращаются с жалобами, они почти никогда не касаются содержания — они касаются его поиска. Несколько сотен страниц, собранных конвейером отчётности, без страницы содержания, и читатель вынужден тащить полосу прокрутки или вводить слова в поле поиска программы просмотра, чтобы найти четвёртую главу. В документе есть структура; просто о ней никто не сообщил.

В этой статье создаётся такая структура: страница содержания, отрисованная точно в той позиции, которую она должна занимать, с записями глав, отточиями и номерами страниц, причём каждая запись кликабельна, чтобы читатель попадал сразу в главу, а не в начало файла. Используется Spire.PDF для JavaScript, который загружает, редактирует и сохраняет документы в браузере на WebAssembly, читая и записывая через виртуальную файловую систему (VFS).

Здесь рассматриваются две части:

По вопросам установки и настройки проекта обратитесь к статье Интеграция Spire.PDF для JavaScript в проект React. Примеры предполагают, что пакет установлен, а модуль WebAssembly инициализирован.


Почему сгенерированные PDF-файлы выходят без страницы содержания

В PDF таблицей содержания называют две разные вещи, и их стоит разделить до написания любого кода.

Первая — это видимая страница содержания — напечатанный список глав с номерами страниц, расположенный после обложки. Именно её обычно имеют в виду читатели, запрашивая содержание; именно она сохраняется при печати файла, и именно её создаёт эта статья.

Вторая — это оглавление, которое программа просмотра показывает на боковой панели; это отдельная структура данных внутри файла. Генератор, собирающий страницы из шаблонов, обычно не создаёт ни того, ни другого: он умеет компоновать главу, но не имеет понятия о списке глав, поэтому навигация просто не записывается.

Знать номера страниц до отрисовки списка — вторая половина задачи. Эти номера берутся из самого документа — если их нужно вычислять, а не задавать жёстко, получение количества страниц PDF — это то, с чего начинаются расчёты.


Вставка страницы содержания по правильному индексу

Страница содержания должна существовать до того, как на неё можно будет что-то нарисовать, и она должна попасть в определённое место: после обложки, перед первой главой. Pages.Insert({ index }) вставляет страницу по заданному индексу и возвращает её, готовую для отрисовки.

// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });

В этом одном вызове скрыто следствие, и оно вызывает самую распространённую ошибку во всём этом упражнении: вставка сдвигает каждую страницу после неё на одну вниз. Если до вставки обложка была страницей 1, а первая глава — страницей 2, то после вставки страницы содержания первая глава становится страницей 3. Любой номер страницы, записанный на странице содержания, должен отражать документ в его текущем состоянии, а не таким, каким он был при составлении списка глав.


Отрисовка записей, отточий и номеров страниц

Страница содержания именно отрисовывается, а не верстается — заголовок, каждая запись, отточия и номера страниц — это отдельные вызовы Canvas.DrawString. Шрифты берутся из встроенного семейства, заголовок центрируется с помощью PdfStringFormat, а записи измеряются через MeasureString, чтобы их ширина была известна до того, как что-либо будет размещено рядом с ними.

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;

Каждая x-позиция вычисляется, а не задаётся жёстко. Запись начинается с левого поля 40, номер страницы попадает в width - 40 - pageSize.Width, выравниваясь по правому краю к тому же полю, а отточия идут от конца записи до начала номера — сдвиньте поле, и отточия последуют за ним.

Документ со страницей содержания: страница после обложки перечисляет каждую главу с её номером страницы

The document with a contents page: the page after the cover lists each chapter with its page number

На этом этапе страница выглядит законченной, и в этом ловушка. Каждая запись — это просто краска. Клик по ней ничего не делает, потому что страница с нарисованным текстом вообще не имеет интерактивных элементов.


Делаем каждую запись кликабельной

Интерактивность в PDF обеспечивается аннотациями, а не самим текстом. Чтобы строка страницы содержания реагировала на клик, её нужно накрыть PdfActionAnnotation, действие которого — PdfGoToAction, несущее PdfDestination — назначение указывает страницу для перехода.

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;

Два решения здесь намеренные. Border обнуляется, потому что видимая граница рисует прямоугольник вокруг каждой записи — читатель должен видеть список, а не форму в рамках. И цель извлекается как настоящий объект страницы через doc.Pages.get_Item(...), поскольку назначению нужна страница, а не индекс.

Клик по названию главы на странице содержания приводит к переходу на эту страницу

Clicking a chapter title in the contents page jumps to that page


Поиск области нажатия с помощью текстового поиска

Самое интересное в этом цикле — область нажатия. Очевидный подход — вычислить её из координат отрисовки: запись была нарисована на y, строка имеет высоту entryFont, значит, прямоугольник должен быть там. Это работает для первой строки и смещается к последней.

Отрисовочный y и прямоугольник аннотации находятся в разных системах координат: текст рисовался сверху вниз, а область нажатия должна быть выражена в координатах страницы. Чтобы их связать, нужно учитывать высоту страницы и верхнее поле, и любая ошибка в метриках шрифта или межстрочном интервале накапливается строка за строкой — пока клик не попадёт на строку ниже.

Решение — перестать вычислять позицию и вместо этого найти её. PdfTextFinder ищет на странице фрагмент текста и возвращает прямоугольники, которые он занимает, уже в координатах страницы:

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 }));

Поиск по тексту самой записи возвращает то место, где этот текст действительно оказался, поэтому не нужно добавлять верхнее поле и не нужно полагаться на предположение о межстрочном интервале. Затем прямоугольник расширяется на всю ширину страницы, чтобы кликабельной была вся строка, а не только глифы, и высота результата поиска задаёт высоту строки. Если запись не найдена, Find возвращает пустой список, и цикл её пропускает — continue вместо ссылки, ведущей в никуда.

Именно поэтому отрисовка и связывание — отдельные проходы: поиску нужно, чтобы записи уже существовали как текст, прежде чем он сможет их найти.


Расширение до многоуровневого содержания

В настоящем руководстве есть главы и разделы, а не один плоский список. Описанная механика масштабируется тремя изменениями.

Изменение Что настроить
Подзаписи с отступом Сдвиньте x подзаписи вправо и начинайте отточия от нового конца текста
Подзаписи менее заметны Рисуйте их меньшим размером или серой кистью, чтобы иерархия читалась с первого взгляда
Более плотный интервал Уменьшите шаг y для строк разделов, чтобы глава и её разделы читались как группа

С этим связаны две оговорки. Отточия начинаются от измеренной ширины записи, поэтому для подзаписи с отступом их нужно вычислять от отступа, иначе они перекроют текст. А область нажатия находится поиском по тексту, поэтому две записи с одинаковым текстом разрешаются в первое вхождение — нумерация разделов (2.1, 2.2) позволяет этого избежать.

Если список перерастает одну страницу, вставьте вторую страницу содержания и ищите каждую запись на той странице, где она была нарисована — объект поиска создаётся для каждой страницы.


Частые проблемы

Все номера страниц смещены на единицу. Страница содержания была вставлена в документ, поэтому каждая страница после неё сдвинулась на одну вниз. Записывайте номера такими, какими они являются после вставки. Если обложка была страницей 1, а первая глава — страницей 2, то когда страница содержания занимает индекс 1, первая глава становится страницей 3.

Клик по записи переходит не к той главе или ничего не происходит. Область нажатия была вычислена из координат отрисовки вместо поиска. Измеренные смещения накапливаются по странице, поэтому прямоугольник смещается со своей строки. Найдите на странице содержания текст записи и используйте возвращённый прямоугольник — он уже в координатах страницы.

Вокруг каждой записи появилась видимая рамка. Это граница аннотации. Установите для action.Border значение PdfAnnotationBorder с borderWidth: 0, чтобы область нажатия оставалась невидимой.

Первый клик ничего не делает. Модуль WebAssembly ещё не завершил загрузку. Проверяйте ссылку на модуль или держите элемент управления отключённым, пока он не будет готов.


Часто задаваемые вопросы

Можно ли заполнять номера страниц автоматически? Списку нужно сообщить, с какой страницы начинается каждая глава, — страница содержания не может сама вывести структуру вашего документа. Прочитайте количество страниц документа и постройте список записей из собственных метаданных глав, не забывая учитывать страницы, которые добавляет сама страница содержания.

Сохраняются ли ссылки при печати? Нет, и не должны. Печать создаёт видимую страницу содержания с номерами страниц; поведение при клике существует только в цифровом файле, где читатель может им воспользоваться.

Можно ли изменить внешний вид страницы содержания? Да. Заголовок и записи используют PdfFont, поэтому семейство, размер и стиль задаёте вы, а цвета берутся из PdfBrushes — отточия в примере нарисованы серым, чтобы они отступали на задний план за записями.

Нужно ли кликабельной записи видимое подчёркивание или цвет? Нет. Область нажатия — это невидимая аннотация, покрывающая строку, поэтому запись выглядит как обычный текст и всё равно реагирует на клик. Именно поэтому ширина границы обнуляется.

Требует ли что-либо из этого сервера? Нет. Документ загружается, редактируется и сохраняется в браузере через виртуальную файловую систему, а полученный файл считывается обратно для скачивания.


См. также