我先说一个我真实遇到过的业务需求:系统里的在线文档编辑器用的是wangEditor,用户上传了一份带完整目录的PDF产品手册,期待导入之后能在编辑器里保留这份目录、直接按章节编辑。大多数人面对这个需求的第一反应是“打开PDF,全选,复制,粘贴”。结果粘进去之后问题一大堆——标题全部变成了普通段落,书签目录彻底丢失,页眉页脚跟着正文混进来,全文变成一坨无法快速导航的文字块。这篇文章就是围绕“PDF导入、书签识别、目录结构还原”这三个点,讲清楚如何在wangEditor中实现一套可落地的PDF导入方案。
先说明一个容易误解的点:标题里说的“识别书签和目录结构”,并不是指做人脸识别或者OCR图像识别,而是解析PDF文件内部已有的“大纲(Outline)”数据——也就是你在Adobe Acrobat或浏览器PDF阅读器左侧看到的书签树。只需要把这份大纲树读出来,再映射成wangEditor里的多级标题标签,导入后的文档就能在编辑器里拥有完整的目录层级。
1. 这个需求到底在解决什么问题
1.1 直接复制粘贴为什么行不通
做过在线文档编辑功能的人,应该都遇到过用户从PDF里复制内容粘到富文本编辑器里的场景。PDF复制的文本和Word复制不同,它本质上是从“页面渲染层”抽取到的文字片段,PDF自身不会告诉浏览器“这一段是标题、那一段是正文”。所以你粘进wangEditor之后:
- 原本是H1、H2的标题,全部变成普通的
<p>段落,视觉层级消失。 - PDF里的书签/大纲信息完全不会被复制,目录结构无从谈起。
- 页眉、页脚、页码等重复内容会混进正文,需要人工删半天。
- 强内联样式和异常换行会把编辑器里的样式体系搅乱。
这些问题的根源,是因为PDF的视觉排版结构和语义结构是分离的。PDF里只管“这行字画在页面什么位置、用什么字号”,并不直接说明“这是三级标题”。它唯一的语义化信息就是大纲树,也就是书签。
1.2 最终要做出什么样的效果
我理想中的效果是这样:用户选择一个PDF,前端解析完成后,wangEditor里自动生成一份多级目录,每一级对应一层标题:
<h1>产品介绍</h1> <h2>产品概述</h2> <p>正文内容……</p> <h2>核心功能</h2> <h3>在线协作</h3> <p>正文内容……</p>这样编辑器里既有清晰的文档结构,又能直接在标题块上继续编辑,还能通过后续扩展在编辑器外做一个点击跳转的目录面板。更重要的是,这份目录不是写死的静态代码,而是真正变成了编辑器内容的一部分,用户可以增删章节、改标题文字。
1.3 哪些业务场景最需要这个能力
我总结下来,大体有这几类:
- 合同/法务文档库:用户上传带目录的合同扫描PDF,导入后需要快速定位条款章节。
- 企业内部知识库:把产品手册、规章制度PDF导入编辑器,转为可检索可修改的在线文档。
- 内容采编平台:编辑需要把外部PDF资料里的骨架结构引用过来,再替换成原创内容。
- 公众号排版素材库:把PDF画册转成图文内容,保留章节结构可以省去大量手动排版工作。
无论哪种场景,核心诉求只有一个:把PDF的“目录结构”翻译成编辑器能理解的多级标题结构,而不是把PDF当成一张图片或一段纯文本塞进去。
2. 技术选型:为什么 pdf.js 是唯一靠谱的解析方案
2.1 pdf.js 的核心能力与书签提取原理
目前前端做PDF解析,绕不开Mozilla维护的 pdf.js 。它基于Web Worker异步解析PDF文件,会把PDF内部的目录字典读取出来,通过getOutline()方法暴露给我们。这里的“Outlines”就是PDF规范里定义的大纲结构,官方实现的Acrobat书签、Chrome阅读器的左侧目录,底层读的都是同一份数据。
有个细节值得说:pdf.js的getOutline()不需要先渲染任何页面,它只解析PDF交叉引用表和目录对象,所以速度通常比逐页渲染要快得多。同时它返回的是一个嵌套数组,节点之间的父子关系天然对应着目录层级。提取书签这件事,pdf.js几乎是前端唯一能开箱即用的方案。
2.2 和其他前端解析方案的对比
有同事问过我:“pdf-lib不是也能解析PDF吗?”这里做个对比:
| 方案 | 能不能提取书签大纲 | 能不能渲染页面 | 体积/复杂度 | 适用场景 |
|---|---|---|---|---|
| pdf.js | 可以,有getOutline()方法 | 可以,Canvas渲染效果好 | 较大,带worker | 浏览器端完整解析 |
| pdf-lib | 不行,主要做生成和修改PDF | 不行 | 较小 | 创建/编辑PDF时用 |
| 后端PDFBox或Java库 | 可以,功能强 | 可以 | 需要后端接口 | 有服务端资源时选用 |
所以最终我选了pdf.js。但对于超大PDF,如果前端解析内存压力过大,也可以把解析逻辑放到后端,再把解析结果以JSON传给前端,pdf.js只负责渲染预览。这是一个很好的降级方案,后面我会提到。
2.3 worker 配置与版本锁定的问题
用pdf.js最常遇到的就是Worker配置问题。原因是pdf.js的解析逻辑放在Worker线程里,浏览器需要加载对应的pdf.worker.min.js文件。除了版本要完全一致之外,Vite、Webpack这类打包工具对Worker的处理策略也不一样,经常会出现“版本不匹配”的报错。我的做法是显式指定:
import * as pdfjsLib from 'pdfjs-dist' import pdfWorker from 'pdfjs-dist/build/pdf.worker.min?url' pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker关键点是?url后缀,打包后它会返回一个可访问的资源URL,而不是把Worker代码打进主包。如果直接使用CDN版本,需要确保当前pdfjs-dist的npm版本和CDN里的版本号完全一致,否则一些奇怪的低级报错会让人排查到怀疑人生。
3. 第一步:从 PDF 中完整提取书签目录树
3.1 读取文件并初始化 PDFDocument
无论用户是从文件选择器还是拖拽区域拿到PDF,第一步都是拿到File对象,然后把它转成ArrayBuffer喂给pdf.js。需要注意兼容性,现代浏览器都支持file.arrayBuffer(),如果项目还要兼容老浏览器,就得用FileReader。
async function loadPdfFromFile(file) { const arrayBuffer = await file.arrayBuffer() const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }) const pdf = await loadingTask.promise return pdf }getDocument返回的是一个PDFDocumentLoadingTask,loadingTask.promise返回PDFDocumentProxy。从这里就能拿到numPages、getOutline()等等方法。这里建议把文件大小限制在100MB以内,太大会导致ArrayBuffer直接吃掉几百MB内存,浏览器容易卡死。
3.2 getOutline 拉取书签树的返回结构
获取书签的方式很简单:
const outline = await pdf.getOutline()但outline返回的并不是一个扁平数组,而是一个嵌套结构。每个节点的核心字段是:
title:书签显示的文字,比如“第一章 绪论”。items:子书签数组,没有子节点就是空数组。dest:书签跳转目标,通常是一个数组,表示跳转到某页具体位置。url:如果书签指向外部链接,这个字段会存在。
看一个真实的结构:
[ { "title": "产品介绍", "dest": [{"num": 1, "gen": 0}, "XYZ", 0, 760, null], "items": [ { "title": "产品概述", "dest": [{"num": 2, "gen": 0}, "XYZ", 0, 500, null], "items": [] }, { "title": "核心功能", "dest": [{"num": 3, "gen": 0}, "XYZ", 0, 600, null], "items": [ { "title": "在线协作", "dest": [{"num": 4, "gen": 0}, "XYZ", 0, 720, null], "items": [] } ] } ] } ]注意items是嵌套的,所以层级关系需要递归处理。dest的第一个元素是一个页码引用对象,后面跟着的是定位模式,这一步我们暂时用不到,但后面如果要实现“点击目录跳转到PDF对应页”就很有用。
3.3 递归归一化:层级、空标题、特殊字符处理
拿到原始outline之后,不能直接拿来用。我见过不少PDF导出的书签里存在脏数据,常见问题包括标题前后有大量空白、包含换行符/制表符/不可见控制字符、标题为空但还有子节点等。所以需要一个归一化函数:
function normalizeOutline(items, depth = 0) { const result = [] items.forEach((item, index) => { const title = cleanTitle(item.title) if (!title) return const node = { id: `bookmark-${depth}-${index}-${Math.random().toString(36).slice(2, 8)}`, title, level: depth, children: [], } if (Array.isArray(item.items) && item.items.length > 0) { node.children = normalizeOutline(item.items, depth + 1) } result.push(node) }) return result } function cleanTitle(rawTitle) { if (typeof rawTitle !== 'string') return '' return rawTitle .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/\s+/g, ' ') .trim() }这个id是给后面做目录跳转、锚点定位用的。还有一点值得注意:item.title中可能带有编号,比如“1.2 系统架构”。如果原PDF的编号风格是层级式的,导入编辑器后保留编号也行,但要注意别和编辑器自动编号冲突。我的习惯是保留,因为对用户来说原文长什么样导入之后就长什么样,最不容易被抱怨。
3.4 没有书签时的兜底分支
有些PDF本身就没有书签,比如部分扫描件或简单导出的打印档。此时pdf.getOutline()返回null。对这种PDF强行解析是没有意义的,我的做法是弹出一个提示:“该PDF未包含书签目录,已导入纯文本内容”,然后把每个页面的文本拼起来,以普通段落形式导入。
let outline = await pdf.getOutline() if (!outline) { outline = [] }这样用户在编辑器里至少能看到内容,只是没有标题层级。如果想硬着头皮做“伪目录”,可以根据页面文本的字号大小来猜测标题级别,但这里面的误判率很高,我不推荐在正式功能里用这种策略,容易把一个两行正文判断成标题。
4. 第二步:把目录树映射成 wangEditor 的多级标题
4.1 HTML 字符串生成:层级映射规则
归一化之后,目录树就是一个带level字段的树形结构。接下来核心是把它转成HTML字符串。wangEditor对标准h1到h6标签的支持很成熟,所以这里做一个直接映射:
const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] function buildHeadingHtml(outline) { return outline.map(node => { const tag = HEADING_TAGS[Math.min(node.level, 5)] const title = escapeHtml(node.title) const childrenHtml = node.children.length ? buildHeadingHtml(node.children) : '' return `<${tag}>editor.setHtml(buildHeadingHtml(normalizedOutline))如果需要在现有文档基础上追加,我会先判断编辑器是否为空,不为空就用dangerouslyInsertHtml在光标处插入。有一点要提醒:dangerouslyInsertHtml的“dangerously”不是开玩笑,它会解析HTML字符串并转成Slate节点,如果HTML里包含不受支持的复杂结构,可能会有样式丢失的风险。所以务必保持生成的HTML结构简单,只用标准标题标签和少量>editor.disable() // 解析并导入 await importPdfToEditor(editor, file) editor.enable()
disable()会阻止内容编辑,但选区和滚动这些交互还是正常的。有朋友在评论区问过“wangEditor怎么设置只读”,所以在这里顺手着重点一句:除了调用editor.disable(),也可以通过配置editorConfig.readOnly来控制初始化时的只读状态,但运行时切换必须用disable/enable这对方法。
4.4 正文文本的可选导入
目录结构导入后,很多场景下用户还希望把正文内容也一并带上。pdf.js也提供了页面文本提取能力:
async function extractAllPageText(pdf) { let text = '' for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { const page = await pdf.getPage(pageNumber) const textContent = await page.getTextContent() const pageText = textContent.items .map(item => item.str) .join(' ') text += `<p>${escapeHtml(pageText.trim())}</p>` } return text }这里有个需要接受的事实:从PDF提取的文本是按页面渲染顺序排列的,比如多栏排版时会乱序,段落之间的关联也不可靠。所以正文导入更适合作为“可选的纯文本草图”,真正可靠的结构化信息还是书签树的标题层级。我在代码里会把正文放在目录标题的后面,形式上变成“标题+段落”的合格富文本文档,用户在此基础上手动修正润色,比从零开始建大纲要快得多。
5. 实际踩到的坑与排查过程
5.1 导入后标题层级高出可视化范围
第一次跑通的时候,我发现有些PDF书签有6层甚至7层结构,而h6已经是编辑器里最小的标题字号了,视觉上几乎看不出层级区别。我当时的处理方案不够好,直接把超出部分全部压成h3,结果整个目录层次感一团糟。
后来想明白了:不能简单一刀切。正确做法是给层级做“缩位映射”,类似把7层结构映射到最多5层:
function getHeadingTag(level, minLevel) { const mappedLevel = Math.max(0, Math.min(5, level - minLevel)) return HEADING_TAGS[mappedLevel] }minLevel是整棵树中根节点的最小level。比如一本书的书签第一层就是“封面”,那minLevel是0;有些PDF书签第一层是“1”,但它的子节点从第二层开始才有意义,这时把minLevel整体抬一级,让编辑器里的最高层级从h2开始,视觉上更平衡。
5.2 书签标题里的乱码和控制字符
测试样本里有一批用老式PDF工具生成的文件,书签标题里带着\r\n和奇怪的Unicode控制字符。第一次导入时我在编辑器里看到一堆空白行和乱码,还以为是pdf.js的问题。后来逐条打印item.title.charCodeAt()才发现,是源PDF本身的数据不干净。
清理逻辑我在前面已经写了cleanTitle(),这里再补充一个细节:有些标题包含全角空格和\u3000,也需要一并替换成普通空格。清理函数里最好加一句replace(/\u3000/g, ' '),否则在中文文档里会出现对不齐的半透明间隙。
5.3 超大PDF解析时页面冻结
用户上传过一个接近200MB的PDF,getDocument走完需要好几秒,期间主UI完全卡死。问题的瓶颈不是CPU,而是一次性把200MB数据读进ArrayBuffer占内存太多。现场排查后发现,pdf.js的getDocument本身就支持只传递url而不是data,让浏览器自己处理流式下载:
const loadingTask = pdfjsLib.getDocument({ url: '/api/pdf/123' })但用户上传的是本地文件,不可能先传后解析。我的折中方案是:第一步先读取文件基本信息(文件大小、页数)给用户一个进度提示,同时把大于50MB的文件提示“解析时间可能较长”。实际解析加载时,在异步函数里加一个loading状态,配合前面的editor.disable()阻止误操作,体验就会好很多。
5.4 undo 历史与导入内容的黏合问题
如果用户在编辑器里已经写了一部分内容,再用setHtml导入PDF,wangEditor的undo历史会被清空——用户一旦操作Ctrl+Z,不仅PDF内容没了,之前写的内容也可能回不去了。这是setHtml全量替换的天然副作用。
我的应对是:业务上明确“导入PDF”是一个新建文档的动作,在导入前弹出确认,让用户知道现有内容会被替换。如果需要保留旧内容,就用dangerouslyInsertHtml在光标处插入,不要用setHtml。另外,导入后最好把editor.getHtml()主动备份到草稿箱,防止用户误操作后无法恢复。
6. 进阶:在编辑器旁做一个可点击跳转的目录面板
6.1 从编辑器内容动态提取标题
导入PDF后,目录已经以h1~h6的形式存在于编辑器里。此时只要监听编辑器的内容变化,就能反向提取出一份侧边目录,类似Notion左侧的文档大纲:
editor.on('change', () => { const html = editor.getHtml() const container = document.createElement('div') container.innerHTML = html const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6') const toc = Array.from(headings).map((h, index) => ({ tag: h.tagName.toLowerCase(), text: h.textContent, id: h.getAttribute('data-bookmark-id') || `heading-${index}`, })) renderTocPanel(toc) })因为导入时已经给标题加了>let timer = null editor.on('change', () => { clearTimeout(timer) timer = setTimeout(buildToc, 300) })
这种“300毫秒防抖”的做法对用户来说感知不到延迟,又能避免输入过程中目录面板疯狂刷新的问题。个人经验是:功能上线后先在测试环境用一本200页的PDF跑一遍,导入耗时、内存占用、编辑器交互三个方面没有明显问题,再放给用户使用,会少接到很多抱怨。
如果你准备做类似的功能,我建议先把PDF样本集准备好,特别是“无书签”“书签层级特别深”“书签带中文乱码”这几类样本,因为在真实业务里会集中暴露问题。先跑通目录结构还原,再考虑正文文本导入,最终扩一个侧边栏目录,这套组合拳做完,功能就非常完整了。