JavaScript(React)로 PDF에서 클릭 가능한 목차 만들기

2026-10-10 09:24:22 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

생성된 보고서에 대한 불만이 돌아올 때, 그 불만은 거의 내용에 관한 것이 아니라 내용을 찾는 것에 관한 것입니다. 보고 파이프라인이 조립한 수백 페이지 분량의 문서에 목차 페이지가 없으면, 독자는 4장을 찾기 위해 스크롤바를 끌거나 뷰어의 검색 상자에 단어를 입력해야 합니다. 문서에는 구조가 있습니다. 다만 그 구조를 아무에게도 알려주지 않았을 뿐입니다.

이 글에서는 그 구조를 만들어 봅니다. 즉, 마땅히 있어야 할 위치에 정확히 그려진 목차 페이지를 만들고, 장 항목과 리더 점, 페이지 번호를 넣으며, 각 항목을 클릭할 수 있게 하여 독자가 파일 맨 위가 아니라 해당 장으로 바로 이동하도록 합니다. 여기에는 WebAssembly 기반으로 브라우저에서 문서를 로드, 편집, 저장하고 가상 파일 시스템(VFS)을 통해 읽고 쓰는 Spire.PDF for JavaScript를 사용합니다.

여기에서는 두 가지를 다룹니다:

설치 및 프로젝트 구성에 대해서는 React 프로젝트에서 Spire.PDF for JavaScript 통합하기를 참조하세요. 예제는 패키지가 설치되어 있고 WebAssembly 모듈이 초기화되었다고 가정합니다.


생성된 PDF에 목차 페이지가 없는 이유

PDF에서 목차라고 불리는 것은 두 가지이며, 코드를 작성하기 전에 이 둘을 구분해 두는 것이 좋습니다.

첫 번째는 눈에 보이는 목차 페이지입니다. 즉, 표지 뒤에 위치한 페이지 번호가 붙은 장 목록 인쇄본입니다. 대부분의 독자가 목차를 요청할 때 의미하는 것이 바로 이것이며, 파일을 인쇄해도 그대로 남는 것이고, 이 글에서 만드는 것입니다.

두 번째는 뷰어가 사이드바에 표시하는 개요(outline)로, 파일 내부의 별도 데이터 구조입니다. 템플릿으로 페이지를 조립하는 생성기는 보통 둘 다 만들지 않습니다. 장을 배치하는 방법은 알지만 장 목록이라는 개념이 없어서 탐색 정보가 아예 기록되지 않습니다.

목록을 그리기 전에 페이지 번호를 알아내는 것이 나머지 절반입니다. 이 번호는 문서 자체에서 나옵니다. 번호를 하드코딩하지 않고 계산해 내야 한다면, PDF의 페이지 수 구하기가 그 계산의 출발점입니다.


올바른 인덱스에 목차 페이지 삽입하기

목차 페이지는 그 위에 무엇이든 그리기 전에 존재해야 하며, 특정 위치, 즉 표지 뒤, 1장 앞에 들어가야 합니다. 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페이지이고 1장이 2페이지였다면, 목차 페이지를 삽입한 후 1장은 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의 상호작용은 텍스트 자체가 아니라 주석(annotation)에서 나옵니다. 목차 페이지의 한 줄이 클릭에 반응하게 하려면, 그 줄을 PdfActionAnnotation으로 덮어야 하며, 그 동작(action)은 PdfDestination을 담은 PdfGoToAction이어야 합니다. 이 대상(destination)이 이동할 페이지를 지정합니다.

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를 0으로 설정했는데, 테두리가 보이면 각 항목 주위에 사각형이 그려지기 때문입니다. 독자는 상자 형태가 아니라 목록을 봐야 합니다. 또한 대상은 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)를 붙이면 이 문제를 피할 수 있습니다.

목록이 한 페이지를 넘으면 두 번째 목차 페이지를 삽입하고 각 항목이 그려진 페이지에서 검색하세요. 검색기(finder)는 페이지마다 생성합니다.


일반적인 문제

페이지 번호가 모두 하나씩 어긋납니다. 목차 페이지가 문서에 삽입되어 그 뒤의 모든 페이지가 하나씩 밀렸습니다. 삽입 이후의 상태를 기준으로 번호를 적으세요. 표지가 1페이지이고 1장이 2페이지였다면, 목차 페이지가 인덱스 1에 놓이는 순간 1장은 3페이지가 됩니다.

항목을 클릭하면 엉뚱한 장으로 이동하거나 아무 일도 일어나지 않습니다. 클릭 영역을 검색하지 않고 그리기 좌표에서 계산했습니다. 측정 오프셋은 페이지 아래로 갈수록 누적되므로 사각형이 제 행에서 벗어납니다. 목차 페이지에서 항목의 텍스트를 검색하고 반환된 사각형을 사용하세요. 이미 페이지 좌표입니다.

모든 항목 주위에 보이는 상자가 나타났습니다. 그것은 주석 테두리입니다. action.Border를 borderWidth: 0인 PdfAnnotationBorder로 설정하여 클릭 영역이 보이지 않게 하세요.

첫 클릭이 아무 효과가 없습니다. WebAssembly 모듈이 아직 로딩을 마치지 않은 것입니다. 모듈 참조에 대한 가드를 두거나, 준비될 때까지 컨트롤을 비활성화해 두세요.


자주 묻는 질문

페이지 번호를 자동으로 채울 수 있나요? 목록에는 각 장이 어느 페이지에서 시작하는지 알려줘야 합니다. 목차 페이지는 문서 구조를 스스로 추론할 수 없습니다. 문서의 페이지 수를 읽고 자체 장 메타데이터로 항목 목록을 구성하되, 목차 페이지 자체가 추가하는 페이지도 고려해야 합니다.

링크는 인쇄해도 유지되나요? 아니요, 그리고 그럴 필요도 없습니다. 인쇄는 페이지 번호가 있는 눈에 보이는 목차 페이지를 만들 뿐이며, 클릭 동작은 독자가 실제로 사용할 수 있는 디지털 파일에만 존재합니다.

목차 페이지의 모양을 바꿀 수 있나요? 네. 제목과 항목은 PdfFont를 사용하므로 패밀리, 크기, 스타일을 원하는 대로 설정할 수 있고, 색상은 PdfBrushes에서 가져옵니다. 예제의 리더 점은 항목 뒤로 물러나 보이도록 회색으로 그려져 있습니다.

클릭 가능한 항목에 눈에 보이는 밑줄이나 색이 필요한가요? 아니요. 클릭 영역은 행을 덮는 보이지 않는 주석이므로 항목은 평범한 텍스트처럼 보이면서도 클릭에 반응합니다. 그래서 테두리 너비를 0으로 설정합니다.

이 작업에 서버가 필요한가요? 아니요. 문서는 가상 파일 시스템을 통해 브라우저에서 로드, 편집, 저장되며, 결과 파일은 다시 읽어내어 다운로드합니다.


함께 보기