Word粘贴内容如何过滤?富文本编辑器自定义规则实战

📅 发布时间:2026/9/30 7:34:10
Word粘贴内容如何过滤?富文本编辑器自定义规则实战
做网页富文本编辑器的人都清楚用户从 Word 里复制一段内容再粘到网页里是整个编辑器生命周期里最容易翻车的入口。我之前维护内部的在线文档系统时收到最多的工单就是从 Word 复制的表格又爆宽了标题前面的编号全丢了粘贴过来多出一堆莫名其妙的大空格。排查到最后问题的根子往往不在编辑器内核而在 Word 写入剪贴板的 HTML 太重——它附带了一整套文档格式描述这些描述在网页里恰好变成了污染源。要解决这个问题唯一的出路就是在 paste 阶段做一套可配置的自定义过滤规则把 Word 带来的垃圾标记清掉同时把用户真正需要的标题、列表、表格、图片等语义结构保留下来。这篇文章我就从实际项目角度完整聊一聊这套规则应该怎么设计、怎么实现以及我在生产环境里踩过哪些坑。1. 为什么 Word 粘贴是所有富文本编辑器的老大难1.1 一次真实的粘贴事故先讲一个典型场景。用户从 Word 文档里复制了带三级标题、一个无序列表和一张 3 列的表格粘到网页编辑器的正文区。保存之后再看页面标题变成了带一大堆langEN-US、stylefont-size: 10.5pt的 span列表全部消失变成了普通段落表格的宽度直接撑破容器最难受的是段落之间莫名多出十几个nbsp;空格。这类问题的本质不是某个 JS 库 bug而是 Word 往剪贴板里放的内容设计目标根本不是给你网页编辑器看的。它是给 Word 自己看的是给 Outlook 邮件客户端看的是一套完整的、带状态还原能力的专有文档描述。网页编辑器只是捡到了这堆东西能不能处理好完全取决于你有没有做过滤。1.2 Word 剪贴板到底塞了什么你在 Word 里按 CtrlCWord 会同时向剪贴板写入好几种格式的数据。Windows 上最常见的是CF_HTML、CF_RTF、UnicodeText可能还有图片、元文件等。Web 端通过clipboardData.getData(text/html)拿到的就是 CF_HTML 围绕的那段 HTML 片段。这段 HTML 和普通网页的 HTML 有很大区别。它外层带有 CF_HTML 头类似这样Version:1.0 StartHTML: 00000155 EndHTML: 00002523 StartFragment: 00000211 EndFragment: 00002412 StartSelection: 00000208 EndSelection: 00002400真正的内容包裹在!--StartFragment--和!--EndFragment--注释之间。这个片段一进来就是带着 Office 命名空间、内联样式、条件注释和各种怪异标签的半成品。你如果直接把它塞进编辑器等于把 Word 的内部状态图当成了网页结构图来用不乱才奇怪。1.3 我们真正需要保留的是什么做过滤规则之前必须先想清楚一个关键问题用户从 Word 复制内容进来业务上真正需要保留什么绝大多数在线编辑器的答案是保留语义结构而不是样式还原。用户在意的是标题层级、加粗、斜体、有序列表、表格的数据关系、图片本身而不是 Word 里的MsoNormal类名、精确到磅的段落间距、或者某个字体在用户本机是否存在。所以过滤规则的第一原则不是尽量保留所有东西而是只保留业务需要的其他一律清理。想通了这一点后面设计白名单时就会果断很多。那些喊着最好能完全还原 Word 效果的需求基本都走了弯路最后要么性能崩掉要么样式照样乱。2. 动手之前先看清 Word 粘贴 HTML 的真实面目2.1 解剖一份典型 Word 粘贴片段我在调试时最常用的一招从 Word 复制一小段含标题、正文、列表、表格的内容然后在paste事件里把getData(text/html)完整打印出来。下面是一份很典型的 Word HTML 片段我做了局部简化p classMsoNormal stylemargin-bottom: 0cm; mso-pagination: none; span stylefont-size: 10.5pt; mso-bidi-font-size: 11.0pt; langEN-USHello/span span stylefont-size: 10.5pt; font-family: quot;Microsoft YaHeiquot;, sans-serif;世界/span /p ul stylelist-style-type: disc; li stylemso-list: l0 level1 lfo1; text-indent: -21.35pt;项目 A/li /ul table classMsoTableGrid styleborder-collapse: collapse; border: none; width: 495.35pt; tr styleheight: 22.2pt; td stylewidth: 123.8pt; border: solid windowtext 1.0pt; padding: 0cm 5.4pt 0cm 5.4pt;内容/td /tr /table第一眼看上去好像还能接受至少标签都是常见的 p、span、table。但你注意细节mso-pagination、mso-bidi-font-size、MsoTableGrid、lfo1、windowtext、0cm……这些记号对网页来说全是噪音。更可怕的是真实内容里还会有大量!--[if gte mso 9]条件注释、o:p标签、v:shapetype矢量标记、m:oMath公式节点那是真正能把编辑器搞崩的东西。2.2 mso 前缀、命名空间和条件注释意味着什么mso是 Microsoft Office 的缩写。所有以mso-开头的内联样式属性比如mso-list、mso-pagination、mso-spacerun、mso-font-charset都是 Office 应用内部使用的元数据浏览器根本不认留着只会造成样式污染。命名空间也很典型。Word 会在 HTML 根节点上声明一堆 xmlnsxmlns:ourn:schemas-microsoft-com:office:officexmlns:wurn:schemas-microsoft-com:office:wordxmlns:mhttp://schemas.openxmlformats.org/officeDocument/2006/mathxmlns:vurn:schemas-microsoft-com:vml对应的o:p段落标记、w:...标签、m:oMath公式节点、v:shape矢量图形都是这些命名空间下的产物。正常情况下网页渲染引擎不会把o:p当块级元素处理所以这些标签会造成结构错乱过滤时必须整块移除或者做语义替换。条件注释是另一个重量级噪音。Word 的 HTML 里会夹杂大量类似!--[if gte mso 9]xmlw:WordDocument.../w:WordDocument/xml![endif]--的注释块里面塞的是文档属性、样式信息。DOMParser 解析后它们是注释节点如果不处理序列化时原样保留编辑器里会残留一堆看不见却拖慢性能的东西。2.3 浏览器差异同一份剪贴板四个结局同一份 Word 内容不同浏览器提供的text/html并不完全一致。Chrome 和 EdgeChromium 内核对 CF_HTML 支持得很好getData(text/html)拿到的就是干净片段通常从StartFragment开始。Safari 的行为略有差异偶尔会把整个htmlbody结构也包进来需要额外剥壳。Firefox 有时会丢一部分格式或者把换行变成br而不是段落这直接影响你过滤器要处理的输入范围。所以过滤规则的前置步骤必须处理两件事一是截取StartFragment到EndFragment之间的内容二是如果解析出的body里还有一层body取最内层。否则后面规则写得再好也可能因为输入污染而误判。3. 自定义过滤规则的核心决策3.1 在哪里拦截paste 事件与 clipboardData过滤规则的第一个落点是paste事件。基本流程是这样editor.addEventListener(paste, function (e) { const html e.clipboardData.getData(text/html); if (!html) return; // 纯文本粘贴不拦截 e.preventDefault(); const cleanHtml wordFilter(html); // 不同编辑器插入方式不同这里以 execCommand 为例 document.execCommand(insertHTML, false, cleanHtml); });这里有个常被忽略的点clipboardData.getData(text/html)在某些浏览器上会触发权限询问但通常一次用户手势内调用没问题。另外如果拿不到text/html不要拦截直接走浏览器默认粘贴否则会把纯文本粘贴也搞挂。3.2 为什么用 DOM 遍历而不是正则过滤 Word 粘贴内容我看到很多同学第一反应是写正则content.replace(/mso-[a-z-]:.../gi, )。正则能解决一小部分问题但它有两个致命缺陷。第一Word HTML 的标签嵌套极其复杂正则匹配标签和属性时很容易把/p和p搞错位或者误删掉用户内容里本来就有的字符串。第二正则处理不了节点语义。你很难用正则判断这个空 span 里面只剩一个 br要不要整体删掉。我更推荐的做法是用DOMParser把 HTML 字符串解析成 DOM然后用TreeWalker遍历节点对每个元素执行规则。这样做的好处是天然处理了嵌套结构可以精确地检查标签名、属性、样式、父子关系而且序列化回来的时候结构是完整的。实测下来处理几万字的 Word 文档解析加遍历加清理全过程也就几十毫秒性能完全没问题。3.3 白名单 vs 黑名单别在这犯懒过滤规则的大方向是选白名单还是黑名单我在这件事上吃过教训。早期我们图省事用黑名单也就是看到mso-就删、看到class就删、看到lang就删。结果 Word 升级一个版本或者用户从某个新的 Office 插件复制内容新的噪音样式又冒出来了我们只能追着补规则。白名单则完全相反我只允许某些标签、某些属性和某些样式存活剩下的全部丢弃。比如白名单里允许strong、em那不管 Word 用b还是font-weight:bold还是别的花样最终都会被归一化白名单里不允许id、class、lang那用户从别处复制来的任何标记也不会漏进来。白名单规则一旦稳定后续几乎不用维护因为它不依赖敌人会长什么样只依赖业务需要什么。3.4 把规则做成可配置的接口既然是自定义过滤规则就不要把规则写死在某个函数里。我在项目里习惯做成一个工厂函数业务方可以传入自己的配置const wordFilter createWordFilter({ keepTags: [ p, br, strong, em, span, a, ul, ol, li, table, thead, tbody, tr, td, th, blockquote, h1, h2, h3, h4, h5, h6, img, hr, code, pre ], keepAttrs: { a: [href, title, target], img: [src, alt, width, height], td: [colspan, rowspan], th: [colspan, rowspan] }, keepStyles: [ color, background-color, font-family, font-size, font-weight, font-style, text-align, text-decoration, margin, padding, width, height, border, vertical-align, line-height ], hooks: { onElement(node) { /* 业务自定义处理 */ } } });这个接口看起来简单但它定义了一套游戏规则标签层面管结构属性层面管链接和图片样式层面管排版hooks 层面管特殊业务。后面每提到自定义过滤规则其实都是在说这个配置对象的设计与扩展。4. 实现一套可落地的过滤规则4.1 标签、属性与注释的清理有了配置实现就顺理成章了。基本骨架可以这样写function createWordFilter(options {}) { const config Object.assign({}, DEFAULT_CONFIG, options); const keepTagSet new Set(config.keepTags); return function filter(html) { const doc new DOMParser().parseFromString(html, text/html); const root doc.body; stripCommentsAndFragments(root); cleanTree(root, config, keepTagSet); return serializeBody(root); }; }cleanTree负责对每个元素执行三类动作标签不在keepTagSet里的按规则处理。比如o:p直接移除标签但保留内部文本script、style、object直接连内容一起删m:oMath走公式降级逻辑。属性不在keepAttrs里的全部移除。尤其中了class、id、lang、dir、align这些重灾区。注释节点直接移除包括![if !supportLists]这种条件注释。这里有个细节移除标签时不能把里面的内容也丢了。正确的做法是拆包——比如o:p想保留内容就用replaceWith(...node.childNodes)如果判断是图片占位或者纯装饰节点才整体删除。不同类型的标签要区分对待。4.2 style 属性的定向过滤style是 Word HTML 里最乱的部分也是过滤规则的核心战场。我采用的做法是把内联样式字符串用分号拆成键值对然后只保留keepStyles里允许的键。这样mso-list、mso-pagination、mso-spacerun这一类的天然被丢弃font-family、color、width这些用户确实用得到的才留下。实际操作中还要加一些归一化逻辑。Word 的长度单位大量使用pt网页更习惯px。保留宽度时如果不转换浏览器也能识别pt但渲染出来经常比预期大而且表格宽度问题十有八九出在这。我的做法是把pt按 1pt 1.333px 换算成px顺便清掉0cm这种零长度单位。字体也有讲究。Word 可能带font-family: Calibri, Microsoft YaHei, sans-serif这一串。业务侧如果不想引入一堆用户本机没有的字体可以把字体列表里的通用族名称保留具体英文字体归一化成sans-serif或Arial中文字体保留Microsoft YaHei、SimSun这类常见字体。这个归一化不是技术必需但产品体验上有意义。4.3 列表和表格的特征还原列表是最容易失真的部分。Word 在剪贴板输出中通常不用ul/ol/li而是用带mso-list样式的段落p classMsoListParagraph stylemso-list: l0 level1 lfo1; text-indent: -21.35pt; span stylefont-family: Symbol;·/span项目一 /p如果只是粗暴清样式这个列表段会退化成普通段落用户看到的就是序号全没了。要还原至少要两步处理识别mso-list样式中的l0 level1 lfo1标记确定它属于哪个列表、第几层然后把外层p转成li把同一lfo标记的连续兄弟节点包进新建的ul或ol。当然有些产品不需要这么复杂的还原业务上接受列表退化为带缩进的段落。这不算错但要在需求和实现成本之间做取舍。从自定义规则的角度看这条逻辑应该作为可选规则存在而不是写死。表格的还原相对简单重点在宽度处理。我在 5.1 节会详细展开先说结论表格和单元格的width、height要走单位转换table上还要额外加一条max-width: 100%防止容器被撑破。4.4 图片与公式的降级策略Word 粘贴里往往包含图片。剪贴板 HTML 里的图片有两种形态一种是data:image/png;base64,...直接把图片数据内嵌在 HTML 里另一种是srcfile:///C:/Users/...或srcblob:...这种本地路径网页端根本拿不到。处理图片的策略不能一刀切。我的建议是data URI的图片按大小做判断通常超过 200KB 的要转交给上传接口换成后端返回的 CDN 地址file://或无效src的尝试用clipboardData.getData(text/plain)或text/rtf兜底看有没有图片对象拿不到就降级成占位文本。图片过滤规则一定要和你们自己的上传通道配合否则就算留下了src保存后照样裂图。公式更麻烦。MathType 公式在 Word 里通常以 OLE 对象形式存在HTML 里表现为o:OLEObject或object网页端直接处理基本没戏。Office 自带的公式OMML 格式则是m:oMath里面可以提取m:t文本。如果编辑器没有公式渲染能力最稳的做法是把整块公式替换成一个span classmath-formula[公式]/span占位至少保住文档的语义完整性如果后端有公式转换服务则提取m:t文本转成 LaTeX 再交给前端渲染。这个看业务投入。5. 实战踩坑表格宽度、公式与多来源粘贴5.1 表格宽度的薛定谔丢失我见过最普遍的问题就是表格爆宽。Word 粘贴过来的表格宽度属性往往写在每个td的style里还带着pt单位。比如width: 495.35pt换算成像素大约是 660px。这个宽度如果超过编辑器容器宽度表格就会把页面撑破。更糟的情况是有些td的宽度写在width属性上有些写在style里两处还不一致。过滤时如果统一删掉表格会退化成每列一样宽的默认布局用户觉得列宽丢了如果全保留又可能爆宽。我后来用的是折中方案把table、td、th上的width都转成px并保留但给table追加max-width: 100%。这样可以防止超宽同时尽量保留 Word 里的列宽比例。如果你希望列宽完全由前端容器自适应也可以把width全部删掉只保留内容但这就要产品接受列宽和 Word 不一致。5.2 MathType 公式、OMML 公式怎么处理才稳公式是另一个高频事故点。早期我们试图保留 OMML 节点让前端去解析m:oMath结果不同浏览器渲染效果差异很大而且用户从 WPS、MathType、Office 不同来源复制结构都不一样维护成本极高。现在我把公式策略定的很保守检测到m:oMath或o:OLEObject节点时先用内部方法提取里面的文本m:t里的内容能提取到就生成[公式: 提取内容]占位提取不到就统一生成[公式]占位。这样至少用户在保存后还能知道这里有一个公式不会静默丢内容。如果你们业务强依赖公式建议走后端转换路线把粘贴的 HTML 连同 RTF 一起传给后端后端用专门的解析库把 OLE 转成图片或 LaTeX。这是另一个话题但过滤规则层面你要留好钩子比如onFormula回调让业务方决定怎么替换。5.3 Outlook 和其他网页来源的干扰过滤规则不能只服务 Word。很多用户的内容是从 Outlook 邮件、企业微信聊天记录、其他网页复制来的这些来源的 HTML 同样可能带着大量内联样式。Outlook 是最典型的类 Word来源因为它的邮件编辑内核就是 Word。你从 Outlook 复制一段带签名和引文的内容HTML 里照样有MsoNormal、mso-样式不过签名部分往往嵌套层级更深。这时候一套基于白名单的规则就体现出优势了不用区分来源反正只保留结构白名单其余全清理。唯一要额外留意的是blockquoteOutlook 回复邮件时经常有多层引文业务上如果支持邮件排版通常会保留一层blockquote否则全拆成普通段落。普通网页复制的内容通常没那么多 mso 噪音但会有大量class和现代 CSS 样式。白名单规则会把它一并清理掉看起来好像删多了但实际上这保证了编辑器内容的统一性。你可以给配置加一个开关strictMode: false在保留更多网页样式和保证内容干净之间做权衡。5.4 幂等性与重复粘贴过滤规则还有一个容易被忽略的要求幂等性。也就是说把已经过滤过的内容再过滤一遍结果不能再变。如果二次过滤还会删东西说明第一次没删干净或者规则处理产生了新的噪音。比较典型的场景是空 span 嵌套。第一次遍历时可能先处理外层空 span 把它删了但暴露出来的内层 span 在当前轮次还没被访问到导致第二次过滤又有变化。解决方法是自底向上遍历或者对空 span 的删除做循环直到稳定。我在实现里会让cleanTree先处理深层节点再处理浅层节点这样结构性清理基本一次完成。6. 回归测试与性能验证6.1 一份可以复用的粘贴回归清单规则写完不是结束真正的坑在回归测试。我把项目里常用的测试场景整理成一张清单每次改规则都跑一遍场景操作预期结果Word 正文从 Word 复制 3 个普通段落保留段落、加粗、颜色无 MsoNormal 类Word 多级标题复制带一级/二级/三级标题的文档标题层级映射到 h1/h2/h3字号不乱Word 列表复制无序列表和有序列表还原为 ul/ol/li或按配置退化为缩进段落Word 表格复制 3x3 表格表格结构完整宽度不超容器列宽比例保留Word 图片复制含图片的段落data URI 走上传替换逻辑file:// 降级占位Word 公式复制 MathType 和 OMML 公式输出 [公式] 占位或后端转换结果Outlook 邮件复制带签名和引文的邮件内容无 mso 样式签名结构不散乱普通网页从普通网页复制带 class 的内容白名单外的 class 被清理正文可读纯文本从记事本复制纯文本不被拦截走原有纯文本粘贴逻辑这张表的核心价值在于它把用户真实操作和过滤规则的预期输出绑定在了一起。你改一行代码跑一遍这张表基本就知道有没有破坏已有功能。6.2 性能观察和大文档压测Word 大文档的量级有多恐怖我实测过一份带 200 多张表格、几十张图片、几十个公式的文档text/html字符串能到几兆。DOMParser 解析这种规模确实会有压力但 TreeWalker 遍历一遍通常在几十毫秒内完成。性能优化有几个关键点不要在遍历过程中频繁修改 DOM。先收集需要删除的节点引用遍历结束后统一删除。不要重复序列化。过滤期间不要反复调用innerHTML或outerHTML只在最后序列化一次。图片的 base64 字符串如果很大避免在过滤阶段做字符串级别的正则匹配直接读 DOM 节点的src属性判断。实测下来大多数编辑器场景下过滤耗时占比很低真正影响体验的是图片上传和后续渲染所以过滤规则不用过度优化。6.3 给后续维护者的一点建议最后说点维护经验。过滤规则一定要独立成模块不要和具体编辑器 API 耦合。我在项目里把createWordFilter放在一个纯 JS 文件里不依赖 jQuery不依赖 Vue也不依赖编辑器实例这样不管以后换编辑器内核还是把它接到服务端做 HTML 清洗都能直接复用。另外规则配置要留给业务层不要塞在过滤函数内部。不同产品对保留到什么程度的需求差异很大文档系统要保留列表和标题社区论坛可能只需要段落和图片知识库要保留代码块和表格。把白名单做成可配置项一个过滤函数就能服务多个项目。还有一个很有用的调试小技巧线上环境可以在 paste 事件里打一个日志把用户粘贴的原始 HTML 和过滤后的 HTML 各存一份。以后无论用户报告什么问题你都能回溯当时规则到底处理了什么而不是凭空猜。我们很多规则迭代就是在这些真实样本上做出来的比自己从 Word 手工复制各种样例高效得多。从最早追着 mso 正则补丁到今天这套以白名单为核心的规则结构我最大的体会是Word 粘贴过滤这件事真正值钱的部分不是那几行清理代码而是你决定留下什么、丢到什么程度、以及规则能不能随业务变化灵活调整。把这个设计想清楚后面所有技术细节就都顺理成章了。