PDF 文本提取是文档自动化里非常常见的一类需求。传统做法是把文件传到后端,由服务端解析后返回文本,但面对大体积 PDF 和并发用户时,服务器压力、上传带宽消耗都会成为瓶颈。把解析能力搬到浏览器里,用 WASM 在前端完成,文件不上传服务器,隐私性和后端负载都能得到改善。
本文基于 Spire.PDF for JavaScript(WASM)配合 React 函数组件与 Hooks,实现一个纯前端的 PDF 文本提取工具:用户选择本地 PDF,前端解析后导出为 TXT 下载,全过程不依赖后端接口。
一、技术选型与整体流程
Spire.PDF for JavaScript 是一个运行在浏览器端的 PDF 操作库,核心通过 WebAssembly 编译,对外提供 JavaScript API,用于加载、解析和操作 PDF 文档。它支持提取文本、图片、表单数据等,本文只用到其中的文本提取能力。
整个处理链路如下:
用户选择 PDF 文件 → 浏览器读取为 ArrayBuffer → WASM 模块加载 PDF 文档 → 逐页提取文本内容 → 生成 TXT 文件并下载
二、依赖文件与目录准备
先安装依赖包:
安装后,把下面这些文件放到 React 项目的 public/ 目录下,由浏览器以静态资源方式访问:
spire.pdf.js —— JS 胶水代码,负责加载和调用 WASM
spire.pdf.wasm —— 核心解析模块
spire.common.js —— 公共依赖
Spire.Common.Wasm.zip —— 公共 WASM 压缩包
_framework 文件夹 —— 依赖的框架资源
项目可用 Create React App 或 Vite 创建,示例基于 React 函数组件和 Hooks 编写。
三、WASM 模块异步加载
WASM 需要异步加载,因此在 useEffect 中动态 import,并把初始化后的模块实例存入 state。
- useEffect(() => {
- (async () => {
- try {
- const publicUrl = process.env.PUBLIC_URL || '';
- // 动态导入 JS 胶水代码,webpackIgnore 避免打包处理
- const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.pdf.js`);
- const rawModule = spireModule.default || spireModule;
- // 加载 WASM,指定 locateFile 以找到 .wasm 文件
- const loadedModule = typeof rawModule === 'function'
- ? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
- : rawModule;
- setPdfModule(loadedModule);
- } catch (error) {
- console.error('加载 spire.pdf.js 失败:', error);
- }
- })();
- }, []);
复制代码
这段代码有两个关键点:
1. webpackIgnore: true 加在动态 import 前,作用是阻止 Webpack 改写导入路径,保证运行时直接从 public 目录取文件;如果被改写成打包内模块,通常会在控制台看到 404 或模块解析失败。
2. locateFile 回调用来告诉 WASM 加载器 .wasm 文件的具体位置,只对以 .wasm 结尾的路径做前缀拼接,其余路径原样返回。
另外,由于导入结果可能是函数(Emscripten 风格的工厂函数)也可能是已初始化的对象,代码先取 default 兜底,再用 typeof rawModule === 'function' 判断是否需要 await 调用一次完成初始化。
四、文件选择与状态管理
原生 input 的存在感太强,这里用 useRef 隐藏它,由自定义按钮触发点击,保证 UI 一致:
- const fileInputRef = useRef(null);
- const handleSelectFile = () => {
- fileInputRef.current.click();
- };
- const handleFileChange = (e) => {
- const file = e.target.files[0];
- if (file && file.type === 'application/pdf') {
- setSelectedFile(file);
- } else {
- alert('请选择一个有效的 PDF 文件');
- e.target.value = '';
- }
- };
复制代码
这里对 file.type 做了 application/pdf 校验,非法文件会清空 input 的值,避免用户再次选择同名文件时不触发 change 事件。
五、文本提取与下载
核心逻辑封装在 ExtractPDFText 中,依次完成读取字节、加载文档、逐页取文本、生成文件下载四步:
- const ExtractPDFText = async () => {
- if (!selectedFile) {
- alert('请先选择一个 PDF 文件');
- return;
- }
- const spirepdf = pdfModule?.spirepdf;
- if (!spirepdf) {
- alert('WASM 模块尚未加载完成,请稍后重试');
- return;
- }
- setIsExtracting(true);
- try {
- // 1. 读取文件为字节数组
- const arrayBuffer = await selectedFile.arrayBuffer();
- const uint8Array = new Uint8Array(arrayBuffer);
- // 2. 创建 PDF 文档对象并加载
- const pdf = new spirepdf.PdfDocument();
- pdf.LoadFromBytes(uint8Array);
- // 3. 配置提取选项(默认即可)
- const extractOptions = new spirepdf.PdfTextExtractOptions();
- let text = '';
- // 4. 遍历所有页面
- for (let i = 0; i < pdf.Pages.Count; i++) {
- const page = pdf.Pages.get_Item(i);
- const textExtractor = new spirepdf.PdfTextExtractor(page);
- text += textExtractor.ExtractText(extractOptions);
- }
- // 5. 生成 TXT 文件并触发下载
- const outputFileName = selectedFile.name.replace(/\.pdf$/i, '_提取结果.txt');
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
- 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);
- } catch (error) {
- console.error('提取文本失败:', error);
- alert('提取文本时发生错误,请查看控制台');
- } finally {
- setIsExtracting(false);
- }
- };
复制代码
几个容易踩坑的细节:
1. LoadFromBytes 接收的是 Uint8Array,直接用 ArrayBuffer 会类型不符,所以要先用 new Uint8Array(arrayBuffer) 转换。
2. 页面集合的下标从 0 开始,循环上限是 pdf.Pages.Count,页面对象通过 pdf.Pages.get_Item(i) 获取。
3. 每页都要新建 PdfTextExtractor 并调用 ExtractText(extractOptions),返回值拼接成完整文本。
4. 下载用 Blob 加 URL.createObjectURL 完成,不需要后端参与;type 中显式声明 charset=utf-8,可避免中文内容在下载后出现乱码。用完记得 URL.revokeObjectURL(url) 释放。
5. 文件名通过正则 /(.pdf$)/i 把后缀替换为“_提取结果.txt”,非 PDF 后缀不会命中,保持原名。
六、交互状态与按钮控制
为了不让用户在模块未就绪时点击,组件维护了三个状态:
selectedFile:当前选中的文件对象
isExtracting:是否正在提取,用于显示加载文案并禁用按钮
pdfModule:WASM 模块是否加载完成
按钮禁用条件由三者合并判断:
- const isButtonDisabled = !selectedFile || isExtracting || !pdfModule;
复制代码
页面采用卡片居中布局,主色 #1a73e8,悬停加深为 #1558b0,禁用态置灰 #b0b0b0 并去掉阴影。
七、完整组件代码
以下为整合后的 App.js,把依赖文件放进 public/ 后可直接使用:
- import React, { useState, useEffect, useRef } from 'react';
- function App() {
- const [pdfModule, setPdfModule] = useState(null);
- const [selectedFile, setSelectedFile] = useState(null);
- const [isExtracting, setIsExtracting] = useState(false);
- const fileInputRef = useRef(null);
- useEffect(() => {
- (async () => {
- try {
- const publicUrl = process.env.PUBLIC_URL || '';
- const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.pdf.js`);
- const rawModule = spireModule.default || spireModule;
- const loadedModule = typeof rawModule === 'function'
- ? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
- : rawModule;
- setPdfModule(loadedModule);
- } catch (error) {
- console.error('加载 spire.pdf.js 失败:', error);
- alert('PDF 库加载失败,请刷新重试');
- }
- })();
- }, []);
- const handleSelectFile = () => {
- fileInputRef.current.click();
- };
- const handleFileChange = (e) => {
- const file = e.target.files[0];
- if (file && file.type === 'application/pdf') {
- setSelectedFile(file);
- } else {
- alert('请选择一个有效的 PDF 文件');
- e.target.value = '';
- }
- };
- const ExtractPDFText = async () => {
- if (!selectedFile) {
- alert('请先选择一个 PDF 文件');
- return;
- }
- const spirepdf = pdfModule?.spirepdf;
- if (!spirepdf) {
- alert('WASM 模块尚未加载完成,请稍后重试');
- return;
- }
- setIsExtracting(true);
- try {
- const arrayBuffer = await selectedFile.arrayBuffer();
- const uint8Array = new Uint8Array(arrayBuffer);
- const pdf = new spirepdf.PdfDocument();
- pdf.LoadFromBytes(uint8Array);
- const extractOptions = new spirepdf.PdfTextExtractOptions();
- let text = '';
- for (let i = 0; i < pdf.Pages.Count; i++) {
- const page = pdf.Pages.get_Item(i);
- const textExtractor = new spirepdf.PdfTextExtractor(page);
- text += textExtractor.ExtractText(extractOptions);
- }
- const outputFileName = selectedFile.name.replace(/\.pdf$/i, '_提取结果.txt');
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
- 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);
- } catch (error) {
- console.error('提取文本失败:', error);
- alert('提取文本时发生错误,请查看控制台');
- } finally {
- setIsExtracting(false);
- }
- };
- const styles = {
- container: {
- display: 'flex', justifyContent: 'center', alignItems: 'center',
- minHeight: '100vh', backgroundColor: '#f0f2f5',
- fontFamily: 'Arial, sans-serif', padding: '20px',
- },
- card: {
- backgroundColor: '#fff', padding: '40px 50px', borderRadius: '16px',
- boxShadow: '0 8px 24px rgba(0,0,0,0.12)', maxWidth: '600px',
- width: '100%', textAlign: 'center',
- },
- title: { fontSize: '28px', fontWeight: '600', color: '#1a1a2e', marginBottom: '30px' },
- fileArea: { marginBottom: '30px' },
- customFileButton: {
- display: 'inline-block', padding: '10px 24px', backgroundColor: '#e8f0fe',
- color: '#1a73e8', borderRadius: '30px', fontWeight: '500', cursor: 'pointer',
- border: '1px solid #d2e3fc', transition: 'background 0.2s',
- },
- fileName: { marginTop: '12px', fontSize: '16px', color: '#333', wordBreak: 'break-all' },
- extractButton: {
- padding: '12px 40px', fontSize: '18px', fontWeight: '500', color: '#fff',
- backgroundColor: '#1a73e8', border: 'none', borderRadius: '30px', cursor: 'pointer',
- transition: 'background 0.3s, transform 0.1s',
- boxShadow: '0 2px 8px rgba(26,115,232,0.3)',
- },
- extractButtonDisabled: { backgroundColor: '#b0b0b0', cursor: 'not-allowed', boxShadow: 'none' },
- extractingText: { marginTop: '15px', fontSize: '16px', color: '#1a73e8' },
- };
- const isButtonDisabled = !selectedFile || isExtracting || !pdfModule;
- return (
- <div style={styles.container}>
- <div style={styles.card}>
- <h1 style={styles.title}>PDF 文本提取器</h1>
- <div style={styles.fileArea}>
- <input
- type="file"
- accept=".pdf"
- onChange={handleFileChange}
- ref={fileInputRef}
- style={{ display: 'none' }}
- />
- <div
- style={styles.customFileButton}
- onClick={handleSelectFile}
- onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#d2e3fc')}
- onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#e8f0fe')}
- >
- 选择 PDF 文件
- </div>
- {selectedFile && (
- <div style={styles.fileName}>
- 已选择:<strong>{selectedFile.name}</strong>
- </div>
- )}
- </div>
- <button
- onClick={ExtractPDFText}
- disabled={isButtonDisabled}
- style={{
- ...styles.extractButton,
- ...(isButtonDisabled ? styles.extractButtonDisabled : {}),
- }}
- onMouseEnter={(e) => {
- if (!isButtonDisabled) e.currentTarget.style.backgroundColor = '#1558b0';
- }}
- onMouseLeave={(e) => {
- if (!isButtonDisabled) e.currentTarget.style.backgroundColor = '#1a73e8';
- }}
- >
- {isExtracting ? '正在提取...' : '提取并下载文本'}
- </button>
- {isExtracting && <div style={styles.extractingText}>处理中,请稍候…</div>}
- </div>
- </div>
- );
- }
- export default App;
复制代码
八、运行与排查要点
执行 npm start 启动开发服务器,页面加载后会自动加载 WASM 模块,稍等片刻按钮才变为可点击。选中 PDF、点击按钮后,浏览器会自动下载“原文件名_提取结果.txt”。
如果点击后没有反应,按以下顺序排查:
1. 控制台出现“模块尚未加载完成”提示,说明 pdfModule 为空,多半是 public/ 下的 spire.pdf.js、spire.pdf.wasm 或 Spire.Common.Wasm.zip 缺失。
2. 导入路径被构建工具改写,会导致运行时找不到 JS 胶水代码,此时检查动态 import 是否保留了 webpackIgnore: true。
3. locateFile 未生效时,WASM 加载器会在错误路径下找 .wasm,表现为初始化失败,需要确认 publicUrl 拼接结果与实际文件位置一致。
4. 示例使用 process.env.PUBLIC_URL,这是 CRA 提供的变量;换到其他构建工具时,应替换为对应的静态资源基础路径。
九、适用场景与扩展方向
该实现完全运行在浏览器端,不依赖后端服务,适用于文档预览、内容检索、数据迁移等场景,文件不上传也意味着数据不出本地。
在此基础上还可以继续扩展:调整 PdfTextExtractOptions 的提取选项以适配不同排版;在循环中增加单页异常捕获,避免某一页失败中断整个文档;把单文件改为 files 数组并加进度条实现批量处理;或将提取结果接入前端全文搜索索引。整体结构清晰,可以直接嵌入已有的 React 项目。 |