
JavaScript 可以通过编程方式生成 PDF 文件——创建文档、添加页面、绘制文本和其他元素,然后保存文档。在 React 应用中,借助 Spire.PDF for JavaScript 和 WebAssembly,这一流程完全在浏览器中完成,无需后端服务器。
从整体来看,JavaScript PDF 生成包含五个步骤:创建文档、添加页面、绘制内容、保存 PDF、下载生成的文件。本文以 React 作为示例环境,但 PDF 生成流程本身基于 JavaScript,适用于任何前端框架。
需要注意的是,本文所说的"生成 PDF"是指通过代码从零开始构建 PDF 文档,而非将现有 HTML 页面打印为 PDF。JavaScript 应用可以通过 HTML 转 PDF、基于 Canvas 的渲染或编程式 PDF 构建等方式生成 PDF。本文聚焦于使用 Spire.PDF for JavaScript 进行编程式 PDF 生成。
准备工作
本文假设您已在 React 项目中安装并初始化了 Spire.PDF for JavaScript。有关配置详情,请参阅如何在 React 项目中集成 Spire.PDF for JavaScript。您还需要配置 WebAssembly 资源,使 PDF 模块可通过 window.spirepdf 访问(Spire.Office for JavaScript 11.7.0 或更高版本)。
内置标准字体不含中文字形,因此本文示例额外使用一份中文字体文件:把等线 Deng.ttf 与粗体 Dengb.ttf(或任何 .ttf)放进项目的 public/fonts/ 目录,页面在运行时读取它。
如何在 JavaScript 中生成 PDF
创建 PdfDocument -> 添加页面 -> 绘制内容 -> 保存 -> 下载
1. 创建 PDF 文档
首先创建一个 PdfDocument 对象,它是即将构建的 PDF 文件的内存表示。
const pdf = window.spirepdf;
let doc = new pdf.PdfDocument();
2. 添加页面
PDF 文档至少需要一个页面。调用 Pages.Add() 创建一个默认大小的空白页面。
let page = doc.Pages.Add();
3. 添加文本和其他内容
页面有一个 Canvas 属性,提供各种绘制方法。使用 DrawString 添加文本,DrawImage 添加图像,DrawLine 绘制线条,DrawRectangle 绘制填充或轮廓矩形。坐标以磅为单位(1 磅 = 1/72 英寸),从左上角开始计算。
PdfFontFamily 中的 Helvetica 等标准字体只有拉丁字形,绘制中文时要改用 PdfTrueTypeFont 引用一份中文字体文件:
// 把中文字体写进 WebAssembly 虚拟文件系统(VFS),再用路径引用它
const FS = window.dotnetRuntime.Module.FS;
FS.writeFile('/Deng.ttf', new Uint8Array(await (await fetch('/fonts/Deng.ttf')).arrayBuffer()));
let font = new pdf.PdfTrueTypeFont('/Deng.ttf', 12);
let brush = new pdf.PdfSolidBrush({ pdfRGBColor: new pdf.PdfRGBColor(0, 0, 0) });
page.Canvas.DrawString({ s: '你好,世界!', font: font, brush: brush, x: 50, y: 50 });
4. 保存生成的 PDF
内容放置完毕后,调用 SaveToFile 将 PDF 写入 WebAssembly 虚拟文件系统(VFS)。VFS 是一个浏览器内的文件系统,Spire.PDF 通过它管理输入和输出文件,无需访问真实磁盘或服务器。
doc.SaveToFile({ fileName: 'Output.pdf' });
doc.Close();
5. 在浏览器中下载 PDF
保存到 VFS 后,将文件读回为字节数组,包装为 Blob 对象,然后通过创建临时锚点元素触发下载。这一模式在所有 Spire.PDF for JavaScript 示例中通用。
const fileArray = window.dotnetRuntime.Module.FS.readFile('Output.pdf');
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'Output.pdf';
a.click();
URL.revokeObjectURL(url);
当您需要精确控制文档结构、页面布局、文本、图形或元数据时,编程式 PDF 生成非常有用。如果您已有网页或 HTML 模板,只需将该布局转换为 PDF,则 JavaScript HTML 转 PDF 方案 可能更合适。
下一节将通过一个完整示例,将上述步骤组合起来,从 JavaScript 数据生成 PDF。
从 JavaScript 数据生成发票 PDF
实际应用中,结构化文档——发票、报表、报价单——是从应用数据生成的,而非硬编码字符串。在 React 应用中,invoice 对象可能来自表单输入、组件状态或 API 响应;PDF 生成代码只需要文档布局所需的数据。本示例将发票定义为 JavaScript 对象,并从中渲染出可打印的 PDF:包含出血式页眉、购买方信息块、明细表格、金额汇总和付款详情。它将 DrawString、DrawRectangle、DrawLine 和 PdfGrid 组合为一个完整的可运行 React 组件。
function App() {
const generateInvoicePdf = async () => {
// spire.office 11.7.0 将引擎挂载到 window.spirepdf
const pdf = window.spirepdf;
if (!pdf) return alert('Spire.PDF 尚未就绪');
// 中文字形需要真实字体:把字体文件写进 WebAssembly 虚拟文件系统(VFS),
// 再用 PdfTrueTypeFont 按路径引用。把任意 .ttf 放进 public/fonts/ 即可替换。
const FS = window.dotnetRuntime.Module.FS;
const loadFont = async (name) => {
const bytes = new Uint8Array(await (await fetch(`/fonts/${name}`)).arrayBuffer());
FS.writeFile(`/${name}`, bytes);
return `/${name}`;
};
const regular = await loadFont('Deng.ttf');
const boldFace = await loadFont('Dengb.ttf');
// ── 发票数据。替换此对象,布局随之变化。 ────
const invoice = {
number: 'INV-2026-0148', issued: '2026年9月23日',
due: '2026年10月23日', po: 'PO-SZZN-88231', terms: '账期 30 天',
seller: ['云启软件科技(上海)有限公司', '面向制造企业的一体化云平台',
'上海市浦东新区世纪大道 1200 号 28 层'],
// [名称, 联系人, 税号, 地址, 城市] — 联系人行绘制在地址下方
buyer: ['苏州精智制造股份有限公司', '联系人:陈晓雯(采购部)',
'纳税人识别号 91320594MA1WQ9XT4K', '江苏省苏州市工业园区星湖街 328 号 5 号楼',
'苏州工业园区 215021 · 中国'],
// [描述, 数量, 单价] — 金额在下方计算
items: [
['CloudDesk Pro 云桌面年度订阅(25 席位)', 25, 860],
['实施与上线服务(远程,16 小时)', 16, 680],
['优先技术支持(高级版,12 个月)', 1, 17200],
['对象存储扩容包(500 GB / 年)', 1, 3400],
],
discountRate: 0.05, taxRate: 0.06,
bank: ['招商银行上海浦东分行 · 账号 1219 0688 7710 901',
'开户名 云启软件科技(上海)有限公司 · SWIFT CMBCCNBS'],
remark: ['请在汇款附言中注明发票号码。',
'逾期未付款项将按每月 1.5% 计收利息。'],
legal:
'云启软件科技(上海)有限公司 · 上海市浦东新区世纪大道 1200 号 28 层 · 税号 91310115MA1K3QX89T',
contact: 'billing@ yunqi-software.cn · 021-6888 1200 · yunqi-software.cn',
};
const round = (n) => Math.round(n * 100) / 100;
const subtotal = round(invoice.items.reduce((sum, [, qty, unit]) => sum + qty * unit, 0));
const discount = -round(subtotal * invoice.discountRate);
const tax = round((subtotal + discount) * invoice.taxRate);
const total = round(subtotal + discount + tax);
const money = (n) =>
'¥' + Math.abs(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const pct = (rate) => Math.round(rate * 100) + '%';
// ── 文档 ──────────────────────────────────────────────────────────
const doc = new pdf.PdfDocument();
doc.PageSettings.Margins.All = 0; // 必须在 Pages.Add() 之前设置,否则原点停留在边距处
const page = doc.Pages.Add();
// 超出客户区域的文本会被丢弃,因此基于 ClientSize 布局
const W = page.Canvas.ClientSize.Width;
const H = page.Canvas.ClientSize.Height;
const PAD = 28;
const EDGE = W - PAD;
// ── 绘制工具集 ───────────────────────────────────────────────────
// 对象式写法选择 PdfSolidBrush / PdfPen 的重载 ——
// 位置式构造函数在此处均报 "Ambiguous call"
const rgb = (c) => new pdf.PdfRGBColor(c[0], c[1], c[2]);
const paint = (c) => new pdf.PdfSolidBrush({ pdfRGBColor: rgb(c) });
// PdfTrueTypeFont 可以按路径重载,加粗用另一份粗体字体文件
const font = (size, bold) => new pdf.PdfTrueTypeFont(bold ? boldFace : regular, size);
// DrawString 的第一个参数是 `s`,按点绘制时没有对齐选项 ——
// 需要右对齐时先测量字符串宽度
const text = (s, f, colour, x, y, align) => {
const dx = align === 'right' ? f.MeasureString({ text: s }).Width : 0;
page.Canvas.DrawString({ s, font: f, brush: paint(colour), x: x - dx, y });
};
const box = (colour, x, y, w, h) =>
page.Canvas.DrawRectangle({ brush: paint(colour), x, y, width: w, height: h });
const INK = [26, 31, 43], NAVY = [23, 54, 93], ACCENT = [47, 111, 181];
const MUTED = [107, 114, 128], RULE = [220, 225, 232], ZEBRA = [246, 248, 251];
const SOFT = [238, 243, 249], WHITE = [255, 255, 255], ON_NAVY = [186, 200, 220];
const fHero = font(24, true), fBrand = font(15, true), fTitle = font(11, true);
const fBody = font(9), fBold = font(9, true), fSmall = font(8);
const fLabel = font(7.5, true), fFoot = font(7.5), fCell = font(8.5), fHead = font(8, true);
// ── 页眉色带,出血到纸张边缘 ──────────────────────────
box(NAVY, 0, 0, W, 106);
box(ACCENT, 0, 106, W, 3.5);
text(invoice.seller[0], fBrand, WHITE, PAD, 30);
text(invoice.seller[1], fSmall, ON_NAVY, PAD, 52);
text(invoice.seller[2], fFoot, ON_NAVY, PAD, 68);
text('服务费发票', fHero, WHITE, EDGE, 28, 'right');
text(invoice.number, fBody, ON_NAVY, EDGE, 58, 'right');
text(`开票日期 ${invoice.issued}`, fFoot, ON_NAVY, EDGE, 76, 'right');
// ── 购买方 / 发票详情 ───────────────────────────────────────
const top = 158;
text('购买方', fLabel, MUTED, PAD, top);
text(invoice.buyer[0], fBold, INK, PAD, top + 17);
invoice.buyer.slice(2).forEach((line, i) => text(line, fBody, MUTED, PAD, top + 35 + i * 14));
text(invoice.buyer[1], fSmall, MUTED, PAD, top + 83);
const details = [
['发票号码', invoice.number], ['开票日期', invoice.issued], ['付款期限', invoice.due],
['采购订单', invoice.po], ['付款条件', invoice.terms],
];
text('发票详情', fLabel, MUTED, EDGE, top, 'right');
details.forEach(([key, value], i) => {
text(key, fSmall, MUTED, EDGE - 128, top + 20 + i * 16);
text(value, fBold, INK, EDGE, top + 20 + i * 16, 'right');
});
const tableY = top + 106;
page.Canvas.DrawLine({
pen: new pdf.PdfPen({ pdfRGBColor: rgb(RULE), width: 0.75 }),
x1: PAD, y1: tableY, x2: EDGE, y2: tableY,
});
// ── 明细行 ────────────────────────────────────────────────────────
const grid = new pdf.PdfGrid();
grid.Columns.Add(4);
[255, 44, 112, 128].forEach((w, i) => (grid.Columns.get_Item(i).Width = w));
const alignRight = new pdf.PdfStringFormat({ alignment: pdf.PdfTextAlignment.Right });
[1, 2, 3].forEach((i) => (grid.Columns.get_Item(i).Format = alignRight));
// 单元格内边距从 row.Height 中扣除 —— 需留出一行高度
// 否则所有单元格渲染为空白,且无任何错误提示
const padding = new pdf.PdfPaddings();
padding.Left = padding.Right = 8;
padding.Top = padding.Bottom = 2;
grid.Style.CellPadding = padding;
grid.Style.Font = fCell;
const hairline = new pdf.PdfBorders();
hairline.All = new pdf.PdfPen({ pdfRGBColor: rgb(RULE), width: 0.5 });
try { grid.Headers.Add(1); } catch {}
const head = grid.Headers.get_Item(0);
head.Height = 26;
head.Style.BackgroundBrush = paint(NAVY);
head.Style.TextBrush = paint(WHITE);
head.Style.Font = fHead;
['服务内容', '数量', '单价', '金额'].forEach((label, i) => {
const cell = head.Cells.get_Item(i);
cell.Value = new pdf.String(label); // .Value 是 .NET 对象 —— 需用 pdf.String 装箱
if (i) cell.StringFormat = alignRight;
cell.Style.Borders = hairline;
});
invoice.items.forEach(([description, qty, unit], r) => {
const row = grid.Rows.Add();
row.Height = 26;
if (r % 2) row.Style.BackgroundBrush = paint(ZEBRA);
[description, String(qty), money(unit), money(qty * unit)].forEach((value, i) => {
const cell = row.Cells.get_Item(i);
cell.Value = new pdf.String(value);
cell.Style.Borders = hairline;
});
});
const layout = new pdf.PdfGridLayoutFormat();
layout.Layout = pdf.PdfLayoutType.Paginate;
// 参数名是 `format`,不是 `layout`
const tableBottom = grid.Draw({ page, x: PAD, y: tableY + 26, format: layout }).Bounds.Bottom;
// ── 金额汇总 ────────────────────────────────────────────────────
let y = tableBottom + 22;
const summary = [
['小计', money(subtotal)],
[`伙伴折扣 · ${pct(invoice.discountRate)}`, '-' + money(discount)],
[`增值税 · ${pct(invoice.taxRate)}`, money(tax)],
];
summary.forEach(([label, value], i) => {
text(label, fBody, MUTED, EDGE - 220, y + i * 18);
text(value, fBody, INK, EDGE, y + i * 18, 'right');
});
y += 58;
box(NAVY, EDGE - 220, y, 220, 34);
text('应付总额', fTitle, WHITE, EDGE - 204, y + 11);
text(money(total), font(14, true), WHITE, EDGE - 14, y + 8, 'right');
y += 34;
// ── 付款信息 ───────────────────────────────────────────────────
const cardTop = y + 34;
box(SOFT, PAD, cardTop, W - 2 * PAD, 104);
box(ACCENT, PAD, cardTop, 3, 104);
text('付款信息', fLabel, ACCENT, PAD + 18, cardTop + 16);
text(invoice.bank[0], fBody, INK, PAD + 18, cardTop + 34);
text(invoice.bank[1], fBody, INK, PAD + 18, cardTop + 50);
invoice.remark.forEach((line, i) => text(line, fSmall, MUTED, PAD + 18, cardTop + 72 + i * 14));
// ── 页脚色带,与页眉对称 ─────────────────────────────────
box(NAVY, 0, H - 58, W, 58);
text(invoice.legal, fFoot, ON_NAVY, PAD, H - 41);
text(invoice.contact, fFoot, ON_NAVY, PAD, H - 27);
text('第 1 页 / 共 1 页', fFoot, ON_NAVY, EDGE, H - 41, 'right');
text(invoice.number, fFoot, ON_NAVY, EDGE, H - 27, 'right');
// ── 保存并下载 ─────────────────────────────────────────────────
const fileName = 'Invoice-zh.pdf';
doc.SaveToFile({ fileName });
doc.Close();
const bytes = window.dotnetRuntime.Module.FS.readFile(fileName);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
Object.assign(document.createElement('a'), { href: url, download: fileName }).click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>生成发票 PDF</h1>
<button onClick={generateInvoicePdf}>生成发票 PDF</button>
</div>
);
}
export default App;
从 JavaScript 数据生成的发票 PDF

代码说明:
- 绘制任何中文之前,先把中文字体文件写入 WebAssembly 虚拟文件系统(VFS),再用
PdfTrueTypeFont按路径引用它。PdfFontFamily里的 Helvetica 等标准字体只有拉丁字形,直接用它绘制中文会得到空白。 - 定义一个
invoice对象,包含卖方、客户、发票元数据和items数组——这正是 React 应用从表单、API 响应或组件状态中获取的数据结构。 - 使用
reduce从该数据派生subtotal、discount、tax和total,确保 PDF 上打印的金额与应用中的金额始终一致。 - 在调用
Pages.Add()之前将Margins.All设为0。画布原点位于页面边距原点,因此这一设置使(0, 0)成为纸张的左上角,让页眉和页脚色带出血到边缘。 - 基于
Canvas.ClientSize而非Size进行页面布局,因为超出客户区域的文本会被丢弃而非裁剪。 - 使用
DrawRectangle、DrawLine和DrawString绘制页眉色带、购买方和发票详情块、金额汇总和付款信息框。右对齐字符串先用MeasureString测量宽度,因为DrawString在按点绘制时没有对齐选项。 - 使用
PdfGrid构建明细表格:固定列宽、带样式的表头行、斑马纹数据行,以及由PdfStringFormat驱动的右对齐数字列。 - 将文档保存到 VFS,并使用第 5 步的 blob-and-anchor 模式下载。
同一模式适用于任何结构化文档——将 invoice 对象替换为报表数据、报价数据或收据数据,五步流程保持不变。
为 PDF 添加更多内容
创建基础文档后,可以根据需要生成的文档类型,使用其他 PDF 元素扩展同一流程。
| 元素 | 典型用途 | 教程 |
|---|---|---|
| 文本 | 标题、标签、段落 | 本文已涵盖 |
| 图像 | Logo、签名、图表 | 在 PDF 中添加图像 |
| 表格 | 发票、报表、对账单 | 文档操作指南 |
| 形状 | 边框、分隔线、示意图 | 在 PDF 中绘制形状 |
| 页眉与页脚 | 页码、重复页眉 | 页面设置指南 |
| 表单域 | 交互式表单、可编辑文档 | 表单域指南 |
五步流程不变——只是第 3 步中绘制的内容不同。
使用 WebAssembly 在浏览器中生成 PDF
Spire.PDF for JavaScript 通过 WebAssembly 运行其 PDF 处理引擎。WASM 模块加载后,您的应用可以在客户端创建、编辑和保存 PDF。文件通过浏览器内的虚拟文件系统(VFS)流转:
JavaScript -> Spire.PDF WebAssembly -> VFS -> Blob -> 下载

这意味着无需 PDF 生成后端。浏览器在本地处理文档创建、渲染和文件输出。生成的 PDF 从 VFS 中读为字节数组,并作为标准 Blob 下载。
代价是初始 WASM 下载大小,这是每次会话的一次性成本。对于频繁生成文档的应用,后续生成速度很快,因为模块已加载完毕。对于非常大或复杂的 PDF,客户端性能取决于用户设备和可用内存。
常见用例
常见用例包括发票、报表、报价单、证书、收据以及其他从应用数据生成的结构化业务文档。具体布局会变化,但底层流程——创建、添加页面、绘制、保存、下载——保持不变。
故障排除
WASM 模块未初始化
如果 window.spirepdf 未定义,请确保在使用 API 之前完全初始化 WebAssembly 运行时:
const commonModule = await import('/node_modules/spire.office/spire.common.js');
await commonModule.initializeWasm();
await import('/node_modules/spire.office/spire.pdf.js');
生成的 PDF 无法下载
如果下载未触发或文件为空,请验证在从 VFS 读取之前已调用 SaveToFile。文件必须先存在于 VFS 中,FS.readFile 才能读取:
doc.SaveToFile({ fileName: 'Output.pdf' });
doc.Close();
// 保存后才能读取
const fileArray = window.dotnetRuntime.Module.FS.readFile('Output.pdf');
总结
Spire.PDF for JavaScript 提供了一种实用的方式,使用 JavaScript 和 WebAssembly 直接在浏览器中生成 PDF 文件。五步流程——创建、添加页面、绘制、保存、下载——涵盖了从简单文本文档到包含表格和金额汇总的结构化发票的所有场景。您可以申请 30 天免费许可证在购买前评估所有功能。







