Web 应用里生成 PDF 的需求很常见:报表、发票、订单详情。常见做法是交给后端渲染,前端直接转换则有另一套取舍------数据不离开浏览器,省去网络往返,后端不必承担计算开销,代价是转换逻辑和字体资源都要在前端管理。
本文记录在 React 项目中完成 HTML 到 PDF 转换的过程,包括环境配置、模块加载、文件与字符串两种输入的处理,以及中文内容涉及的字体问题。
客户端转换的适用场景
服务端方案(wkhtmltopdf、Puppeteer 等)成熟稳定,但 HTML 内容需要传到服务器,敏感数据多了一次外发;服务器要额外承担渲染开销;用户要等一个网络往返。
客户端方案把转换放在浏览器执行,数据不出本地,后端只需提供静态资源。WebAssembly 让这件事变得可行------文档处理库可以编译成 wasm 在浏览器里运行。
需要说明的是,客户端方案并非在所有场景下都优于服务端。当文档结构复杂、需要引用大量外部资源、或对排版精度要求极高时,服务端方案的可控性通常更好。客户端方案更适合内容相对简单、对隐私和响应速度有要求的场景。
环境准备与模块初始化
安装:
bash
npm i spire.office
安装完成后,把 spire.doc.js、Spire.Doc.Wasm.zip 及相关运行时文件复制到 React 项目的 public 目录。WebAssembly 模块异步加载,在组件里用 useEffect 在挂载时初始化:
javascript
import React, { useState, useEffect } from 'react';
function App() {
const [wasmModule, setWasmModule] = useState(null);
useEffect(() => {
(async () => {
try {
const publicUrl = process.env.PUBLIC_URL || '';
const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.doc.js`);
const rawModule = spireModule.default || spireModule;
window.wasmModule = typeof rawModule === 'function'
? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
: rawModule;
setWasmModule(window.wasmModule);
} catch (error) {
console.error('Failed to load spire.doc.js WASM module:', error);
}
})();
}, []);
}
两个关键点:/* webpackIgnore: true */ 阻止 Webpack 对动态导入路径打包,让运行时从 public 目录按原路径加载;locateFile 回调确保 .wasm 文件从同一目录取。wasmModule 这个 state 用于在加载完成后才启用转换按钮,避免用户过早点击。
把模块挂到 window 上是一种可行做法。更严格的项目可以把实例保存在 ref 或 context 中,避免污染全局作用域。
字体管理与虚拟文件系统
字体处理是容易出问题的一环。如果 PDF 中用到的字体在运行环境中不存在,排版会乱、字符会缺。这类库用虚拟文件系统(VFS)管理字体和输入文件,转换前需要先把字体加载进去。
FetchFileToVFS 接收三个参数:文件名、VFS 中的目标路径、源文件所在的 URL 前缀。加载一款西文字体:
javascript
await window.spire.FetchFileToVFS('CALIBRI.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
字体选哪个取决于 HTML 里实际声明了什么。如果内容含中文,只有西文字体是不够的。 CALIBRI.ttf 不含中文字形,只加载它,中文会变成方块或乱码。需要额外加一款中文字体,比如宋体:
javascript
// 西文字体,用于数字和英文
await window.spire.FetchFileToVFS('CALIBRI.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
// 中文字体
await window.spire.FetchFileToVFS('SimSun.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
字体文件可以从系统字体目录(Windows 下是 C:\Windows\Fonts)复制,商业分发前需确认授权许可。中文字体通常几 MB 到十几 MB,比西文字体大得多,会拖慢加载。只用到少量汉字时,可以对字体做子集化再放进 static/font/。建议统一放在 public/static/font/ 下。
转换 HTML 文件为 PDF
HTML 内容以独立文件存在时,流程分三步:加载字体、把 HTML 文件载入 VFS、创建文档并保存为 PDF。
javascript
const ConvertHTMLFileToPDF = async () => {
const wasmModule = window.wasmModule.spiredoc;
if (wasmModule) {
await window.spire.FetchFileToVFS('CALIBRI.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
await window.spire.FetchFileToVFS('SimSun.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
const inputFileName = 'Sample.html';
const outputFileName = 'HTMLFileToPDF.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);
const doc = new wasmModule.Document();
doc.LoadFromFile({ fileName: inputFileName, fileFormat: wasmModule.FileFormat.Html, validationType: wasmModule.XHTMLValidationType.None });
doc.SaveToFile({ fileName: outputFileName, fileFormat: wasmModule.FileFormat.PDF });
doc.Dispose();
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = outputFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
LoadFromFile 显式指定 fileFormat 为 Html,并用 validationType: XHTMLValidationType.None 关掉 XHTML 验证,结构不够严格的 HTML 也能解析。生成 PDF 后,通过 window.dotnetRuntime.Module.FS.readFile 从 VFS 读出二进制数据,再用 Blob 和 URL.createObjectURL 触发下载。
转换 HTML 字符串为 PDF
HTML 来自前端拼接或接口返回的字符串时,用 AppendHTML 把内容插进段落即可。下面用中文内容,配合前面加载的宋体。
javascript
const ConvertHTMLStringToPDF = async () => {
const wasmModule = window.wasmModule.spiredoc;
if (wasmModule) {
await window.spire.FetchFileToVFS('CALIBRI.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
await window.spire.FetchFileToVFS('SimSun.ttf', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
const doc = new wasmModule.Document();
const outputFileName = 'HTMLStringToPDF.pdf';
const htmlString = `
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>限时促销</title>
</head>
<body style="font-family: SimSun, Arial, sans-serif; margin: 20px;">
<div style="border: 1px solid #ddd; padding: 15px; max-width: 600px; margin: auto; background-color: #f9f9f9;">
<h1 style="color: #e74c3c; text-align: center;">限时优惠,不容错过!</h1>
<p style="font-size: 1.1em; color: #333; line-height: 1.5;">
本周内全场商品享受 85 折优惠。从时尚服饰到家居装饰,热门好物一应俱全,价格实惠,欢迎选购。
</p>
<div style="text-align: center;">
<button style="background-color: #5cb85c; border: none; color: white; padding: 10px 20px; font-size: 16px; margin: 4px 2px; border-radius: 8px;">
立即选购
</button>
</div>
</div>
</body>
</html>
`;
const section = doc.AddSection();
const paragraph = section.AddParagraph();
paragraph.AppendHTML(htmlString);
doc.SaveToFile({fileName: outputFileName, fileFormat: wasmModule.FileFormat.PDF});
doc.Dispose();
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = outputFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
字符串方式不依赖 VFS 里的输入文件,而是通过 AddSection 和 AddParagraph 搭出文档结构,再 AppendHTML 注入内容。适合动态生成的内容片段。AppendHTML 主要识别 HTML 标签和内联样式,对外部 CSS 和 JavaScript 动态渲染支持有限。
关于中文的几点补充:
<meta charset="UTF-8"> 保留即可。HTML 以 JS 字符串传入 AppendHTML,不涉及外部文件读取,字符串编码由 JS 源文件决定,声明主要是为了解析一致性。
CSS 里声明的字体名必须和 VFS 中加载的字体文件实际名称匹配。 上面示例中 font-family: SimSun 对应 VFS 里的 SimSun.ttf。想换微软雅黑,就加载 MSYH.ttf,样式改成 font-family: 'Microsoft YaHei', SimSun, Arial, sans-serif;。这里的「实际名称」指字体文件内部的 family name,而非文件名本身,两者不一致的情况在实际项目中并不少见,可以用字体查看工具确认。
<button> 标签在 PDF 里是否保留,取决于解析器的处理方式,有时会被转成普通文本或忽略。按钮仅作展示用途时,用带背景色和内边距的 <div> 代替更稳妥。
中文显示为方块或空白时,按这个顺序排查:字体文件能否访问、CSS 字体名和 VFS 加载的字体是否一致、字体加载是否在 AppendHTML 之前完成、字体体积是否过大导致加载超时。
两种方式的对比
文件方式需要额外的 VFS 加载步骤,但保留了 HTML 文件的完整结构,适合内容已经以文件存在的场景。字符串方式更直接,省去输入文件加载,适合代码动态生成的场景。样式保真度上,两者用的是同一个解析引擎,差异不大。
资源管理上,两者都要在转换完成后调用 doc.Dispose() 释放文档对象,并从 VFS 读取输出。转换调用频繁时,建议每次转换后清理 VFS 里的临时文件,避免内存占用持续增长。Dispose() 释放的是文档对象,VFS 中已写入的文件不会随之清除,两者要分别处理。
总结
整个流程可以归为三个环节:模块初始化、字体加载、文档生成。其中字体是最容易出问题也最需要提前规划的部分------中文字体的体积、字体名与 CSS 声明的匹配、加载时机,都直接影响转换能否成功以及首次使用的等待时间。
两种输入形式各有适用场景:文件方式适合内容已固化为文件的场景,字符串方式适合动态拼接的内容。API 层面的差异主要在于是否需要先把输入写入 VFS,以及用 LoadFromFile 还是 AppendHTML。
落地到具体项目时,需要权衡的是文档复杂度、对排版精度的要求、可接受的首次加载时间,以及是否需要并发处理多次转换。这些因素共同决定了客户端方案是否合适,以及字体和模块该如何组织。