资讯详情

资讯详情

富文本编辑器处理Word粘贴:自定义HTML过滤规则实战解析

做了几年网页富文本编辑器我到现在还记着第一次有用户抱怨“从Word粘贴过来之后整个页面全乱了”的场景。那会儿我用的是最粗暴的方式直接把event.clipboardData.getData(text/html)拿到的内容塞进编辑器里结果就是满屏的classMsoNormal、o:p空节点、mso-*私有样式、条件注释还有十八层内联嵌套。后来我专门研究过一轮“富文本编辑器、Word、粘贴、自定义过滤规则”这组关键词发现大部分资料只讲了“用现成库清洗一下”但对Word生成的那堆HTML到底长什么样、为什么要这么设计过滤规则、规则写在代码里怎么组织讲得不够透。这篇文章想把整个思路从头到尾摊开讲。核心解决的是在线文档、后台CMS、知识库这类系统里的共性问题用户从Word复制内容粘贴到网页编辑框如何通过自定义过滤规则把Office私有语义转换成编辑器能展示的干净HTML。适合正在做富文本编辑器、想优化粘贴体验的前端同学也适合负责低代码平台或工单系统、被Word粘贴搞得焦头烂额的开发者参考。我会给出一套可以直接落地的规则设计思路、完整代码骨架以及我踩过的一些坑。1. 为什么Word复制出来的HTML这么难搞1.1 Word往剪贴板里塞了一整套“Office世界”很多人以为从Word复制内容剪贴板里只有一段纯文本和一段干净的HTML。实际上Word复制时会向剪贴板写入多种格式除了纯文本text/plain还有text/html、text/rtf甚至OLE对象。对网页编辑器来说我们拿到的text/html是Word自己生成的不是浏览器解析出来的它把Office内部的一套文档模型整个暴露出来了。一个典型的Word复制HTML长这样html xmlns:ourn:schemas-microsoft-com:office:office xmlns:wurn:schemas-microsoft-com:office:word xmlns:mhttp://schemas.microsoft.com/office/2004/12/omml head style page WordSection1 { size: 595.3pt 841.9pt; margin: 72.0pt 72.0pt 72.0pt 72.0pt; } p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0cm; ... } /style /head body p classMsoNormal styletext-indent:21.0pt; mso-char-indent-count:2.0; span stylefont-family:宋体; mso-ascii-font-family:Calibri;这是一段内容/span o:p/o:p /p !--[if supportLists]-- p classMsoListParagraph.../p ![endif]-- /body /html特征很明显大量xmlns命名空间声明、MsoNormal这类业务类名、mso-*私有样式、o:p这种Office专属哨兵标签、条件注释还有style里的page规则。浏览器默认粘贴时并不认识这套语义它只知道把这些节点塞进contenteditable类名没有任何CSS定义私有样式被部分保留但没意义于是用户看到的就是一团糟。1.2 剪贴板数据里不止有HTML处理前必须知道我们能拿到什么。标准paste事件里event.clipboardData可以读取剪贴板内容关键方法有两个event.clipboardData.getData(text/html); event.clipboardData.getData(text/plain);如果你调试过会发现同一份内容从Word复制时text/html段非常长带一堆命名空间和类名从浏览器网页里复制时text/html相对干净从纯文本编辑器复制时text/html通常是空字符串。这个差异本身就可以作为过滤流程的开关如果HTML里能匹配到xmlns:o、MsoNormal、!--[if gte mso 9]这类特征就走Word专用清洗流程否则只做普通净化。RTF格式text/rtf也能读到但大多数网页编辑器用不到它主要是给Windows原生应用之间交换用的。1.3 第一步永远是拦截而不是事后补救正确的流程是在粘贴发生前就拦截处理完再插入绝不能先让编辑器把原始内容吃进去再想着清理。回归代码editorEl.addEventListener(paste, function (event) { const clipboardData event.clipboardData || window.clipboardData; const html clipboardData clipboardData.getData(text/html); if (html) { const hasWordMark /xmlns:o|MsoNormal|Microsoft(Word|Office)|!--\[if .*mso/gi.test(html); event.preventDefault(); let cleaned; if (hasWordMark) { cleaned cleanWordHtml(html, wordRules); } else { cleaned sanitizeNormalHtml(html); // 普通HTML净化 } insertHtmlAtCursor(cleaned); } });preventDefault()相当于把编辑器默认粘贴行为关掉。这一步不做后面所有自定义规则都是给别人的孩子洗澡白费力气。2. 自定义过滤规则应该怎么设计2.1 先定基调白名单为主黑名单兜底设计过滤规则第一个绕不开的问题是到底是“列出我允许的”还是“列出我要删的”。我的经验是白名单为主、黑名单兜底。原因很简单Office的私有标记太多了黑名单根本列不全。今天你认识o:p明天可能冒出mso-ansi-font-size、mso-line-height-rule、v:roundrect。白名单的特点是“我只要我知道的、能渲染的”其余一律拆掉输出永远是可控的。黑名单用来处理那些不需要经过白名单审批、明确要整棵删除的东西比如script、iframe、style里的内容、head标签。白名单和黑名单混用逻辑上更干净。2.2 规则对象的结构为了让过滤逻辑不散落在函数里到处if我习惯把它收敛成一个可配置的规则对象。一个典型配置长这样const wordRules { allowedTags: [ P, DIV, BR, B, STRONG, I, EM, U, S, A, IMG, UL, OL, LI, TABLE, THEAD, TBODY, TR, TD, TH, H1, H2, H3, H4, H5, H6, BLOCKQUOTE, PRE, CODE, SPAN, SUB, SUP, HR ], deniedTags: [SCRIPT, IFRAME, OBJECT, EMBED, STYLE, XML], allowedAttrs: [href, src, alt, title, target, colspan, rowspan, width], styleWhitelist: [ font-family, font-size, font-weight, font-style, color, background-color, text-align, text-indent, line-height, margin, padding, vertical-align, border, border-collapse, border-spacing, text-decoration, list-style-type, float, max-width ], stripEmptyTags: [P, SPAN, DIV], image: { allowDataURI: true, allowHttp: true }, table: { normalizeWidth: true, maxRows: 100, maxCols: 20 } };把规则抽成对象有两个好处。第一测试好写给不同项目传不同配置就能跑用例。第二不同业务场景差异很大知识库允许多级标题和表格聊天窗口可能只需要粗体斜体规则可配置就不用改引擎代码。规则引擎是通用的业务差异都在配置里。2.3 解析HTML用DOM别用正则硬解必须强调一点不要试图用正则完整解析HTML。HTML结构有嵌套、有容错、有缺失闭合正则处理树形结构是经典的反模式。正确做法是先把HTML字符串转成DOM树再通过递归遍历做结构化过滤。浏览器里可以这样解析function parseHtml(html) { const template document.createElement(template); template.innerHTML html; return template.content; }template元素不会触发图片加载、脚本执行这类副作用比临时iframe方便很多。拿到DocumentFragment后从根节点开始递归每一个元素节点都过一遍过滤器。正则也不是完全没用它适合做“预处理”删除条件注释、剔除style/head这类大块内容因为它们结构简单、边界清晰用正则快速摘掉能减少后续DOM遍历的开销。但真正精细的标签、属性、样式过滤必须基于DOM。3. 动手实现过滤引擎3.1 预处理先剥掉Word的外包装进入DOM遍历之前先用正则做一轮预处理把明显不想要的块删掉。function preprocessWordHtml(html) { // 去掉条件注释如 !--[if gte mso 9]...![endif]-- html html.replace(/!--\[if[\s\S]*?!\[endif\]--/gi, ); // 去掉 XML 命名空间声明块 html html.replace(/xml[\s\S]*?\/xml/gi, ); // 去掉整个 head里面通常只有样式和 meta html html.replace(/head[\s\S]*?\/head/gi, ); // 去掉内联样式表 html html.replace(/style[\s\S]*?\/style/gi, ); return html; }为什么先做这一步因为Word生成的HTML里style包含page、.MsoNormal、.MsoListParagraph等大量规则这些对编辑器没有用还会误导后续判断。条件注释里往往藏着列表编号和公式的结构如果不删等到DOM遍历时会变成奇怪的文本节点。预处理做得好后面树遍历的压力直接减半。3.2 标签级过滤保留、拆解还是删除DOM遍历的核心是对每个节点做三选一决策保留节点标签在白名单里继续处理属性和子节点。拆解unwrap标签不在白名单但内容希望保留比如u、font。拆解就是丢掉标签把子节点提升到父级。删除remove标签在黑名单或明确无意义比如o:p、script、iframe连同子树整个删掉。代码实现看起来像这样function filterTag(node, rules) { const tag node.nodeName.toLowerCase(); if (rules.deniedTags.includes(tag)) return remove; if (rules.allowedTags.includes(tag)) return keep; return unwrap; }拆解要注意点node.replaceWith(...node.childNodes)时childNodes是动态集合直接展开会出问题。稳妥做法是先把子节点转成数组function unwrapNode(node) { const children Array.from(node.childNodes); node.replaceWith(...children); }div styletext-align: center;p文字/p/div这类块级标签如果白名单里有p没有div直接拆div会导致center样式丢失。所以拆解时如果节点本身有值得保留的样式要先合并到子节点或转移给父节点。实战里我一般建议把div、p、span都放进白名单靠样式层做清理不要轻易拆块级标签否则排版丢得很厉害。3.3 属性与样式过滤从源头上销毁Office私有样式标签保留后下一步过滤属性。原则上只保留白名单里的属性并且对href、src这类带协议的值做二次校验function sanitizeAttrs(node, rules) { const attrs Array.from(node.attributes); for (const attr of attrs) { const name attr.name.toLowerCase(); if (!rules.allowedAttrs.includes(name)) { node.removeAttribute(attr.name); continue; } if (name href /^javascript:/i.test(attr.value.trim())) { node.removeAttribute(href); } if (name src !/^(https?:|data:image\/)/i.test(attr.value.trim())) { node.removeAttribute(src); } } }样式过滤是Word清洗的重头戏。Word的内联style经常长这样span stylefont-family:宋体; mso-ascii-font-family:Calibri; mso-hansi-font-family:Calibri; font-size:14.0pt; mso-bidi-font-size:11.0pt; color:black; mso-themecolor:text1;我们需要从中间把mso-*丢掉只留下编辑器支持的属性。最可靠的办法不是正则拆字符串而是利用浏览器的CSS解析能力function sanitizeStyle(styleStr, styleWhitelist) { if (!styleStr) return ; const probe document.createElement(span); probe.setAttribute(style, styleStr); const decl probe.style; const kept []; for (let i 0; i decl.length; i) { const prop decl[i]; if (styleWhitelist.includes(prop)) { kept.push(prop : decl.getPropertyValue(prop)); } } return kept.join(; ); }decl.length是浏览器解析后的CSS属性数量decl[i]拿到的是属性名。这个方案天然跳过mso-*这类非法或不在白名单里的属性比正则可控得多。Color、font-family这些写成什么格式、哪个属性名是标准形式都由浏览器帮我们归一化。需要注意字体大小单位的问题。Word常用pt作为单位网页里更合适的是px否则同一文档里不同字号会出现渲染不一致。换算关系是1pt 4/3 px即乘1.3333。例如14.0pt换算成18.67px。这一步在样式白名单里单独处理function normalizeFontSize(value) { const match value.match(/^([\d.])pt$/i); if (match) { return (parseFloat(match[1]) * 4 / 3).toFixed(2) px; } return value; }3.4 Word专属节点清理见了就删别心软遍历过程中凡是nodeName里带冒号的基本都是Office命名空间下的私有节点比如o:p、v:imagedata、w:sdt。这类节点网页端渲染不出来处理策略就是“整棵拆掉”或“整棵删除”。我偏向直接删因为o:p基本是个空哨兵v:shapetype留着也没用function shouldRemoveOfficeNode(node) { return node.nodeName.indexOf(:) ! -1; }如果你要用DOMParser解析HTML里的o:p在DOM里nodeName会是O:P冒号判断依然有效。顺手把SVG考虑进去如果编辑器不支持SVGsvg相关节点最好也删掉避免粘贴进奇怪的矢量标记。删完Office节点后还要清理空段落。Word段落里十有八九藏着o:pnbsp;/o:p一旦o:p删除留下的就是只有空白符的空p粘贴后看起来是一整页换行。我建议对p、span、div做空内容清理function isElementEmpty(node) { return node.nodeType Node.ELEMENT_NODE !node.hasChildNodes() || (node.childNodes.length 1 node.firstChild.nodeType Node.TEXT_NODE !node.textContent.trim()); }清理时注意不要误删那些靠padding或height撑起视觉占位的元素但Word粘贴场景下这种元素很少可以放心处理。3.5 表格、图片、链接每个都要单独“上刑”表格是最容易翻车的。Word表格有大量固定单元格宽度width单位通常是pt直接保留会让表格在窄屏上溢出。我的做法是归一化把table的宽度转成百分比或直接去掉宽度限制设置max-width: 100%让布局自适应容器。function normalizeTable(node) { if (node.nodeName TABLE) { node.style.maxWidth 100%; if (node.style.width) { node.removeAttribute(width); } node.style.borderCollapse collapse; } if (node.nodeName TD || node.nodeName TH) { if (node.style.width node.style.width.includes(pt)) { node.style.width auto; } } }合并单元格的colspan、rowspan必须在白名单属性里删掉它们表格结构就变了。border样式可以允许但建议只允许1px solid #ccc这类简化写法否则Word的复杂边框样式会把表格渲染得跟补丁布一样。图片的坑在网上特别多。Word里图片在HTML中通常有两种形态一种是img带file://路径另一种是v:imagedata搭配OLE对象。file://路径网页端无法访问直接在过滤时删掉srcv:imagedata前面Office命名空间清理时已经顺带删了。如果希望图片能用需要在paste事件里读取剪贴板文件const files Array.from(clipboardData.files || []); const imageFile files.find(function (f) { return f.type.startsWith(image/); });拿到图片文件后可以转成Base64暂时插入或者走自己项目的上传接口。这是编辑器产品里图片粘贴的常用方案先转成Base64保底用户保存内容时再主动上传替换。链接的href一定要做协议校验白名单只放http:、https:、mailto:其他一律清掉。Word里自动生成的超链接还会带mso-bookmark这类属性统一在属性白名单里过滤掉即可。target_blank这种在后台系统里可能有安全要求加不加relnoopener取决于编辑器自己的输出策略。3.6 数学公式和OLE对象隐蔽的大坑Word里的公式到了网页端基本不能直接用。网页端常见的Word公式产物有两种一种是m:oMath数学标记另一种是OLE对象如o:OLEObject或v:shape包着公式编辑器内容。如果编辑器不支持渲染这些结果就是粘贴后看到一堆空白或乱码用户还找不到原因。处理策略根据产品需求分三种。第一编辑器支持公式展示比如集成了MathJax建议把公式部分交给专门的解析器把oMath转成MathML或LaTeX第二业务允许用户以图片形式保存公式可以把OLE对象对应的位图识别出来插入图片第三什么都不做直接删掉公式节点至少保证内容不脏。这里面的“word公式转latex”“mathtype”“公式图片转word”这些热词说明很多用户真实需求是把Word里的公式无损搬到线上编辑器。可惜这不是过滤规则能独立解决的需要专门工具链配合所以在过滤引擎里我建议只做兜底识别到公式节点则删除并给用户提示“当前内容包含公式粘贴建议使用公式导入工具”避免无声的数据丢失。4. 完整实现骨架4.1 一个可以直接落地的cleanWordHtml把前面几节的逻辑组装起来就是一个可用的清洗函数。这段代码的结构是按“预处理 - DOM遍历 - 属性样式过滤 - 表格图片归一化 - 空节点清理”的顺序组织的function cleanWordHtml(html, rules) { html preprocessWordHtml(html); const root parseHtml(html); (function walk(node) { if (node.nodeType ! Node.ELEMENT_NODE) return; // 带冒号的Office节点直接删 if (shouldRemoveOfficeNode(node)) { node.remove(); return; } // 标签级过滤 const action filterTag(node, rules); if (action remove) { node.remove(); return; } if (action unwrap) { unwrapNode(node); return; } // 属性与样式过滤 sanitizeAttrs(node, rules); if (node.hasAttribute(style)) { const style sanitizeStyle(node.getAttribute(style), rules.styleWhitelist); if (style) node.setAttribute(style, style); else node.removeAttribute(style); } // 特殊标签处理 if (node.nodeName TABLE || node.nodeName TD || node.nodeName TH) { normalizeTable(node); } if (node.nodeName IMG) { sanitizeImage(node, rules.image); } if (node.nodeName A) { node.setAttribute(rel, noopener noreferrer); } // 递归子节点 Array.from(node.childNodes).forEach(walk); })(root); // 删除空段落 root.querySelectorAll(p, span, div).forEach(function (el) { if (isElementEmpty(el)) el.remove(); }); return root; // 调用方拿到处理后的 DocumentFragment }这段代码没有处理每个细节的边界情况但完整覆盖了主干流程。实际项目里建议在函数返回前最后再过一遍DOMPurify或者服务端再清洗一次防止XSS。之前我们讨论过“自定义规则负责还原语义DOMPurify负责安全兜底”这是我认为最稳的组合。4.2 接入不同编辑器的方式如果是原生contenteditable插入清理后的内容有几种办法。老派但兼容性极好的做法是document.execCommand(insertHTML, false, fragment);虽然execCommand已经标记废弃但在ContentEditable场景下目前仍然能用。新项目里更好的方案是拿到选中区域用Range.insertNodefunction insertHtmlAtCursor(fragment) { const selection window.getSelection(); if (!selection.rangeCount) return; const range selection.getRangeAt(0); range.deleteContents(); range.insertNode(fragment); }如果是Quill编辑器直接在clipboard.dangerouslyPasteHTML里传入清洗后的HTML即可TinyMCE可以在paste_preprocess回调里修改event.content。每个编辑器都有自己的粘贴接入点但核心思路不变拦截默认行为执行自定义过滤再以编辑器允许的方式注入。4.3 用测试数据锁住行为清洗规则很容易“修一个bug引出三个bug”所以我建议直接把典型输入输出固化成测试用例。比如输入片段期望输出断言点p classMsoNormalo:p/o:p/p空空段落被删除span stylemso-ansi-font-size:14.0pt; color:red;文本/spanspan stylecolor: red;文本/spanmso样式被过滤table width623.6pttrtd width200x/td/tr/tabletable宽度自适应表格宽度被归一化用Jest这类测试框架把真实Word复制出来的HTML存成fixture文件每次改规则后跑一遍快照能省下大量的手工回归时间。5. 常见问题与排查技巧实录以下是我在实际项目里遇到频率最高的问题整理成一个速查表。现象原因解决方案粘贴后只剩纯文本样式全丢text/html没读到或过滤时把 span 样式全删了检查getData(text/html)返回值确认 styleWhitelist 是否包含常用样式属性表格溢出容器页面被撑破Word表格固定宽度pt未被转换对TABLE强制max-width:100%去掉固定width图片不显示只有红叉图片src是file://网页无法访问从clipboardData.files读取图片文件转Base64或上传后插入粘贴进来一堆空白行o:p删掉后遗留空段落清理空p/span/div节点编号列表变成纯文本段落Word列表用了mso-list样式而非ol识别text-indent和list-style组合转换或建议用户粘贴时选“只保留文本”从Excel粘贴表格过大Excel会生成超大table行数上千限制最大行列数超过则提示改用纯文本粘贴Safari里读不到text/html旧版本Safari对clipboardData支持不完整检测不到HTML时回退到text/plain或升级适配排查这类问题有个实用技巧在paste事件里打一个断点把clipboardData.getData(text/html)完整复制出来存到本地当测试样本。多存几份不同类型的Word文档含表格、图片、公式、多级列表清洗规则改一次就跑一遍这批样本很多隐藏问题都能提前暴露。另一个经验是规则执行顺序比规则本身更容易被忽略。我的固定顺序是预处理删注释和style - 命名空间节点直接删 - 标签白名单过滤 - 属性过滤 - 样式过滤 - 表格和图片专项处理 - 空段落清理 - DOMPurify兜底。顺序一旦乱了比如先清理空段落再处理表格很容易把表格里的空单元格误删。6. 自研规则 vs 现成库到底怎么选6.1 四类方案的快速对比方案定位优点缺点适合场景DOMPurifyXSS净化安全可靠API简单不做语义还原Word样式还是脏任何方案的安全兜底sanitize-htmlHTML清洗配置丰富支持白名单/黑名单依赖配置深度Word特殊标签要自己定义Node端清洗或给清洗流程打底js-xssHTML过滤轻量可自定义规则对Office私有结构和样式处理弱简单评论、留言板自研规则引擎语义还原能精确处理Word结构、列表归一化、表格转换维护成本高需要充分测试在线文档、复杂表单场景DOMPurify这类库解决的是“安全”问题但把Word HTML直接交给它结果是XSS没了排版也基本没了它不会理解mso-char-indent-count是首行缩进的意思。反过来自研规则解决的是“还原”问题允许我把一个Word的拼音指南结构翻译成编辑器支持的简单标签。做在线文档这类强排版的产品自研是绕不开的。6.2 我推荐的自研兜底组合我不会完全自研一套安全过滤器安全领域专业的事交给专业库。实际落地是三层结构自定义规则引擎处理Word-浏览器HTML的语义转换DOMPurify对输出结果做白名单XSS清洗服务端再存一份净化后的HTML或纯文本备份。这三层缺一层都有隐患。只做第一层恶意用户贴一个script标签可能被过滤规则漏掉只做第二层Word结构还原会失败。组合起来过滤规则在“语义”层面工作DOMPurify在“安全”层面兜底各管各的边界清晰。6.3 规则配置在项目里怎么演进规则配置不要硬编码在组件里建议独立成模块给不同编辑器实例传不同规则。我见过一个项目同时存在“论坛编辑器”“知识库编辑器”“后台公告编辑器”三种场景粘贴需求完全不同。论坛只需要简单排版知识库要允许多级标题和表格公告后台则要严格控制样式。同一套引擎三份不同配置维护起来反而比三份复制粘贴的代码轻松。演进时注意版本化。规则会随着产品需求变化比如某天决定图片不再允许Base64存储回到image.allowDataURI: false历史内容是不受影响的它是存库时的快照但新粘贴的内容会按新规则生成。规则配置本身要有版本号方便回溯是哪次改动引起了行为变化。最后说一个实操体会做这个功能踩过最大的坑不是正则写不好也不是规则不够全而是总想着“完美还原Word”。后来我想通了一个原则过滤规则的目标不是把Word原封不动搬进网页而是在编辑器的语义范围内尽量保留用户可读的内容。凡是解析不出来的宁可丢掉也不要把一堆不可见残留塞给用户。根据我的经验Word粘贴里90%的“奇怪Bug”都出在“想保留的东西太多”。规则设计得再精细也比不上在paste拦截前先问一句这个编辑器到底允许哪些内容把答案写进白名单一切问题都从根上解决了。再分享一个小技巧开发时打开console把粘贴前后的HTML打印出来对比着看日积月累就是一套非常有价值的样本库下次再有人反馈“粘贴有问题”你拿出样本库跑一遍定位速度快得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →