项目交付前一周,需求文档里多出一行:要用 wangEditor 做 Word 导入,并且导入结果必须保留批注和修订记录。看到这句话时第一反应是改期,但既然需求都定下来了,就只能硬着头皮拆。真正动手后才发现,这个需求最坑的地方不在“Word 转 HTML”,而在“批注和修订是有锚点的、是跨环节存在的”,只要中间任何一步丢一次,后期想再找回来就非常被动。
这篇文章把我这次“wangEditor word导入支持批注和修订记录”的完整实现过程整理出来,内容包括 docx 底层结构分析、解析方案选型、编辑器渲染策略、Django 后端配合,以及 WPS、公式、图片等特殊场景的翻车记录。适合两类读者:一类是正在做富文本编辑器文档导入功能的开发,另一类是产品经理把需求写成“保留批注”但完全没想清楚批注到底该怎么交互的团队。
1. 项目需求里说的“批注支持”,到底要支持到什么程度
需求方说“导入 Word 支持批注和修订记录”时,双方理解的往往不是同一个东西。这节先说清楚需求边界,否则后面功能做完了也可能被判定为“不符合预期”。
1.1 批注不能只是“看得到”,还要能定位回原文
批注(Comment)的典型使用方式是用户选中一段文字,在旁边写意见,这选中的一段文字就是批注的锚区。如果只是把批注文本拼到一个列表里,用户不知道这条批注在批评哪一段文字,那这个功能基本等于没做。
所以实现分成了两层:
- 批注内容层:包括评论人、评论时间、评论正文。
- 批注锚点层:批注所框选范围在正文中的起点和终点。
锚点层是最容易被忽略、也最容易出问题的。Word 文档里的批注可以跨段落,也可以跨表格单元格,还可以锚定一段在修订中被删除的文字。解析时如果只按“段落”处理,或者只按“找到一个标记就算是命中了”处理,都会导致锚点错位。
1.2 修订记录不是把内容显示出来,而是要把“变更语义”保留
修订记录(Track Changes / Revision)和批注完全是两码事。批注是附加在原文上的评论,而修订是文档本身变更的痕迹,常见有四类:
- 插入(Insertion):新增的内容。
- 删除(Deletion):被删除掉的内容,Word 里通常用删除线表示。
- 格式变更(Format Change):文字还在,但字体、加粗、颜色等被改了。
- 移动(Move):一段文字从一个位置改到另一个位置。
需求文档没有明确说修订“需要支持到什么程度”,我只能按最稳妥的方式处理:导入后至少能看出哪些内容是后来插入的、哪些是被删除且处于“待接受/待拒绝”状态的。实际上,Word 导出为 HTML 时,这类语义经常会被拍扁成普通文本,所以必须走底层解析。
1.3 和产品对齐验收口径
在动工前我列了一个验收口径表,发给产品和测试,双方签字认可。
| 功能模块 | 验收要求 | 不包含范围 |
|---|---|---|
| 批注显示 | 高亮批注锚区,可查看批注者、时间、正文 | 不要求直接在编辑器里新增、回复批注 |
| 修订插入 | 插入的文本带明显底色,可识别插入作者 | 不要求接受/拒绝修订操作 |
| 修订删除 | 原文删除部分用删除线显示,不直接消失 | 不要求点击后找回删除内容 |
| 公式 | Word 原生公式能识别为占位块或基础公式 | 不要求复杂公式可反向编辑 |
| Excel批注文件 | 不处理 .xlsx 文件里的批注 | 不纳入本次 Word 导入范围 |
这个表格看起来简单,但它救了整个项目。因为产品后来果然问“能不能顺便支持 Excel 批注导入”,我直接把表格拿出来说“这是当时对齐后定的边界,Excel 批注和 Word 批注不是同一条链路”。如果没有这一段,需求会无限蔓延。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先拆 docx:批注和修订在 OOXML 文件里到底躺在哪一层
要实现对批注和修订的完整解析,首先要能回答这个问题:批注文字和修订标记分别存在什么位置?用的什么样的 XML Node?
2.1 批注本体在 word/comments.xml,锚点标在 word/document.xml
一个 .docx 文件本质上是一个 ZIP 压缩包。解压后能看到 word/ 目录下有 document.xml、styles.xml、comments.xml、settings.xml 等文件,批注正文并不写在 document.xml,而是写在独立的 comments.xml 里。
comments.xml 中每一条批注大致是这样的结构:
xml复制<w:comment w:id="0" w:author="张工" w:date="2024-06-01T10:00:00Z">
<w:p>
<w:r>
<w:t>这里的口径需要再确认一下</w:t>
</w:r>
</w:p>
</w:comment>
document.xml 正文中并不会直接把这条批注内容堆进来,它只负责标记“哪一段文字被这条批注覆盖”。用到的节点有:
w:commentRangeStart:批注范围起点。w:commentRangeEnd:批注范围终点。w:commentReference:紧跟在范围终点后的批注引用标记。
结构大致如下:
xml复制<w:p>
<w:r><w:t>这段合同条款</w:t></w:r>
<w:commentRangeStart w:id="0"/>
<w:r><w:t>需要按新版本执行</w:t></w:r>
<w:commentRangeEnd w:id="0"/>
<w:r>
<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>
<w:commentReference w:id="0"/>
</w:r>
</w:p>
批注范围可以横跨多个段落,起点在第一段,终点在第二段,甚至起点在一个表格单元格里,终点出到表格外。这种情况在真实合同评审文档里极其常见。
2.2 修订记录直接散落在正文流里,侵入性更强
修订记录和批注不一样,它不放在独立文件里,而是直接作为 document.xml 中的正文节点存在。读取正文时,遇到以下节点要特殊处理:
w:ins:插入内容,内部通常嵌套一个或多个w:r。w:del:删除内容,内部嵌套w:r,但其中文本用w:delText节点承载。w:rPrChange:记录文字格式的变更,例如从普通字改成加粗。
xml复制<w:ins w:id="200" w:author="李老师" w:date="2024-06-02T14:30:00Z">
<w:r><w:t>补充验收条件</w:t></w:r>
</w:ins>
xml复制<w:del w:id="201" w:author="李老师" w:date="2024-06-02T14:35:00Z">
<w:r><w:delText>旧验收条件</w:delText></w:r>
</w:del>
如果没识别到 w:delText,正常解析器会把它当成普通文本恢复出来,导致“已删除内容”重新进入正文,这是修订记录导入最基本也是最严重的错误。
2.3 修订和批注还能互相嵌套
真正把解析复杂度拉高的场景是“修订中带批注”以及“批注里嵌套修订”。
例如,一段被删除的文字上挂着批注,批注引用节点在 w:del 内部。按正文顺序遍历时,先看到删除节点,再在删除节点内部看到 commentRangeStart,这意味着处理完删除标记后,还需要继续把删除范围内的批注识别出来,并在渲染时告诉用户“这段删除内容下有一条未处理批注”。
如果代码按顺序线性处理、不允许递归,这类文档拿过来基本会错乱。后面工具解析部分我专门用了递归遍历 XML 的方式解决,不能简单用正则匹配。
3. Word 解析不是一条路走到黑,关键选择在“哪一层做”
明确了障碍之后,就得选技术方案。这个项目可选路线有三条,实际执行时能明显感觉到性能和准确率之间的拉扯。
3.1 路线一:浏览器端直接转 HTML,再用编辑器 API 塞进去
这种方案最常见,绝大多数 Word 导入需求也都是这么做的。浏览器端用 mammoth.js 或 docx-preview 将用户上传的 Word 文件解析成 HTML,再做样式清洗,最终插入 wangEditor 内容区。
优点是链路短、实时预览效果好,不需要后端参与,部署环境改动小。缺点也非常直接:这些工具只会做文档内容还原,对批注和修订记录的保留程度参差不齐,尤其是删除修订、还原格式变更、跨段批注,很难开箱即用。
所以在方案一基础上我加了一层自定义 XML 解析模块,专门处理普通转 HTML 丢失的信息。批注正文从 comments.xml 里抽出来,修订和锚点从 document.xml 里单独遍历,最后再和 mammoth 生成的 HTML 做一次合并。
3.2 路线二:后端解析,输出结构化 JSON
把完整 docx 上传到后端,再用 python-docx 之类的库做一次深度解析,生成 JSON 数据交给前端渲染。好处是解析逻辑和前端框架解耦,能复用后端能力做脱敏和格式校验;缺点是 Word 的复杂排版最容易在 JSON 序列化和反序列化过程中失真,而且把锚点、修订这类“游标性质”的数据改成 JSON,很容易丢失顺序关系。
如果团队后端能力很强、前端解析能力弱,这条路可以走,但批注锚点的实时定位成本很高。最终我没有选择后端全解析,而是把后端定位为“上传、存储、格式试探、安全过滤”的角色,真正的文本语义解析放在浏览器端做。
3.3 路线三:桌面端/WPS/Office 宏去提取批注
第三种路线是使用 WPS JS 宏或者 Office COM 从 Word 文件里直接读取批注和修订记录。这种方式适合一次性批量处理,比如校验模板、导出批注清单,不适合同时存在几百个用户在网页端上传的场景。
这个项目里 WPS JS 宏没有进主链路,但后来被我用在了测试环节,用来做批注导出比对。当文档在 WPS 里保存过、又回到网页导入时,JS 宏可以快速帮我看清楚真实批注内容是否和目标一致。
最终确定的技术处理链路是这样的:
text复制用户上传 .docx
↓
前端 JSZip 解压
↓
定位 word/document.xml、word/comments.xml、word/settings.xml
↓
基础正文转换:mammoth.js 出 HTML
↓
自定义解析:由 XML 节点提取批注锚点、修订记录
↓
HTML 文本流中插入“批注标记”和“修订样式”
↓
注入 wangEditor 并渲染
这是我验证下来准确率最高的组合方式,既不用把完整底层图景重新造一遍轮子,也不会因为只依赖通用转 HTML 工具而丢失关键语义。
4. 解析模块:从 document.xml 中把批注和修订完整打捞出来
进入代码实现阶段。这里给出的是我实际使用并精简过的主要流程,不是文档里那种只说明原理的示例。
4.1 用 JSZip 解包,先把 XML 文件“请”出来
浏览器端拿到的是用户上传的 Blob/ArrayBuffer,首选 JSZip 解包。这一步没有太多技术含量,但有两个坑:一是 mac 上文件的路径大小写问题,二是有些工具生成的 docx 里根本没有 comments.xml。
javascript复制import JSZip from 'jszip';
async function openDocx(buffer) {
const zip = await JSZip.loadAsync(buffer);
const docXml = zip.file('word/document.xml');
const commentsXml = zip.file('word/comments.xml');
if (!docXml) {
throw new Error('文件格式不正确或文件已经损坏');
}
const documentXmlText = await docXml.async('string');
const commentsXmlText = commentsXml
? await commentsXml.async('string')
: null;
return {
documentXmlText,
commentsXmlText,
};
}
有些定制版 Word 模板会带有 word/commentsExtended.xml 和 word/commentsIds.xml,这不是现代 Word 默认产物,但遇到时必须忽略而不要抛异常。
4.2 解析批注表与批注锚点
批注正文解析可以通过 DOM 遍历一次性把 comments.xml 里的数据拉出来。这里需要区分“正文文本”和“嵌套修订”,我只保留批注正文的纯文本,因为网页端是轻量展示,不需要把批注里再套的删除线展示出来。
javascript复制function parseComments(commentsXmlText) {
if (!commentsXmlText) return new Map();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(commentsXmlText, 'text/xml');
const commentNodes = xmlDoc.getElementsByTagName('w:comment');
const result = new Map();
for (let i = 0; i < commentNodes.length; i++) {
const node = commentNodes[i];
const id = node.getAttribute('w:id');
const author = node.getAttribute('w:author') || '未知用户';
const date = node.getAttribute('w:date') || '';
const text = node.textContent.trim();
result.set(id, { id, author, date, text });
}
return result;
}
生产环境建议用 XML 解析器遍历子节点而不是直接 textContent,因为 w:t 之间的空格和换行需要保留,后面的业务需要按文档原有格式渲染,不能把中文文档里的换行直接吞掉。
4.3 修订与批注锚点统一排序
document.xml 里的修订节点和批注锚点是“离散节点”,不是按段落整齐排列的。简单遍历可能会发现 commentRangeStart 在一个 box 容器里,而对应 commentRangeEnd 在后面的正文段里。
我的策略是从文档根节点开始做一次“带状态的深度优先遍历”,维护变量 lastCommentId,每遇到一个注释、修订节点就生成一个事件对象。
javascript复制const events = [];
function walk(node, events) {
for (let child of node.children) {
const tagName = child.tagName;
if (tagName === 'w:commentRangeStart') {
events.push({
type: 'commentStart',
commentId: child.getAttribute('w:id'),
});
} else if (tagName === 'w:commentRangeEnd') {
events.push({
type: 'commentEnd',
commentId: child.getAttribute('w:id'),
});
} else if (tagName === 'w:ins') {
events.push({
type: 'insertion',
author: child.getAttribute('w:author') || '',
date: child.getAttribute('w:date') || '',
text: getInsertText(child),
});
} else if (tagName === 'w:del') {
events.push({
type: 'deletion',
author: child.getAttribute('w:author') || '',
date: child.getAttribute('w:date') || '',
text: getDeleteText(child),
});
}
walk(child, events);
}
}
把所有事件收集到数组后,再按 document XML 中的文档原始顺序统一渲染。这一条核心经验必须反复强调:批注和修订的处理顺序要严格按照 XML 遍历序,不能按批注 ID 排序,否则删除与插入混排时会呈现完全不同的阅读含义。
4.4 删除文本与插入文本的合并策略
常规 Word 修订记录中,用户先删除一段文字,再在同一位置插入新文字,XML 中通常表现为相邻的一个 w:del 和一个 w:ins。从可读性讲,HTML 输出结果应该是:
html复制<p>
<span class="delete">旧内容</span>
<span class="insert">新内容</span>
</p>
实现时要注意:Word 有时把删除和插入拆成多个小片段,例如“第1页”和“的说明”可能是两个不同的删除节点,中间还夹着普通文本节点。如果渲染成一个大的删除段落,阅读效果会很差。
我采用的方案是把相邻、且作者相同的删除节点合并为一个 del 块,中间如果隔着普通文本则不合并。合并条件的判断代码可以通过注入 span 实现,真正重要的是“不要默认所有 del 都应该连在一起”。
5. 解析结果进 wangEditor:数据模型、渲染标记和只读
解析模块拿到了带批注锚点、修订类型、作者、时间的结构化 events 数组后,下一步就是让 wangEditor 把它展示出来。
5.1 用 HTML span 标记保留批注锚点和修订痕迹
wangEditor 的底层数据模型本质上是 slate.js 的节点树,但导入时最常见的接入方式仍是 html。这样做的好处是同一条 HTML 既能覆盖导入需求,也能兼容复制粘贴场景。
插入标记时我做了三个约定:
- 批注锚区内的文字用
<span data-comment-id="0" data-w-e-type="comment">包裹。 - 修订插入文本用
<span data-w-e-type="ins">包裹,并加黄色背景。 - 修订删除文本用
<span data-w-e-type="del">包裹,并加删除线。
这三个约定确保 wangEditor 内部不会把自定义 span 当成无意义标签过滤掉,也方便后续在编辑器内容里查找特定批注锚点。
生成的 HTML 大致如下:
html复制<p>此条款<strong>必须</strong><span data-comment-id="3" class="comment-anchor">在下月一号前</span>完成确认。</p>
<p><span style="background-color: #ffe18a;">新增补充条款</span></p>
<p><span style="text-decoration: line-through; color: #666;">旧条款内容</span></p>
5.2 批注内容不直接进正文,放到右侧批注栏
很多人实现批注时会把批注文本直接塞进正文的括号中,变成一个“文字气泡”,这种方法会让 Word 正文和批注内容混在一起,用户很难判断哪些是正文、哪些是评论。
我在编辑器外布局了批注面板。正文中只插入一个带 ID 的下标气泡标记,点击时联动到右侧批注面板并高亮对应项目。编辑器的 HTML 内容只是数据载体,右侧面板的数据直接来源于解析模块生成的 commentsMap。
批注面板的示意结构如下:
html复制<div class="comment-panel">
<div class="comment-item" data-comment-id="3">
<div class="comment-meta">张工 · 2024-06-01 10:00</div>
<div class="comment-body">这里的口径需要再确认一下</div>
<button class="jump-btn">定位到原文</button>
</div>
</div>
“定位到原文”按钮的实现逻辑是:遍历 editor 内容区里的 [data-comment-id] 元素,用对应 ID 找到第一个元素并利用浏览器的 scrollIntoView 滚动到可视区,再临时加一个高亮 class,300 毫秒后移除。
5.3 导入后如果只看不改,把 wangEditor 切到只读状态
热搜词里有“wangeditor怎么设置只读”,说明很多人把导入功能和只读展示放一起处理。实际在 wangEditor 中,多数版本调用编辑器实例的 disable() 方法就能把内容区切成只读模式。代码比较简单:
javascript复制// 草稿预览模式
editor.disable();
// 切回编辑模式
editor.enable();
同时建议在加载批注面板时默认进入只读状态,否则用户一边看批注一边编辑正文,会导致正文中的 span 标记被破坏,导致后续“定位到原文”失败。
注意:如果你使用的是 wangEditor v4,配置只读的方式可能不是
disable(),请以当前接入版本官方 API 为准。我这里说明的只是业务语义上的处理思路。
6. Django 后端协同:接收上传、接口鉴权与文件伪装排查
这个项目的前端解析能力很强,但后端不能只是写一个“接收文件存到本地”的接口。还要处理文件上传安全性、大文件限制、文件名规范、以及异常文件排查。
6.1 Django 里接 wangEditor 上传接口的方式
“django pip wangeditor” 这个关键词对应的场景,是很多人在 Python 项目中直接用 Django 管理后台集成 wangEditor。但当前需求不是后台富文本编辑,而是导入文件处理。我依然沿用了 wangEditor 自定义上传的逻辑:前端把文件 POST 到 Django 后端,后端存完文件后返回 JSON。
python复制import os
from uuid import uuid4
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
ALLOWED_DOC_EXT = {".docx", ".doc"}
@csrf_exempt
@require_POST
def upload_docx_for_import(request):
upload_file = request.FILES.get("file")
if not upload_file:
return JsonResponse({"errno": 1, "message": "缺少上传文件"}, status=400)
ext = os.path.splitext(upload_file.name)[1].lower()
if ext not in ALLOWED_DOC_EXT:
return JsonResponse({"errno": 1, "message": "仅支持 Word 文档"}, status=400)
if upload_file.size > 10 * 1024 * 1024:
return JsonResponse({"errno": 1, "message": "文件不能超过 10MB"}, status=400)
safe_name = f"{uuid4().hex}{ext}"
save_path = os.path.join(settings.MEDIA_ROOT, "word_import", safe_name)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
return JsonResponse({
"errno": 0,
"data": {
"url": f"/media/word_import/{safe_name}",
"name": upload_file.name,
},
})
前端配置 customUpload 时,重点不是把文件传上去就行,而是要把返回的 URL 和名称继续传给 wangEditor 的插入函数。这里对应了“wangeditor customupload”这个热搜点。
6.2 customUpload 回调的接法与错误处理
在 wangEditor 中,自定义上传图片、附件、文件都是类似的机制,核心是拿到上传成功后回调函数去把信息插入编辑器。代码上要注意:回调函数必须被调用,且只能调用一次,否则会重复插入文件。
javascript复制const editorConfig = {
MENU_CONF: {
uploadAttachment: {
customUpload(file, insertFn) {
const formData = new FormData();
formData.append('file', file);
fetch('/api/word/upload-for-import/', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.then((json) => {
if (json.errno === 0) {
insertFn(json.data.url, json.data.name, json.data.url);
} else {
window.alert(json.message);
}
})
.catch(() => {
window.alert('上传失败,请重试');
});
},
},
},
};
实际还有一个很容易踩的坑:如果后端返回了 errno 非 0,但依然把 response 当成成功处理,前端会插入一个无效地址。所以回调里必须明确判断 errno。
6.3 文件名伪装与内容歧义检查
只判断扩展名是不可靠的。有人会把一个压缩包改成 .docx 上传,Django 存下来了,前端解析时 JSZip 直接报错。为了给用户友好提示,后端必须做“预解析校验”。
最简单的方法是读取文件前两个字节,检查是否为 PK,这是 ZIP 文件的标准魔数。如果是真正的 .docx,文件头一定是 PK;历史版本 .doc 则是 D0 CF 11 E0。做不到百分百可靠,但能过滤绝大多数伪装文件。
python复制def is_docx_or_doc(file_bytes):
# ZIP 文件头
if file_bytes[:2] == b"PK":
return True
# OLE2 复合文档头,常见于 .doc
if file_bytes[:4] == b"\xd0\xcf\x11\xe0":
return True
return False
后端一定不要在文件解析上试图替代前端的所有逻辑,Django 这一层只要管好存储、大小、文件头、权限,就能解决 90% 的问题。真正的内容判定,还是让前端 JSZip 去处理。
7. 特殊文档场景:WPS、Origin图片、Word公式、Excel批注别混进来
真实项目里的文档都是千奇百怪的。如果只按微软 Word 默认输出的 docx 做测试,很容易被后续来的“WPS 顺手改过的文件”打穿。
7.1 WPS 保存过的文档,修订日期可能是空的
WPS 保存的 docx 在结构上与 Word 基本一致,但在 XML 细节上存在偏差。最常见的两个现象:
w:date属性可能为空字符串或缺少时区后缀。- 部分批注 id 是数字字符串但排序不连续,中间有空洞。
解析代码要具备容错:日期为空时不抛异常,改用空字符串占位;id 排序不连续时不能依赖数组下标做 map,必须用 id 精确定位。
在这个问题上,WPS JS 宏可以用作校验工具。我之前写过一个简单宏,遍历 Word 格式文档中的批注并将作者、日期、内容导出为文本清单,用来和网页导入后的批注面板做比对。如果需要处理大批量历史文档,WPS JS 宏“批注导出”非常适合做离线抽检,它不参与前端运行,但能帮你节省大量测试用例时间。
7.2 Word 中粘贴进 Origin 图像时,图片可能变成 EMF/WMF 矢量
“Word导入origin图像”这个词条,说的是用户在 Origin 绘图软件里复制图表,然后以图片形式粘贴到 Word 中。Word 默认可能嵌入两种副本:位图 PNG 和矢量图 EMF/WMF。
mammoth.js 处理这类嵌入图片时,通常会把图片转成 data URI 或提示“不支持图片格式”。如果这批 Original 图片又和批注锚点有重叠,那么 HTML 中图片位置可能丢失,批注标记也会错位。
我的建议是:
- 前端解析时单独收集
word/media/目录下的图片文件,并根据 document.xml 中的r:embed关联建立媒体映射。 - 遇到 EMF/WMF 时,如果浏览器无法显示,则展示一个“原始矢量图对象”占位块,不要直接删除。
- 批注标记如果是锚在图片上的,宁可把标记显示在图片占位块前后,也不要丢弃。
7.3 Word 原生公式与“MathML 代码怎么导入 Word”
这个热搜词其实是站在反方向关心的:用户有 MathML 代码,想导入 Word。但实际遇到的问题是 Word 原生公式在 docx 中存储为 OMML 结构,而不是 HTML 能直接识别的文本。因此“Word 导入公式”和“MathML 导入 Word”中间夹了一层格式转换。
前端基础 HTML 转换时,mammoth 对公式的还原度非常有限。常见做法是把 m:oMath 区域整体保留为一张“LaTeX 表达式”或者占位符。如果希望公式在网页里正常显示,需要后期用 LaTeX 渲染插件,把识别出的公式片段渲染成可读的数学表达式。
我调优后决定:公式区域在导入结果中保留为代码块,而不是强行渲染成图片。原因是公式和批注的位置关联比观看公式本身更重要,强行转图片会导致锚点失效,得不偿失。
7.4 qxlsx 添加批注和“批注需求”的区别
qxlsx 是一个处理 Excel 的 C++/Qt 库,用户搜索“qxlsx 添加批注”时关心的其实很有可能是 Excel 里单元格的批注。但本项目的核心是 Word 文档批注。需要和自己在项目里区分清楚,不要在 Word 导入逻辑里掺入 xlsx 分支,否则代码结构会失去重点。
这没有小看 Excel 批注的意思,而是说产品边界要清楚。Word 批注依赖 OOXML 的 comments.xml,Excel 批注依赖的是表格工作簿里的 comments 关系,两者解析链路完全不同。如果项目确实需要两种都支持,应该拆成两个独立模块,而不是在一个 Word 导入组件里塞一个 Excel 解释器。
8. 最终测试与踩坑复盘:实测没通过的 Case 比网上教程更有用
在准备上线前,我整理了一份回归测试清单。这份清单覆盖了常规文档、跨段批注、修订删除、WPS 另存和图片锚点。
| 测试用例 | 准备文档 | 预期结果 | 实测反馈 |
|---|---|---|---|
| 基础批注 | Word 中写 3 条批注 | 3 条批注显示在右侧面板 | 通过 |
| 跨段批注 | 从第 1 段选到第 3 段加批注 | 批注起点在段落1,结束标记段落3 | 首次实现有 bug,原因是只处理了同段情况 |
| 修订删除 | 删掉一长句 | 删除文字带划线 | 通过 |
| 修订插入 | 插入新句子 | 插入文字高亮 | 通过 |
| 删除范围内有批注 | 先删除再在删除文字上批注 | 批注面板正常展示,标记可定位到删除span | 通过 |
| WPS 另存文档 | 用 WPS 开启修订并保存 | 修订记录能识别,空日期不报错 | 通过 |
| Word 中内嵌 Origin 图 | 文档包含 EMF 图且加批注 | 占位块显示,批注锚点可用 | 通过 |
这次开发过程中最值得记录的一条教训是:永远不要在 HTML 转换结果已经生成后,再尝试用标签匹配去反推批注范围。因为 HTML 标准标签在转换过程中会被编辑器灵活重排,反推只能碰运气。必须回到 XML 原始节点,在 HTML 拼接时就把批注边界和修订样式做进去,这样才能保证结果稳定。
其次,批注面板和编辑器正文的联动,不需要依赖 wangEditor 内部 API。我的做法是直接给批注元素标记 data-comment-id,通过 DOM 查询和 event delegation 去完成点击联动。这样即使编辑器升级、内部结构变化,只要 HTML 输出仍保留自定义属性,代码就还能继续工作。
第三点是交互设计的认知:修改建议要不要提供“接受/拒绝”操作,产品验收时确实提过这个问题,被我延期到下个版本。因为批注和修订内容在编辑器里只是“展示性质的标记”,如果要做接受/拒绝修订操作,就必须反向修改 wordprocessingml 模型,这本质上是一个新的文档版本管理功能,不能在导入面板里顺便做掉。
最后分享一个在本次项目里比较意外的发现。最初我以为最大的技术风险来自 docx 解析库,毕竟 Word 格式千奇百怪,很容易翻车。但实际测试跑完后,更多问题反而来自用户上传的“脏数据”和产品需求边界不清。比如文件名编码错误、扩展名伪装文件、WPS 时间戳缺时区、批注 id 重复这类问题,靠技术手段都能兜住,真正费时间的反而是“这个批注到底要展示到什么程度”这种前置需求讨论。
如果以后接类似项目,我建议第一天就拉着产品和测试把批注范围、修订接受拒绝、Excel批注边界、公式显示策略全部对齐。这些前置工作开展得越充分,后面实现越顺利。若没有先对齐就开工,多半会陷入“开发觉得已经做完了,验收却说批注表达不完整”的死循环里。
