ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

xhEditor集成PDF导入:高亮与注释还原完整方案

2026/9/10 1:27:10 拓冰建站 浏览量
xhEditor集成PDF导入:高亮与注释还原完整方案 接手这个需求之前我先说一下背景。我们团队做的是一个在线文档审阅系统编辑器一直用的是 xhEditor。这是款老牌的国产富文本编辑器虽然生态不如 TinyMCE 或者 CKEditor 那么庞大但胜在轻量、API 简单集成成本低表单场景下非常好用。最近业务方提了一个需求用户天天要往编辑器里贴 PDF 里的内容做批注审阅但直接复制 PDF 文本再粘贴排版全乱了高亮和注释也全丢了。于是就有了这个项目——让 xhEditor 支持 PDF 导入并且导入后能保留原文里的文本高亮和注释信息。这个需求看起来只是编辑器加个文件上传的活儿真正做起来才发现坑不少。PDF 的文本提取、高亮位置的坐标映射、注释的锚点定位、编辑器内同步高亮和气泡注释……每一层都有讲究。这篇文章我把完整方案和踩坑记录整理出来希望能帮到正在做同类功能的朋友。1. 需求场景与整体方案设计1.1 这个功能到底要解决什么先聊聊需求本身的业务背景。我们的目标场景是合同审阅和论文批改业务方经常收到一堆 PDF 格式的原始材料里面已经有人用 Adobe Acrobat 或者 Foxit 做过了高亮和批注。他们希望把这些内容导入到 xhEditor 之后编辑框里依然能清楚地看到原始文本内容不要乱码、不要丢排版PDF 里的高亮到了编辑器里依然是高亮PDF 里的注释批注到了编辑器里要能看得见、能点开、能编辑这个需求拆开看就是三件事文本还原、高亮还原、注释还原。难点不在导入本身而在还原。1.2 技术路线选型为什么不用纯组件渲染一开始团队里有人提出一个更省事的方案直接把 PDF 转成图片整页贴在编辑器里。这样视觉 100% 还原但业务方当场否决了——因为我们要的不是看图而是要编辑文字、引用原文、统计批注图片方案意味着所有文本都无法复制和编辑等于把编辑器变成了一个看图工具。还有一条路是用 PDF.js 在编辑器里嵌一个 iframe 渲染 PDF 预览但这样根本不算导入编辑器本质上只是借了个壳内容没有进入编辑器的 DOM后续的查找、替换、导出统统做不了。所以最后确定的方案是解析 PDF 结构提取文本和元数据转换成结构化的 HTML再注入 xhEditor 的可编辑区域。1.3 整体架构与数据流整个系统的数据流可以分成三段上传 PDF 到后端后端解析出页面文本、字号、位置坐标、高亮区域坐标、注释内容和锚点坐标。后端把解析结果渲染成一套伪排版的 HTML通过接口返回给前端。前端拿到 HTML 后注入 xhEditor同时注册自定义按钮和事件让高亮和注释在编辑器内可交互。这个设计把重活放在后端前端只做展示和交互。原因很简单浏览器端做 PDF 解析虽然可行但性能不稳定特别是大文件动辄几十页的 PDF 纯前端解析内存占用很难看。后端解析完直接给结构化数据前端的工作量小也方便未来扩展成批量导入。注意这个方案中伪排版的意思是我们不做像素级还原只保证段落、标题、列表等基础结构正确。对审阅场景来说内容可读、可编辑比视觉还原更重要。2. PDF解析与高亮注释提取2.1 解析层选型PDFBox还是pdf.js后端解析我们对比过两个主流工具Java 生态的 Apache PDFBox 和 Node 生态的 pdf.js也叫 pdf-lib 配合使用。我们的服务端是 Java所以最终选了 PDFBox。原因有几点PDFBox 对 PDF 文档结构访问能力强能拿到内容流、注释对象、渲染模式等底层信息高亮注释Highlight Annotation在 PDFBox 中是一个明确的 Annotation 子类型可以直接通过页面对象获取中文支持相对成熟配合字体替换能解决不少乱码问题pdf.js 本身非常适合浏览器端的文本层渲染但它是 JavaScript 生态后端强耦合 Java没必要为了解析去额外起一个 Node 服务。2.2 文本内容与坐标信息的提取PDFBox 提取文本并不难核心 API 就是PDFTextStripper。但默认的文本剥离器只给我们字符串不给坐标那就没法做高亮区域还原。所以我们必须自己写一个 TextStripper 子类重写processTextPosition(TextPosition text)方法把每个字符的 x、y、宽高信息都记录下来。代码核心结构是这样的public class PositionAwareTextStripper extends PDFTextStripper { private ListCharPosition charPositions new ArrayList(); public PositionAwareTextStripper() throws IOException { super(); } Override protected void processTextPosition(TextPosition text) { float x text.getXDirAdj(); float y text.getYDirAdj(); float width text.getWidthDirAdj(); float height text.getHeightDir(); String character text.getUnicode(); charPositions.add(new CharPosition(character, x, y, width, height)); super.processTextPosition(text); } public ListCharPosition getCharPositions() { return charPositions; } }这里要注意两个坐标细节PDF 坐标系的 y 轴是从页面底部向上增长的而我们最终在 HTML 里的 y 轴是从上往下增长。所以提取出来的 y 坐标必须做一次换算htmlY pageHeight - pdfY - charHeight。同一个视觉位置的字符可能来自不同的内容流或者不同的渲染命令所以拿到字符坐标数组之后还需要按行聚合。聚合规则简单粗暴y 坐标差值在某个阈值内通常取该页平均字符高度的三分之一的字符归为同一行然后按 x 坐标排序。2.3 高亮和注释的识别与还原PDF 中的高亮和注释本质上不是内容流的一部分它们是文档对象模型中的独立对象。PDFBox 获取方式很简单PDPage page document.getPage(pageIndex); ListPDAnnotation annotations page.getAnnotations(); for (PDAnnotation annotation : annotations) { if (annotation instanceof PDAnnotationMarkup) { PDAnnotationMarkup markup (PDAnnotationMarkup) annotation; // 高亮是 PDAnnotationMarkup 的 subtype Highlight String subtype markup.getSubtype(); if (Highlight.equals(subtype)) { PDAnnotationHighlight highlight (PDAnnotationHighlight) markup; PDRectangle rect highlight.getRectangle(); String title markup.getTitlePopup(); String contents markup.getContents(); // rect 就标记了高亮区域contents 是注释内容 } } }重点来了getRectangle()返回的是一个边界矩形但 PDF 高亮不是矩形框是一段不规则的色带。如果直接把 rect 转成一个矩形 HTML 节点你会发现高亮区域把行间的空白也覆盖了不同行还会混在一起。正确的做法是拿到高亮的四边形数据。PDFBox 低版本没有直接暴露高亮顶点但可以通过PDAnnotationTextMarkup的getQuadPoints()拿到。每个高亮区域由多个四边形组成每个四边形是四个点。我这边从 quadPoints 中还原出每一行的高亮范围再根据 2.2 中记录的字符坐标判断哪些字符落入了高亮区域把这些字符包裹进一个高亮节点。判定是否落入高亮区域的方法很简单字符的中心点坐标落在四边形范围内就认为这个字符属于高亮区域。对交叠部分的字符取覆盖率更高的那个高亮归属。2.4 扫描版PDF的兜底方案不是所有 PDF 都有文本层扫描件就是纯图片。如果碰到这种情况后端解析得到的文本是空的我们的方案会自动降级调用 OCR 服务做识别。OCR 这里不展开讲方案但有几个经验值得分享OCR 的识别结果自带矩形坐标正好可以复用我们已有的 HTML 映射逻辑中文扫描件建议用 PaddleOCR英文和混合文本用 Tesseract 也行高亮区域的识别在扫描版里特别头疼因为高亮是画在图片上的颜色特征明显可以通过像素分析提出高亮色块的范围注意OCR 属于兜底方案识别率和排版还原度都远不如有文本层的 PDF。正式上线时我们在前端提示用户当前文件为扫描件文本由 OCR 识别生成可能存在误差。3. xhEditor 插件开发与编辑器内交互3.1 xhEditor 的插件扩展机制xhEditor 的插件机制不像现代前端框架那么复杂本质上是往工具栏里注册按钮并为每个按钮绑定一个执行函数。它官方文档里提供的插件扩展方式是这样的XHEDITOR.addPlugin({ name: pdfimport, lang: { zh-cn: { common: { edit: PDF导入 } } }, init: function(editor) { editor.addButton(pdfimport, { title: PDF导入, icon: pdfimport, click: function() { openPdfImportDialog(editor); } }); } });这样一个插件在 xhEditor 工具栏上就会多出一个 PDF导入 按钮。剩下的交互逻辑我们全部写在openPdfImportDialog里包括文件上传、导入预览、确认插入等。3.2 导入内容的结构化处理后端返回的 HTML 不是简单地把所有文本用p包裹就完事。因为 PDF 里有很多分段和区块我们需要在生成的 HTML 结构上做文章让 xhEditor 的编辑体验更接近原生文档而不是一堆不可编辑的文本行。我这边采用的结构规则是每个逻辑段落生成一个p标签标题语段生成h2、h3标签通过字号大小判断高亮文本用mark标签包裹注释用自定义数据属性挂在对应节点上比如一段被高亮且带有注释的文本最终生成的 HTML 大致是这个样子p 本合同自 mark>function applyHighlight(editor) { const iframeDoc editor.getDoc(); const selection iframeDoc.getSelection(); if (!selection.rangeCount || selection.isCollapsed) return; const range selection.getRangeAt(0); const mark iframeDoc.createElement(mark); try { range.surroundContents(mark); } catch (e) { // 选区跨多个节点时 surroundContents 会抛异常 // 兜底方案把范围里的内容先提取出来再包进 mark const fragment range.extractContents(); mark.appendChild(fragment); range.insertNode(mark); } selection.removeAllRanges(); }这里特别要注意一个问题range.surroundContents要求选区边界不能在一个元素的中间否则会抛Invalid state异常。Google Chrome 给用户选择文本时几乎都是以字符为边界不会精确到 DOM 节点的边界所以一旦文本跨了两个p或一行中间有mark就会出问题。上面代码里的 try-catch 就是干这个用的。3.4 注释气泡的设计与实现注释的交互我们参考了 PDF 阅读器里的批注模式正文里有一个锚点标记点击后弹出气泡展示注释内容气泡里可以编辑文字。这个实现依赖三部分第一锚点标记。我们用的是[批注]这个文本标记渲染成一个带下划线的 span本身显示为可点击状态。用户点击后触发气泡展示。第二气泡弹窗。由于 xhEditor 的内容区是在 iframe 里弹窗必须挂到 iframe 的 document 上否则会被编辑器的 CSS 遮挡或者出现定位错乱。我们写了一个简单的绝对定位气泡function showNoteBubble(editor, noteId, x, y, content) { const iframeDoc editor.getDoc(); const bubble iframeDoc.createElement(div); bubble.className note-bubble; bubble.style.position absolute; bubble.style.left x px; bubble.style.top y px; bubble.contentEditable true; bubble.innerText content; iframeDoc.body.appendChild(bubble); }第三数据绑定。注释内容最终需要保存在编辑器内容里。我的做法是把注释内容直接序列化到>function extractNotes(htmlString) { const dom new DOMParser().parseFromString(htmlString, text/html); const notes []; dom.querySelectorAll([data-note-id]).forEach(el { const id el.getAttribute(data-note-id); if (!notes.find(n n.id id)) { notes.push({ id: id, content: el.getAttribute(data-note-content) || , highlight: el.tagName MARK }); } }); return notes; }这份 JSON 和 HTML 分开存储。以后重新打开编辑页面时根据 JSON 给编辑器重新注入注释气泡位置信息通过 id 查找锚点恢复。4. 实操过程与关键代码实现4.1 后端解析接口的核心代码后端我们提供了一个简单的上传接口接收 PDF 文件返回解析后的 HTML 结构。为了展示方便我整理了一个简化版示例PostMapping(/api/pdf/import) public MapString, Object importPdf(RequestParam(file) MultipartFile file) throws IOException { byte[] bytes file.getBytes(); MapString, Object result new HashMap(); try (PDDocument document PDDocument.load(bytes)) { PDFToHtmlConverter converter new PDFToHtmlConverter(); String html converter.convert(document); result.put(html, html); result.put(pageCount, document.getNumberOfPages()); } catch (Exception e) { result.put(error, e.getMessage()); return result; } return result; }我这里把 PDFToHtmlConverter 封装成了一个独立的转换类里面做三件事遍历每一页提取字符坐标、读取每页的注释对象、组合生成 HTML 字符串。这个类的内部实现是纯 Java 逻辑没有额外依赖。4.2 前端导入流程与编辑器内容注入前端导入流程是这样的用户点击工具栏上的 PDF导入 按钮弹出一个文件选择框文件选择后立即上传同时显示一个 loading 状态上传成功拿到 HTML 字符串后确认是否替换当前编辑器内容还是追加到末尾确认后把 HTML 插入编辑器核心代码function openPdfImportDialog(editor) { const input document.createElement(input); input.type file; input.accept application/pdf; input.onchange function() { const file input.files[0]; if (!file) return; uploadPdf(file, (res) { if (res.html) { if (confirm(导入PDF内容将替换当前编辑器内容是否继续)) { editor.setData(res.html); } } else { alert(解析失败 res.error); } }); }; input.click(); }实际项目里我还加了一个追加模式不替换整个编辑器而是把 HTML 内容 append 到当前编辑内容的末尾。实现方式是从 xhEditor 的 iframe 里拿到 body 节点然后把内容解析成 DOM 节点逐个挂上去。这里注意不要用 innerHTML 直接拼接否则会破坏 xhEditor 内部的撤销栈。4.3 高亮注释交互的完整实现这一节我给出一个完整的、可以跑通的高亮注释交互流程。整个交互涉及三个操作添加注释、查看注释、删除注释。添加注释的操作流程是这样的用户在编辑器中选择一段文本弹出的工具条上点击添加注释弹窗输入注释内容保存后选中的文本被mark包裹并带上>