查看: 597|回复: 3

React集成WASM实现浏览器端PDF文本提取与下载

[复制链接]
发表于 8 小时前 | 显示全部楼层 |阅读模式
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 文件并下载

二、依赖文件与目录准备

先安装依赖包:
  1. npm i spire.office
复制代码

安装后,把下面这些文件放到 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。
  1. useEffect(() => {
  2.   (async () => {
  3.     try {
  4.       const publicUrl = process.env.PUBLIC_URL || '';
  5.       // 动态导入 JS 胶水代码,webpackIgnore 避免打包处理
  6.       const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.pdf.js`);
  7.       const rawModule = spireModule.default || spireModule;
  8.       // 加载 WASM,指定 locateFile 以找到 .wasm 文件
  9.       const loadedModule = typeof rawModule === 'function'
  10.         ? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
  11.         : rawModule;
  12.       setPdfModule(loadedModule);
  13.     } catch (error) {
  14.       console.error('加载 spire.pdf.js 失败:', error);
  15.     }
  16.   })();
  17. }, []);
复制代码

这段代码有两个关键点:

1. webpackIgnore: true 加在动态 import 前,作用是阻止 Webpack 改写导入路径,保证运行时直接从 public 目录取文件;如果被改写成打包内模块,通常会在控制台看到 404 或模块解析失败。
2. locateFile 回调用来告诉 WASM 加载器 .wasm 文件的具体位置,只对以 .wasm 结尾的路径做前缀拼接,其余路径原样返回。

另外,由于导入结果可能是函数(Emscripten 风格的工厂函数)也可能是已初始化的对象,代码先取 default 兜底,再用 typeof rawModule === 'function' 判断是否需要 await 调用一次完成初始化。

四、文件选择与状态管理

原生 input 的存在感太强,这里用 useRef 隐藏它,由自定义按钮触发点击,保证 UI 一致:
  1. const fileInputRef = useRef(null);
  2. const handleSelectFile = () => {
  3.   fileInputRef.current.click();
  4. };
  5. const handleFileChange = (e) => {
  6.   const file = e.target.files[0];
  7.   if (file && file.type === 'application/pdf') {
  8.     setSelectedFile(file);
  9.   } else {
  10.     alert('请选择一个有效的 PDF 文件');
  11.     e.target.value = '';
  12.   }
  13. };
复制代码

这里对 file.type 做了 application/pdf 校验,非法文件会清空 input 的值,避免用户再次选择同名文件时不触发 change 事件。

五、文本提取与下载

核心逻辑封装在 ExtractPDFText 中,依次完成读取字节、加载文档、逐页取文本、生成文件下载四步:
  1. const ExtractPDFText = async () => {
  2.   if (!selectedFile) {
  3.     alert('请先选择一个 PDF 文件');
  4.     return;
  5.   }
  6.   const spirepdf = pdfModule?.spirepdf;
  7.   if (!spirepdf) {
  8.     alert('WASM 模块尚未加载完成,请稍后重试');
  9.     return;
  10.   }
  11.   setIsExtracting(true);
  12.   try {
  13.     // 1. 读取文件为字节数组
  14.     const arrayBuffer = await selectedFile.arrayBuffer();
  15.     const uint8Array = new Uint8Array(arrayBuffer);
  16.     // 2. 创建 PDF 文档对象并加载
  17.     const pdf = new spirepdf.PdfDocument();
  18.     pdf.LoadFromBytes(uint8Array);
  19.     // 3. 配置提取选项(默认即可)
  20.     const extractOptions = new spirepdf.PdfTextExtractOptions();
  21.     let text = '';
  22.     // 4. 遍历所有页面
  23.     for (let i = 0; i < pdf.Pages.Count; i++) {
  24.       const page = pdf.Pages.get_Item(i);
  25.       const textExtractor = new spirepdf.PdfTextExtractor(page);
  26.       text += textExtractor.ExtractText(extractOptions);
  27.     }
  28.     // 5. 生成 TXT 文件并触发下载
  29.     const outputFileName = selectedFile.name.replace(/\.pdf$/i, '_提取结果.txt');
  30.     const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
  31.     const url = URL.createObjectURL(blob);
  32.     const a = document.createElement('a');
  33.     a.href = url;
  34.     a.download = outputFileName;
  35.     document.body.appendChild(a);
  36.     a.click();
  37.     document.body.removeChild(a);
  38.     URL.revokeObjectURL(url);
  39.   } catch (error) {
  40.     console.error('提取文本失败:', error);
  41.     alert('提取文本时发生错误,请查看控制台');
  42.   } finally {
  43.     setIsExtracting(false);
  44.   }
  45. };
复制代码

几个容易踩坑的细节:

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 模块是否加载完成

按钮禁用条件由三者合并判断:
  1. const isButtonDisabled = !selectedFile || isExtracting || !pdfModule;
复制代码

页面采用卡片居中布局,主色 #1a73e8,悬停加深为 #1558b0,禁用态置灰 #b0b0b0 并去掉阴影。

七、完整组件代码

以下为整合后的 App.js,把依赖文件放进 public/ 后可直接使用:
  1. import React, { useState, useEffect, useRef } from 'react';
  2. function App() {
  3.   const [pdfModule, setPdfModule] = useState(null);
  4.   const [selectedFile, setSelectedFile] = useState(null);
  5.   const [isExtracting, setIsExtracting] = useState(false);
  6.   const fileInputRef = useRef(null);
  7.   useEffect(() => {
  8.     (async () => {
  9.       try {
  10.         const publicUrl = process.env.PUBLIC_URL || '';
  11.         const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.pdf.js`);
  12.         const rawModule = spireModule.default || spireModule;
  13.         const loadedModule = typeof rawModule === 'function'
  14.           ? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
  15.           : rawModule;
  16.         setPdfModule(loadedModule);
  17.       } catch (error) {
  18.         console.error('加载 spire.pdf.js 失败:', error);
  19.         alert('PDF 库加载失败,请刷新重试');
  20.       }
  21.     })();
  22.   }, []);
  23.   const handleSelectFile = () => {
  24.     fileInputRef.current.click();
  25.   };
  26.   const handleFileChange = (e) => {
  27.     const file = e.target.files[0];
  28.     if (file && file.type === 'application/pdf') {
  29.       setSelectedFile(file);
  30.     } else {
  31.       alert('请选择一个有效的 PDF 文件');
  32.       e.target.value = '';
  33.     }
  34.   };
  35.   const ExtractPDFText = async () => {
  36.     if (!selectedFile) {
  37.       alert('请先选择一个 PDF 文件');
  38.       return;
  39.     }
  40.     const spirepdf = pdfModule?.spirepdf;
  41.     if (!spirepdf) {
  42.       alert('WASM 模块尚未加载完成,请稍后重试');
  43.       return;
  44.     }
  45.     setIsExtracting(true);
  46.     try {
  47.       const arrayBuffer = await selectedFile.arrayBuffer();
  48.       const uint8Array = new Uint8Array(arrayBuffer);
  49.       const pdf = new spirepdf.PdfDocument();
  50.       pdf.LoadFromBytes(uint8Array);
  51.       const extractOptions = new spirepdf.PdfTextExtractOptions();
  52.       let text = '';
  53.       for (let i = 0; i < pdf.Pages.Count; i++) {
  54.         const page = pdf.Pages.get_Item(i);
  55.         const textExtractor = new spirepdf.PdfTextExtractor(page);
  56.         text += textExtractor.ExtractText(extractOptions);
  57.       }
  58.       const outputFileName = selectedFile.name.replace(/\.pdf$/i, '_提取结果.txt');
  59.       const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
  60.       const url = URL.createObjectURL(blob);
  61.       const a = document.createElement('a');
  62.       a.href = url;
  63.       a.download = outputFileName;
  64.       document.body.appendChild(a);
  65.       a.click();
  66.       document.body.removeChild(a);
  67.       URL.revokeObjectURL(url);
  68.     } catch (error) {
  69.       console.error('提取文本失败:', error);
  70.       alert('提取文本时发生错误,请查看控制台');
  71.     } finally {
  72.       setIsExtracting(false);
  73.     }
  74.   };
  75.   const styles = {
  76.     container: {
  77.       display: 'flex', justifyContent: 'center', alignItems: 'center',
  78.       minHeight: '100vh', backgroundColor: '#f0f2f5',
  79.       fontFamily: 'Arial, sans-serif', padding: '20px',
  80.     },
  81.     card: {
  82.       backgroundColor: '#fff', padding: '40px 50px', borderRadius: '16px',
  83.       boxShadow: '0 8px 24px rgba(0,0,0,0.12)', maxWidth: '600px',
  84.       width: '100%', textAlign: 'center',
  85.     },
  86.     title: { fontSize: '28px', fontWeight: '600', color: '#1a1a2e', marginBottom: '30px' },
  87.     fileArea: { marginBottom: '30px' },
  88.     customFileButton: {
  89.       display: 'inline-block', padding: '10px 24px', backgroundColor: '#e8f0fe',
  90.       color: '#1a73e8', borderRadius: '30px', fontWeight: '500', cursor: 'pointer',
  91.       border: '1px solid #d2e3fc', transition: 'background 0.2s',
  92.     },
  93.     fileName: { marginTop: '12px', fontSize: '16px', color: '#333', wordBreak: 'break-all' },
  94.     extractButton: {
  95.       padding: '12px 40px', fontSize: '18px', fontWeight: '500', color: '#fff',
  96.       backgroundColor: '#1a73e8', border: 'none', borderRadius: '30px', cursor: 'pointer',
  97.       transition: 'background 0.3s, transform 0.1s',
  98.       boxShadow: '0 2px 8px rgba(26,115,232,0.3)',
  99.     },
  100.     extractButtonDisabled: { backgroundColor: '#b0b0b0', cursor: 'not-allowed', boxShadow: 'none' },
  101.     extractingText: { marginTop: '15px', fontSize: '16px', color: '#1a73e8' },
  102.   };
  103.   const isButtonDisabled = !selectedFile || isExtracting || !pdfModule;
  104.   return (
  105.     <div style={styles.container}>
  106.       <div style={styles.card}>
  107.         <h1 style={styles.title}>PDF 文本提取器</h1>
  108.         <div style={styles.fileArea}>
  109.           <input
  110.             type="file"
  111.             accept=".pdf"
  112.             onChange={handleFileChange}
  113.             ref={fileInputRef}
  114.             style={{ display: 'none' }}
  115.           />
  116.           <div
  117.             style={styles.customFileButton}
  118.             onClick={handleSelectFile}
  119.             onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#d2e3fc')}
  120.             onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#e8f0fe')}
  121.           >
  122.             选择 PDF 文件
  123.           </div>
  124.           {selectedFile && (
  125.             <div style={styles.fileName}>
  126.               已选择:<strong>{selectedFile.name}</strong>
  127.             </div>
  128.           )}
  129.         </div>
  130.         <button
  131.           onClick={ExtractPDFText}
  132.           disabled={isButtonDisabled}
  133.           style={{
  134.             ...styles.extractButton,
  135.             ...(isButtonDisabled ? styles.extractButtonDisabled : {}),
  136.           }}
  137.           onMouseEnter={(e) => {
  138.             if (!isButtonDisabled) e.currentTarget.style.backgroundColor = '#1558b0';
  139.           }}
  140.           onMouseLeave={(e) => {
  141.             if (!isButtonDisabled) e.currentTarget.style.backgroundColor = '#1a73e8';
  142.           }}
  143.         >
  144.           {isExtracting ? '正在提取...' : '提取并下载文本'}
  145.         </button>
  146.         {isExtracting && <div style={styles.extractingText}>处理中,请稍候…</div>}
  147.       </div>
  148.     </div>
  149.   );
  150. }
  151. 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 项目。
回复

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: React集成WASM实现浏览器端PDF文本提取与下载

这个思路挺实用的,把解析放到前端确实能省掉上传带宽和后端并发压力,对隐私敏感的文档场景也更合适。 你提到的两个坑很到位,webpackIgnore 和 locateFile 基本是这类 WASM 库集成时最容易翻车的地方。补充一点:如果项目用的是 Vite,webpackIgnore 注释是不生效的,得换成 /* @vite-ignore */,而且 PUBLIC_URL 在 Vite 里也不存在,要用 import.meta.env.BASE_URL 或者干脆写死相对路径,否则部署到子目录下会 404。这块建议在文里点一句,能少让不少人踩坑。 另外几个建议可以在后面补充部分带上:一是首次加载 spire.pdf.wasm 体积不小,最好加个加载中的状态提示,避免用户点按钮时模块还没就绪;二是逐页提取文本时,几百页的 PDF 在主线程上跑会卡住 UI,如果追求体验可以丢进 Web Worker,把 Uint8Array 用 transferable 传过去;三是 PdfDocument 用完记得调用释放/Dispose 之类的方法,多文件连续操作时不然内存容易涨上去;四是整个提取逻辑用 try/catch/finally 包住,确保按钮的 loading 状态一定会复位。 最后,正文好像发到 “const extractOptions = new sp” 这里就断了,后面的逐页提取
回复 支持 反对

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: React集成WASM实现浏览器端PDF文本提取与下载

思路挺清晰的,把 PDF 解析从后端搬到浏览器端确实是个好方向,隐私和服务器压力两方面都受益,尤其是有合规要求、文件不方便出内网外传的场景。 几个读下来想补充的点: 一是 webpackIgnore 那段在 Webpack 下没问题,但如果项目是 Vite 搭的,动态 import 变量路径会报错,得换成 Vite 的忽略注释,或者干脆走 script 标签手动注入。楼主用的是 CRA 的话问题不大,但换成 Vite 的读者容易在这一步踩坑,正文里提一嘴会更稳。 二是内存这块值得警惕。ArrayBuffer 一次读进来,加上 WASM 堆里再复制一份,几十兆以上的 PDF 在低配机器或者移动端很容易顶不住。简单点可以在选择文件时加个大小上限并提示,复杂点可以考虑分页处理、及时释放。另外 PdfDocument 用完记得调 Dispose,不然反复提取会一直涨内存。 三是错误处理可以再细一点。WASM 首次加载的文件体积不小,加载期间用户点提取只会收到"尚未加载完成",体验上不如把加载状态和进度显式反馈出来,顺便用 Cache Storage 或 Service Worker 缓存一下,二次进入会快很多。 四是文本提取的边界。扫描件、纯图片 PDF 逐页取文本是拿不到内容的,得走 OCR,这点最好在 UI 上说明,避免用户以为工具坏了。还有中文字体编码特殊的 PDF 偶尔会出现乱码或
回复 支持 反对

使用道具 举报

发表于 7 小时前 | 显示全部楼层

Re: React集成WASM实现浏览器端PDF文本提取与下载

帖子写得很清楚,整体链路一目了然,把“文件不上传服务器”这个收益点也交代得很明白。几个地方我读的时候有点想法,顺手说一下。 首先正文最后停在 `const extractOptions = new sp` 这里,后面逐页提取、拼接文本、生成 TXT 下载的部分没贴出来,正好是最关键的一段,如果方便的话补全一下,大家照着做才不会卡在最后一步。 几个实现细节的提醒: 一是路径问题。`process.env.PUBLIC_URL` 这套写法在 CRA 里没问题,Vite 下要换成 `import.meta.env.BASE_URL`,否则部署到子路径时 WASM 会 404,而且报错信息一般不太直观,建议在加载失败时把具体请求的 URL 打印出来,方便排查。另外 public 目录放 wasm 时,要确认服务器的 MIME 类型和 gzip 设置,有些静态托管会把 .wasm 当成 application/octet-stream,个别加载器对此敏感。 二是初始化时序。代码里是异步加载模块后存 state,但按钮没提到禁用逻辑。建议在 `pdfModule` 还没就绪时把“提取”按钮置灰,比弹“模块尚未加载完成”的提示体验更好。同时注意如果组件卸载时模块还在加载,setState 会报警告,加个 cancelled 标志比较稳。 三是性能。WASM 虽然绕开了后端,但解析和文本提取仍然跑
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-30 15:03 , Processed in 0.025805 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部