几百页的手册、报告发出去之后,最常被抱怨的不是内容,而是找不到想看的章节。读者想在开头就拿到一份按页排好的章节清单,点一下直接翻过去;而很多 PDF 在生成时就没有目录,翻页只能靠滚动条或者页内搜索。
本文介绍用 Spire.PDF for JavaScript 创建目录页、并为目录条目添加跳转。它基于 WebAssembly 在浏览器端直接加载、修改与保存 PDF 文档,全过程在本地完成,通过虚拟文件系统(VFS)读写文件,无需后端配合。
本文介绍两个核心功能点:
有关安装和项目配置,请参考 React 项目中集成 Spire.PDF for JavaScript。以下示例默认已安装 Spire.PDF 并完成 WebAssembly 模块初始化。
生成目录页
目录页要落在文档里某个位置,用 Pages.Insert({ index }) 插入一页并拿到它,标题、章节条目、前导点和页码都用这页的 Canvas.DrawString 绘制。条目的横向位置按文字宽度推进,前导点从标题右端逐个补到页码左端。
function App() {
const createTocPage = async () => {
// 获取 Spire.PDF WASM 模块
const pdfModule = window.wasmModule?.spirepdf;
// 检查模块是否就绪
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// 将中文字体与待处理的 PDF 文件载入 VFS
await window.spire.FetchFileToVFS('SIMSUN.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = '章节文档.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
// 创建 PdfDocument 对象并加载 PDF 文档
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// 在封面之后插入目录页,原来的正文各页整体后移一位
const tocPage = doc.Pages.Insert({ index: 1 });
// 标题与条目使用的字体
const titleFont = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/SIMSUN.TTF', size: 20, style: pdfModule.PdfFontStyle.Bold });
const entryFont = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/SIMSUN.TTF', size: 14 });
const centerFormat = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center });
// 绘制居中的目录标题
const title = '目录';
tocPage.Canvas.DrawString({
s: title,
font: titleFont,
brush: pdfModule.PdfBrushes.get_Black(),
point: new pdfModule.PointF(tocPage.Canvas.ClientSize.Width / 2, 50),
format: centerFormat
});
// 章节标题与插入目录页后的显示页码
const chapters = [
{ title: '第一章 系统概述', page: 3 },
{ title: '第二章 功能架构', page: 4 },
{ title: '第三章 部署要求', page: 5 },
{ title: '第四章 维护与支持', page: 6 }
];
const width = tocPage.Canvas.ClientSize.Width;
let y = 110;
for (const chapter of chapters) {
// 条目文字
const titleSize = entryFont.MeasureString({ text: chapter.title });
tocPage.Canvas.DrawString({ s: chapter.title, font: entryFont, brush: pdfModule.PdfBrushes.get_Black(), x: 40, y: y });
// 右对齐的页码
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 });
// 前导点:从条目右端补齐到页码左端
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;
}
// 定义输出文件名并保存
const outputFileName = '带目录的文档.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// 从 VFS 读取生成的文件,触发下载
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>生成目录页</h1>
<button id="btn-1" onClick={createTocPage}>
生成目录
</button>
</div>
);
}
export default App;
带目录页的文档,封面之后的第二页列出了各章节标题与页码:

为目录条目添加跳转
目录页画好之后,条目还只是一行文字。要让条目可点,得给每条覆盖一块 PdfActionAnnotation 命中区,用 PdfGoToAction 携带 PdfDestination 指定跳转页实现点击目录条目进行页面跳转。命中区的位置不用按行距去推算——用 PdfTextFinder 在目录页上按条目文字搜索,拿到的矩形就是这行字在页面上的真实位置,直接就能当命中区用。
function App() {
const addTocNavigation = async () => {
// 获取 Spire.PDF WASM 模块
const pdfModule = window.wasmModule?.spirepdf;
// 检查模块是否就绪
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// 载入上一步生成的带目录文档
const inputFileName = '带目录的文档.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// 目录页是文档的第 2 页(索引 1)
const tocPage = doc.Pages.get_Item(1);
// 条目文字与各自要跳转的页
const chapters = [
{ title: '第一章 系统概述', page: 3 },
{ title: '第二章 功能架构', page: 4 },
{ title: '第三章 部署要求', page: 5 },
{ title: '第四章 维护与支持', page: 6 }
];
// 在目录页上按关键字搜索
const finder = new pdfModule.PdfTextFinder(tocPage);
for (const chapter of chapters) {
const found = finder.Find(chapter.title);
if (found.length === 0) {
continue;
}
// 根据关键字位置定义命中区域
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 destination = new pdfModule.PdfDestination({
page: targetPage,
location: new pdfModule.PointF(0, 0)
});
// 添加上跳转动作,并把边框宽度设为 0
const action = new pdfModule.PdfActionAnnotation(bounds, new pdfModule.PdfGoToAction({ destination }));
action.Border = new pdfModule.PdfAnnotationBorder({ borderWidth: 0 });
tocPage.Annotations.Add(action);
}
// 定义输出文件名并保存
const outputFileName = '可点击的目录.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// 从 VFS 读取生成的文件,触发下载
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>为目录条目添加跳转</h1>
<button id="btn-2" onClick={addTocNavigation}>
添加跳转
</button>
</div>
);
}
export default App;
点击目录里的章节标题即可跳到对应页面:

常见问题
目录页码与实际页面对不上
原因:目录页是插进原文档的,插入点之后的页面整体后移一位。页码列如果仍沿用插入前的页序,就会整体差一页。
解决:页码按插入后的显示位置写。例如封面原本是第 1 页、第一章原本是第 2 页,在封面之后插入目录页后,第一章落在第 3 页,目录里就该写 3。
点击目录条目跳到了别的章节,或者没有反应
原因:条目是用 Canvas.DrawString 画上去的,命中区却要按页面坐标给。如果靠绘制时的 y 加行距去推算,字体度量、行距、页边距里任何一项和实际排版对不上,误差就会一行行累积,点到的是别的条目,甚至点空。
解决:别推算,直接在目录页上按条目文字(关键字)搜索,拿命中的矩形当命中区。PdfTextFinder 返回的坐标本来就是页面坐标,不用再补页边距;page 用 doc.Pages.get_Item(...) 取文档里真实的页对象:
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 }));
获取免费许可证
如果您希望删除结果文档中的评估消息,或者摆脱功能限制,请该Email地址已收到反垃圾邮件插件保护。要显示它您需要在浏览器中启用JavaScript。获取有效期 30 天的临时许可证。









