接手这个需求之前,我先说一下背景。我们团队做的是一个在线文档审阅系统,编辑器一直用的是 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 List<CharPosition> 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 List<CharPosition> getCharPositions() { return charPositions; } }这里要注意两个坐标细节:
- PDF 坐标系的 y 轴是从页面底部向上增长的,而我们最终在 HTML 里的 y 轴是从上往下增长。所以提取出来的 y 坐标必须做一次换算:
htmlY = pageHeight - pdfY - charHeight。 - 同一个视觉位置的字符,可能来自不同的内容流或者不同的渲染命令,所以拿到字符坐标数组之后,还需要按行聚合。聚合规则简单粗暴:y 坐标差值在某个阈值内(通常取该页平均字符高度的三分之一)的字符归为同一行,然后按 x 坐标排序。
2.3 高亮和注释的识别与还原
PDF 中的高亮和注释本质上不是内容流的一部分,它们是文档对象模型中的独立对象。PDFBox 获取方式很简单:
PDPage page = document.getPage(pageIndex); List<PDAnnotation> 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 Map<String, Object> importPdf(@RequestParam("file") MultipartFile file) throws IOException { byte[] bytes = file.getBytes(); Map<String, 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>包裹并带上>
AI论文工具实测:7款软件全流程跑分与写作闭环选型指南
1. 毕业季实测:为什么我把市面上叫得上号的 AI 论文工具全跑了一遍 上个月学弟来找我时,我正在帮另一位朋友改硕士论文的致谢段落,改到第三版还是被答辩秘书挑毛病。他苦笑着说,现在连致谢这种固定套路的文字都写不顺,…
libcurl 自定义 DNS 服务器:CURLOPT_DNS_SERVERS 完整指南
libcurl 自定义 DNS 服务器:CURLOPT_DNS_SERVERS 完整指南 【免费下载链接】curl A command line tool and library for transferring data with URL syntax, supporting DICT, FILE, FTP, FTPS, GOPHER, GOPHERS, HTTP, HTTPS, IMAP, IMAPS, LDAP, LDAPS, MQTT, MQ…
Fabric `check_agreement` 模式实战:用 AI 对合同与协议进行结构化风险审查
Fabric check_agreement 模式实战:用 AI 对合同与协议进行结构化风险审查 【免费下载链接】Fabric Fabric is an open-source framework for augmenting humans using AI. It provides a modular system for solving specific problems using a crowdsourced set of…
前端框架为何弃用Class?函数组件与Hooks的底层逻辑
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
AQ1100高通量靶标定量系统:从样品到结果的自动化流水线
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
Backstage 软件目录(Software Catalog)REST API 完全指南:实体与 Location 接口详解
Backstage 软件目录(Software Catalog)REST API 完全指南:实体与 Location 接口详解 【免费下载链接】backstage Backstage is an open framework for building developer portals 项目地址: https://gitcode.com/GitHub_Trending/ba/backs…