
说实话被“Word文档格式化编辑”这个需求找上门多半是因为你正在维护某个老OA或者后台管理系统编辑器十有八九就是UEditor。我刚接一个项目时也天真地以为本地Word打开CtrlC到编辑器里CtrlV完事。等真正上线后用户反馈接踵而至标题字号全丢了、正文缩进没了、表格碎成一堆td、图片要么不显示要么直接裂开。这才意识到所谓“格式化编辑”本质上是一个从Word私有格式到网页标准HTML的转换问题而且不是简单地改个配置就能糊弄过去的。这篇文章我按实际开发中的处理顺序来写先搞懂格式丢在哪再讲轻量级的粘贴清洗方案然后是我更推荐的上传docx解析方案mammoth.js最后是几个真实项目里很头疼的坑和对应的收尾建议希望能给还在跟UEditor较劲的同行一点参考。1. 先把问题看明白Word内容进UEditor时格式到底丢在了哪里1.1 一个典型的“乱格式”现场先还原一下最常见的用户操作在Word里打开一份标书或者红头文件选中一大段正文复制切到浏览器在UEditor里粘贴。你第一眼看到的效果通常是这样的段落还在但原本的“首行缩进2字符”没了正文的宋体小四变成了默认字体标题的“黑体三号加粗”只剩一个加粗Word里的表格粘贴过来边框丢失合并单元格也面目全非偶尔还有一堆空行夹杂在段落中间HTML结构里全是不带任何样式的pbr/p。这不是UEditor故意跟你作对而是它做了一件自己职责范围内的事过滤不可信HTML。UEditor作为一个面向网页的富文本编辑器安全设计上会把从剪贴板进入的HTML当作“外部输入”来审视。它有自己的标签白名单、属性白名单和样式处理规则凡是不在白名单里的东西直接丢弃。而Word生成的那套HTML恰好充满了UEditor默认白名单之外的东西。1.2 我看到的UEditor粘贴过滤机制UEditor对粘贴内容的处理大体分这么几步不同版本细节有差异但思路一致拿到剪贴板HTML后先做一轮“清理”去掉脚本、事件属性、危险协议比如javascript:等。然后按标签过滤规则检查每个标签是否在白名单内。不在就删标签但保留内容或者在特定情况下连内容一起处理。最后是属性过滤。即使标签被保留它的属性也得逐个过检。比如span保留但style属性里那些奇怪的mso-*前缀样式会被丢掉。我见过很多老项目的二次开发文档里把这块统一称为“过滤规则配置”字段名在1.4.x版本里常见的是filterTxtRules和retainOnlyTags在更早版本里还有allowTag之类叫法。核心逻辑都一样白名单决定生死。1.3 Word粘贴进浏览器的HTML到底长什么样想解决问题先得知道敌人长什么样。在Chrome里复制一段Word内容粘贴出来得到的HTML经常是这个画风p classMsoNormal styletext-indent:24.0pt;mso-char-indent-count:2.0 span stylefont-family:宋体;font-size:10.5pt;mso-bidi-font-size:10.5pt; 这里是一段正文内容 /span /p注意里面的几个关键信息classMsoNormal是Word的默认段落类名本身没样式但UEditor默认可能把它过滤掉。text-indent:24.0pt是首行缩进但.0pt这种小数点格式在网页端并不友好。mso-bidi-font-size:10.5pt这类mso-*前缀属性是微软Office私有标记W3C标准里没有浏览器不认UEditor更不认。font-family:宋体在没有安装宋体的操作系统上会回退成默认字体这不是编辑器的问题是字体环境问题。如果是复杂文档还会出现o:p标签Word命名空间的段落标记、!--[if gte mso 9]条件注释、v:shape绘图形状对象等。把这些东西原样塞进UEditor轻则样式丢失重则HTML结构直接乱了。知道了这些背景再来看两条解决路线。2. 轻量路线靠配置和粘贴后处理保住常见格式如果你的业务场景里用户的操作习惯就是“复制→粘贴”不愿意选文件上传那我们只能在粘贴链路上做文章。这条路的好处是改动小、对用户无感坏处是Word粘贴的HTML格式千奇百怪清洗规则没法做到100%完美。我的思路是“配置放大白名单事件里做二次清洗”双管齐下。2.1 调整过滤规则给标签和样式留口子UEditor初始化时按你实际使用版本的API来放宽白名单。以我项目里基于1.4.x的写法为例var ue UE.getEditor(editor, { // 其他原有配置... // 关闭自动抓取远程图片避免粘贴时对图片做额外的远程请求 catchRemoteImageEnable: false, // 白名单放宽p、span、font、table系列标签都保留相应属性 filterTxtRules: { p: { class: 1, style: 1 }, span: { style: 1, class: 1 }, font: { face: 1, size: 1, color: 1, style: 1 }, table: { border: 1, cellspacing: 1, cellpadding: 1, style: 1, width: 1 }, tr: { style: 1 }, td: { colspan: 1, rowspan: 1, style: 1, width: 1 }, th: { colspan: 1, rowspan: 1, style: 1, width: 1 }, a: { href: 1, title: 1, target: 1 }, img: { src: 1, width: 1, height: 1, alt: 1, style: 1 } } });这段配置里我把style属性给p、span、td这些标签开了口子因为Word的格式大量靠内联style表达滤镜全关等于自杀。但是只放宽白名单还不够filterTxtRules保留的style是“原样保留”那些mso-*私有样式和pt单位依然会原封不动地进到编辑器里。所以接下来得靠事件处理做“清洗”。2.2 粘贴内容的后置清洗脚本我采取的做法是监听afterpaste事件等UEditor把剪贴板内容插入完成后从编辑器里取出HTML跑一遍自定义的清洗函数再用setContent把清洗结果写回去。这样不会跟UEditor内部粘贴流程的时机纠缠不清兼容性也稳一些。ue.addListener(afterpaste, function () { var html ue.getContent(); var cleaned cleanWordHtml(html); if (cleaned ! html) { ue.setContent(cleaned); } }); function cleanWordHtml(html) { if (!html) return html; // 1. 删除Word的命名空间声明形如 xmlns:ourn:schemas-microsoft-com:office:office html html.replace(/xmlns:[a-zA-Z0-9][^]*/g, ); // 2. 删除Word条件注释形如 !--[if gte mso 9]xml.../xml![endif]-- html html.replace(/!--\[if[^]*].*?!\[endif\]--/gis, ); // 3. 删除mso-开头的私有样式属性比如 mso-bidi-font-size:10.5pt html html.replace(/\bmso-[a-zA-Z\-]:[^;]*/gi, ); // 4. 把o:p空标签直接去掉避免产生多余空段落 html html.replace(/o:p\s*\/o:p/gi, ); // 5. 清理Word自动生成的类名 html html.replace(/classMsoNormal/gi, ); html html.replace(/classMsoTableGrid/gi, classtable); // 6. 删除空span这类标签留着没意义 html html.replace(/span[^]*\s*\/span/gi, ); // 7. 给表格补上基础边框属性因为Word表格粘贴过来后经常没有border属性 html html.replace(/table([^]*)/gi, table$1 border1 cellspacing0 cellpadding0 styleborder-collapse:collapse;width:100%;); return html; }这段正则里有两个地方容易踩坑我单独说一下。第一个是删除mso-*样式时如果文档里存在font-size:10.5pt;mso-bidi-font-size:10.5pt这种连着写的样式正则必须匹配到分号或引号才停否则会把后面的正常样式也吞掉。我上面用的是[^;]*就是为了避免误伤。第二个是table替换逻辑用([^]*)来捕获原标签属性避免多次替换时把已经加上的border再套一遍。这套方案在大多数“正文标题简单表格”的场景下能把格式亮眼程度从“全丢”提升到“基本还原”但遇到复杂表格、多级编号、图文混排的长文档效果还是不够理想。所以就有了下一条更稳的路线。3. 更稳的方案用mammoth.js把docx解析成干净HTML再进编辑器如果你的产品形态允许用户“上传本地Word文档”我强烈建议走这条路线让用户选择.docx文件前端直接解析把Word文档转换成结构干净的HTML再塞进UEditor里继续编辑。3.1 为什么我选mammoth.js而不是后端转换后端转换比如服务器装Word/LibreOffice再导HTML的问题是环境依赖太重服务器要装办公软件转换时间长还经常出现字体缺失渲染偏差。前端用JS解析docx省去服务端开销而且docx本质上是ZIP包内部是XML前端解析并不是什么黑魔法。跟其它JS库对比一下就知道它合适在哪docx-preview主要用来预览渲染适合“只读查看Word文件”不关心你能不能继续编辑输出的不是干净的可编辑HTML。jszip 手写XML解析自由度最高但要把WordprocessingML里的样式、段落、表格、图片全处理一遍开发成本直接劝退。mammoth.js专为“docx转HTML”设计浏览器端直接跑输出的HTML语义化程度高而且支持通过styleMap定制映射规则非常适合接到富文本编辑器里。mammoth生成的HTML不会带MsoNormal这类Word私有类名而是干净的h1、p、strong、table这正是UEditor这种富文本编辑器最喜欢的类型。它天然绕过了我们刚才在粘贴清洗方案里对付的那一堆mso-*垃圾。3.2 前端接入mammothjs的完整步骤先引入文件UEditor和mammoth都得加载script src/lib/ueditor/ueditor.all.js/script script src/lib/mammoth/mammoth.browser.min.js/script注意mammoth的浏览器版本是mammoth.browser.min.js加载后全局变量为mammoth。我用FileReader读取用户选择的文件再交给mammoth解析核心代码如下// 假设页面上有个input typefile idwordFile accept.docx,.doc document.getElementById(wordFile).addEventListener(change, function (e) { var file e.target.files[0]; if (!file) return; // 文档类型提示mammoth只支持docx老doc要先另存为docx if (/\.doc$/i.test(file.name)) { alert(暂不支持老版.doc请先在Word中另存为.docx格式); this.value ; return; } var reader new FileReader(); reader.onload function (ev) { var arrayBuffer ev.target.result; mammoth.convertToHtml({ arrayBuffer: arrayBuffer }, { styleMap: getWordStyleMap(), convertImage: mammoth.images.imgElement(function (image) { return image.read(base64).then(function (base64) { return { src: data: image.contentType ;base64, base64 }; }); }) }).then(function (result) { // result.value 是转换后的HTML字符串 var html postProcessHtml(result.value); ue.setContent(html); }).catch(function (err) { console.error(解析Word失败, err); alert(解析失败请确认文件没有损坏); }); }; reader.readAsArrayBuffer(file); // 清空input的值防止选择同一个文件不触发change this.value ; });这段代码里有几个细节值得注意。第一convertToHtml的第一个参数是解析源浏览器环境下传{ arrayBuffer: arrayBuffer }最可靠不要传File对象某些浏览器会有兼容问题。第二result.value里存的是干净的HTML后面做二次加工非常方便。第三我故意加了.doc的检查因为mammoth只认docx这个在后文坑里会展开说。3.3 用styleMap定制标题、正文、表格的映射mammoth最有价值的地方就是styleMap。Word文档里的“标题1”“标题2”“正文”这些段落样式默认会被mammoth转换成对应的HTML标签但中文字体下经常出现样式名不一致的情况。所以我在项目里显式指定映射规则function getWordStyleMap() { return [ // Word内置标题映射到HTML标题 p[style-name标题 1] h1:fresh, p[style-name标题 2] h2:fresh, p[style-name标题 3] h3:fresh, p[style-name标题 4] h4:fresh, // 英文样式名兜底 p[style-nameHeading 1] h1:fresh, p[style-nameHeading 2] h2:fresh, p[style-nameHeading 3] h3:fresh, // 正文样式 p[style-nameNormal] p:fresh, p[style-name正文] p:fresh, // 列表项保持为li p[style-nameList Paragraph] li:fresh, // 表格保留table标签 table table, // 强调样式 b strong, i em, u u ]; }styleMap里每一项的格式是选择器 HTML标签后面的:fresh表示生成的标签不带mammoth自动附加的样式这样可以让我们后续的CSS或编辑器主题完全接管标题样式避免出现UEditor里标题字号跟Word里相差一大截的问题。实测下来加了这些映射后Word文档里的多级标题进入UEditor后能直接用编辑器自带的“格式刷”和标题下拉框继续调整这个体验比粘贴方案好太多。还有一个容易漏的地方如果Word里用了“正文缩进”或“首行缩进”这类自定义样式默认styleMap不会把它映射到HTML的text-indent导致段落缩进丢失。这时候可以针对公司内部模板补充映射比如p[style-name首行缩进] p.indent-2:fresh然后在编辑器给出的CSS样式表里给.indent-2加上text-indent:2em;。这步很吃业务模板但一旦配好导入效果基本是“所见即所得”。3.4 图片的读取、上传与回填mammoth对图片的默认处理是转换成img并把图片内容base64编码放进src。上面代码里我用的就是base64路线但这只适合图片数量少、体积小的文档。真实项目中几十张截图、几百KB的图直接变成base64后UEditor内容体积会爆炸保存到后端数据库时字段长度会超编辑器本身的渲染和撤销操作都会变卡。正确的做法是在convertImage钩子里把图片内容上传到自己的服务器或OSS/CDN然后把返回的URL作为src替换进去。我在项目里用了一个辅助函数function uploadImageAsBlob(image) { // 第一步读取为blob return image.read(base64).then(function (base64) { // 把base64转成Blob对象 var byteCharacters atob(base64); var byteNumbers new Array(byteCharacters.length); for (var i 0; i byteCharacters.length; i) { byteNumbers[i] byteCharacters.charCodeAt(i); } var byteArray new Uint8Array(byteNumbers); var blob new Blob([byteArray], { type: image.contentType }); return uploadFile(blob); // 返回PromiseURL }); } // 使用时 function convertImage() { return mammoth.images.imgElement(function (image) { return uploadImageAsBlob(image).then(function (url) { return { src: url }; }); }); }image.contentType通常能拿到image/png或image/jpeg后端根据这个类型决定存储格式。这里有一个很重要的细节即使最终图片上传失败也不要让整个解析流程崩掉。我通常会在uploadImageAsBlob里catch住返回一个1x1的占位图URL或提示图然后继续解析其它内容避免用户因为一张损坏图片导致整篇文档导入失败。4. 后来我在真实项目里踩过的坑前面两套方案看起来都不复杂但拿到真实文档里跑一遍问题就全冒出来了。我一个个说都是直接影响上线验收的。4.1 表格没有边框样式全没了mammoth转换过来的表格默认是不带border属性的。浏览器里表格默认无边框Word里明明有网格线到了网页上变成一片空白。我自己用解析后HTML一看发现mammoth只输出table和td样式属性一概不保留。我的处理是统一在做HTML后处理时给表格补基础样式也就是postProcessHtml函数里的逻辑function postProcessHtml(html) { // 给所有表格加基础边框和宽度 html html.replace(/table([^]*)/gi, function (match, attrs) { // 如果表格没有style才补默认样式 if (/style/i.test(attrs)) { return match; } return table attrs styleborder-collapse:collapse;width:100%; border1 cellspacing0 cellpadding5; }); // 把单元格也统一处理一下 html html.replace(/td([^]*)/gi, td$1 styleborder:1px solid #ddd;padding:5px;); html html.replace(/th([^]*)/gi, th$1 styleborder:1px solid #ddd;padding:5px;background:#f5f5f5;); return html; }这里有个很鸡贼的点必须说如果你用正则给所有table强制加style而实际上UEditor编辑器内容里有些表格是用户自己插入的、不想被强制改样式的表格那会把所有表格都改了。所以我加了一个if (/style/i.test(attrs))判断只为来自mammoth的、没有style属性的表格补样式。这样导入的Word表格看起来像样用户手动插的表格也保留自己的样式。4.2 字号单位pt和px混用编辑器字号下拉框失灵Word里的字号单位是pt磅网页里常用px或em。mammoth转换时如果Word指定了font-size:12pt它会原样输出font-size:12pt。这本身没问题浏览器能正确渲染pt。但UEditor自带的“字体大小”下拉框识别不了pt它内部维护的是12px、14px、16px这套值。结果就是Word导入后选中文本字号下拉框显示空白用户以为字没有字号。解决思路是导入后做一次单位换算把pt转成px。换算公式很简单1pt 96 / 72 px ≈ 1.333px。我写了一个HTML级别的正则替换html html.replace(/font-size:\s*([\d.])pt/gi, function (match, ptValue) { var pxValue Math.round(parseFloat(ptValue) * 1.333); return font-size: pxValue px; });Word里常见的几个字号换算结果先列一下方便你核对Word字号pt换算px常见用途八号6.5pt约9px注释七号5.5pt约7px极小字六号7.5pt10px脚注五号10.5pt14px正文常用小四12pt16px正文标题四号14pt约19px小标题三号16pt约21px文章标题换算成px后UEditor的字号下拉框就能正确识别后续用户在编辑器里继续调整字号也顺手。这个细节我实测下来非常影响体验不处理的话用户会误以为“导入功能是坏的”。4.3 mso开头的样式残留和XSS风险粘贴清洗方案里我们已经清过mso-*但mammoth方案也存在同样的风险因为某些docx导出的样式里有mso-*属性mammoth偶尔也会透传出来。为了稳妥我在postProcessHtml里又跑了一遍正则清理。手段相同效果一致这里就不再重复贴代码。需要额外警惕的是XSS风险。Word文档里的超链接a hrefjavascript:...或者用户在Word里粘贴过带onerror的HTML片段mammoth不一定能完全过滤。UEditor本身有xssFilterRulessetContent进去时它会再做一次过滤所以一般情况下问题不大。但如果你把解析后的HTML直接存到后端再通过接口返回给其它页面展示一定要在后端再做一次白名单过滤别只依赖前端编辑器。4.4 setContent会清空撤销栈用户操作习惯要适配UEditor的setContent会重置编辑器的内容同时清空undo/redo历史。用户导入Word后又想撤销到导入前的状态按CtrlZ没反应会以为系统出bug了。我在项目里专门做了个“导人前快照”的处理var snapshotBeforeImport null; function importWord(file) { // 记录当前内容以便用户撤销导入 snapshotBeforeImport ue.getContent(); // ...解析并setContent... } // 给工具栏加一个“撤销导入”按钮点击时恢复快照 ue.addButton(undoImport, { title: 撤销导入, click: function () { if (snapshotBeforeImport ! null) { ue.setContent(snapshotBeforeImport); } } });这样至少给了用户一个后退通道不至于导入完发现某段内容丢了只能干瞪眼。如果你不想额外加按钮也可以在beforepaste或导入前用ue.execCommand(undo)配合历史记录做补偿但实测还是快照方案最省心。4.5 mammoth解析不了老的.doc格式这是mammoth的硬伤它只支持.docxOffice 2007不支持老的.doc二进制格式。项目里如果用户还在传.doc文件你得有预案前端提示用户将.doc另存为.docx再上传。操作成本低但会增加用户操作步骤。后端用LibreOffice或者专门的转换服务把.doc转成.docx再交给前端。适合批量导入场景但对服务器有额外要求。接受.doc转成HTML后的粗糙结果。不推荐效果太差。我目前的处理是前端拦截弹提示同时在操作手册里写明支持格式。如果将来遇到批量的老文档迁移再考虑后端转换。5. 我最终采用的组合方案说到底没有任何单一方案能覆盖所有用户习惯所以我在正式项目里用的是“粘贴清洗 上传解析”两条腿走路用户习惯直接复制粘贴走第2章的afterpaste清洗方案通过放宽白名单清洗脚本尽量还原格式。用户主动上传Word文档走第3章的mammoth.js解析方案通过styleMap定制映射、图片上传、表格补样式、字号单位转换把Word文档变成一个真正可以在UEditor里继续格式化编辑的HTML。两条路径最后都汇到同一个postProcessHtml做收尾保证表格、字号、图片三样最容易翻车的东西是一致的处理结果。为了维护方便我还把“字号转换”“表格补样式”“清mso残留”“清空span”这些都拆成了独立函数这样以后遇到新坑只需要往postProcessHtml里加一个处理步骤不会动了A功能坏了B功能。如果你不巧手上也维护着一个老系统希望这篇东西能帮你少走点弯路。最后再唠叨一句导入功能上线后一定要拿你们业务里最复杂的几篇真实文档反复测因为Word文档的格式复杂度远超预期你总会遇到“所有正则都写了但某个文档还是乱”的情况。多准备几套典型文档比啥都有用。