这类需求我在业务里接过不止一次:用户上传一份PDF,希望在线上文档编辑器里直接打开,还要能圈出重点、在旁边写几句批注,提交后高亮和注释都能保存下来。标题虽然写的是“xhEditor PDF导入支持文本高亮和注释”,但把“PDF导入”“文本高亮”“注释”三个点拆开,每一块都有不少隐藏的坑。
先把结论放前面:如果只是把PDF渲染成canvas图片塞进编辑器,等于在PPT里贴了一页纸,看起来是那么回事,但文字没法选中,标注没法落库,一刷新全丢;真正能做的是用PDF解析库取出里面的文本内容,再转成可编辑的HTML,让高亮与注释以DOM标签的形态和正文一起保存。这套方案不止xhEditor能用,换成UEditor、wangEditor甚至自己写的contenteditable都成立。
下面按我实际落地的思路,把这套链路完整写一遍。
1. 先拆需求:PDF、富文本、批注这三样东西要怎么合体
接到“PDF导入编辑器,支持文本高亮和注释”这类需求时,最忌讳的是直接开始写代码。先要把交付物定义清楚:到底是要“像在PDF阅读器里一样批注原PDF”,还是“把PDF的文字变成编辑器里的正文,再对正文做标注”。
这两者的技术路线完全不同。前者是典型的PDF预览批注系统,需要以页面为底图,在上面叠加高亮矩形、注释图标,保存的是坐标和文本片段;后者是富文本编辑器能力扩展,核心在于把PDF里的文本正确提取出来,保持可编辑性,高亮和注释都只是对文本节点做包装。
我采用的路线是后者,原因是多数后台系统的诉求其实是“文档内容入库、审核、二次编辑”,而不是做一份可以回写的PDF审阅文件。两种方案的取舍如下:
| 方案 | 可编辑性 | 数据保存 | 版式还原 | 开发复杂度 |
|---|---|---|---|---|
| PDF整页转图片插入 | 差,仅能看图 | 差,保存的是图片 | 高 | 低 |
| PDF.js渲染+坐标浮层批注 | 差,批注与正文不联动 | 一般,需要存坐标JSON | 高 | 高 |
| PDF解析成富文本并做标签标注 | 好,文字本身就是HTML | 好,高亮注释随HTML存储 | 中低 | 中 |
从业务可维护性角度看,第三种最划算:xhEditor初始化时绑定的是textarea,textarea的值就是一段HTML。我把带高亮标签的正文写进textarea,用户点保存,高亮和注释天然跟着内容走,不需要额外的关联表,也没那么容易出现“正文改了,批注坐标还指在原来的位置上”这种尴尬问题。
这个方案也有它的边界:它不适合要严格保留原始PDF排版的场景,比如多栏杂志、复杂表单、图文混排的设计稿。单栏的合同、论文、报告、通知类PDF是它的主场,由于导入后正文会变成可编辑文本,原版的字体、缩进、表格边框大概率是保留不住,这一点要在文档里向用户说清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PDF文本提取:从pdf.js到一份不丢字的可编辑正文
2.1 搭好pdf.js的运行前提
PDF文本提取我选择pdf.js,它对中文的兼容性比mupdf.js要稳,作者维护也勤快。前端用的时候别只引入主库,worker和CMap目录这两样经常被忽略,而恰好是中文PDF不乱码的关键。
javascript复制import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.cMapUrl = '/vendor/pdfjs/cmaps/';
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
cMapUrl指向的路径是pdf.js发布包里自带的cmaps目录,需要把它复制到静态资源目录下。没有配置CMap时,部分中文PDF会提取出一堆乱码或空白,因为PDF的内部编码需要CMap表来做映射。
选择PDF文件后,用file对象的arrayBuffer取得二进制数据:
javascript复制async function loadPdfFromFile(file) {
const buffer = await file.arrayBuffer();
const task = pdfjsLib.getDocument({ data: buffer });
return task.promise;
}
2.2 按页提取文本并拼装成HTML
拿到pdf对象后,遍历每一页调用getTextContent()。这个方法返回的items数组里,每个item的str字段就是该文本片段的内容。问题在于items的顺序不一定是标准的阅读顺序,直接join(''),日志里一页的字并不是从左上读到右下,因为PDF在存储文本对象时经常按内部绘制顺序排列,甚至可能先输出右栏再输出左栏,我实测单栏公文还好,双栏或表格类页面就会串。
我的处理方式是按文本坐标排序后再拼接。getTextContent返回的每一项带transform数组,其中的transform[4]和transform[5]是文本元素在页面坐标系中的x、y位置。常见的PDF坐标系origin在左下角,而css坐标的y方向是向下的,但pdf.js的viewport.transform会把坐标换算成适合CSS使用的像素坐标。
javascript复制function textItemsToLines(items, viewport) {
const normalized = items
.filter((item) => item.str && item.str.trim())
.map((item) => {
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
const fontSize = Math.hypot(tx[2], tx[3]);
return {
text: item.str,
x: tx[4],
y: tx[5],
height: fontSize,
};
});
// 优先按y从大到小分段,再在段内按x从小到大排序
normalized.sort((a, b) => {
if (Math.abs(a.y - b.y) > Math.max(a.height, b.height) * 0.6) {
return b.y - a.y;
}
return a.x - b.x;
});
let html = '';
let lastY = null;
for (const item of normalized) {
if (lastY !== null && Math.abs(item.y - lastY) > item.height * 0.6) {
html += '\n';
}
html += item.text;
lastY = item.y;
}
return html;
}
这个函数按行的维度对文本做了粗略重组:同一行内的文字按x坐标从左到右排列,行与行之间插入换行。后面生成HTML时,把换行替换成<p>分段或<br>。
页面文本生成HTML时要注意两点:一是对文本做HTML转义,PDF里可能出现“<”“>”“&”这类字符,直接拼进innerHTML会破坏标签结构;二是给每一页包一个带data-page属性的容器,这样高亮和注释将来可以追溯来源页。
javascript复制async function pdfToHtml(pdf) {
let html = '';
for (let pageNo = 1; pageNo <= pdf.numPages; pageNo++) {
const page = await pdf.getPage(pageNo);
const viewport = page.getViewport({ scale: 2 });
const textContent = await page.getTextContent();
const pageText = textItemsToLines(textContent.items, viewport);
const pageHtml = escapeHtml(pageText)
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => `<p>${line}</p>`)
.join('');
html += `<section class="pdf-import-page" data-page="${pageNo}">${pageHtml}</section>`;
}
return html;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
实际用的时候,将生成的HTML插入xhEditor编辑区域,可以用它暴露的api,也可以直接向textarea里写入再重新初始化编辑器。插入前建议加一个空行做分隔,避免PDF正文和这段正文原来已有的内容粘连。
2.3 为什么我建议放大scale到2再提取
有经验的开发者会注意到上面代码里getViewport({ scale: 2 }),这个2不是随意写的。PDF内部坐标是点,1点等于1/72英寸,屏幕显示时如果scale=1,很多间距比较紧凑的中文行会被当成同一行。放大到2后,文本元素之间的y坐标差值会被拉大,行分类的准确性会高很多。
提取文本用的viewport和最终展示的HTML没有直接关系,因为一旦进了富文本编辑器,版式完全由HTML和CSS决定。放大scale只是为了帮助分页分行更准确,但这个技巧估计很多人没注意,单独提出来说明一下。
3. 富文本里的高亮实现:选中、包mark、保留选区
3.1 高亮的本质是给Range包一层mark
PDF被转成HTML并插入编辑器后,高亮就变成了一个纯粹的DOM操作:用户用鼠标选中一段文字,点击“高亮”按钮,代码把选中的内容包进一个<mark>标签。这个思路非常简单,但实现时很容易被selection丢失这个坑绊倒。
xhEditor这类富文本组件本质上是在一个iframe的document里做编辑,工具栏按钮通常在父页面。用户选完文字后,鼠标要移出iframe去点击按钮,浏览器默认的点击行为会让iframe失去焦点,等按钮的click事件触发时,window.getSelection()已经为空,选区没了。
我的处理方式是给“高亮”按钮绑定mousedown事件,并在事件里调用event.preventDefault()。这样可以阻止默认的焦点转移,保住iframe里的选区,然后在click回调里通过编辑器实例拿到iframe的window和document,再操作选区。
javascript复制function highlightCurrentSelection(win, doc) {
const sel = win.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const mark = doc.createElement('mark');
mark.className = 'pdf-highlight';
try {
// 如果选区只跨文本节点,surroundContents最省事
range.surroundContents(mark);
} catch (e) {
// 跨块级元素时,改成extractContents再插入
const fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
}
const highlightId = 'hl-' + Date.now() + '-' + Math.random().toString(16).slice(2);
mark.setAttribute('data-hl-id', highlightId);
sel.removeAllRanges();
return mark;
}
range.surroundContents在选区跨多个块级节点时会抛异常,所以要有fallback分支。fallback里的逻辑也不复杂:把range里的内容抽成一个fragment,塞进新的mark元素,再把mark插回去。
这里还要警惕的一个情况是重复高亮时出现mark嵌套mark。用户在已经高亮的文字里再选一段子集再点高亮,就会产生嵌套。嵌套会让采集注释时逻辑变复杂,我选择了在每次高亮前先做一次“清理选区边界”的处理:如果range边界落在mark标签内部,就先把外层mark的边界位置换成文本节点的边界。对于一般场景,更简单的做法是在插入新的mark时用closest方法检查选中区域内是否已经有.pdf-highlight,如果有,直接复用那个mark或提示用户“该内容已被高亮”,实践下来够用。
3.2 注释怎么和mark绑定
注释的交互细节有很多讲究,可以根据使用场景选:
- 轻量场景:把注释文本存在mark元素的
data-note属性里,鼠标悬停时用title展示。 - 阅读标记场景:在mark文字后面加
<sup>角标,注释量少时像论文脚注。 - 审核场景:在编辑器旁边放一个批注列表,每生成一个高亮就往列表里插入一条记录,用户直接在列表里编辑。
我在做合同审核类的需求时用的是mark之后插sup的方式。给mark加一个<sup>,里面默认显示序号,点击后弹出一个可拖拽的小浮层填写注释。
javascript复制function addNoteToMark(mark, noteText) {
const existing = mark.querySelector('sup.note-indicator');
if (existing) {
existing.title = noteText;
} else {
const sup = doc.createElement('sup');
sup.className = 'note-indicator';
sup.textContent = mark.dataset.noteIndex || '①';
sup.title = noteText;
mark.appendChild(sup);
}
mark.setAttribute('data-note', noteText);
}
用户如果有编辑权限,这个sup
