☰
Word粘贴内容如何过滤?富文本编辑器自定义规则实战
2026/9/30 7:34:00 网站建设 项目流程

做网页富文本编辑器的人都清楚,用户从 Word 里复制一段内容再粘到网页里,是整个编辑器生命周期里最容易翻车的入口。我之前维护内部的在线文档系统时,收到最多的工单就是"从 Word 复制的表格又爆宽了""标题前面的编号全丢了""粘贴过来多出一堆莫名其妙的大空格"。排查到最后,问题的根子往往不在编辑器内核,而在 Word 写入剪贴板的 HTML 太"重"——它附带了一整套文档格式描述,这些描述在网页里恰好变成了污染源。

要解决这个问题,唯一的出路就是在 paste 阶段做一套可配置的自定义过滤规则:把 Word 带来的垃圾标记清掉,同时把用户真正需要的标题、列表、表格、图片等语义结构保留下来。这篇文章我就从实际项目角度,完整聊一聊这套规则应该怎么设计、怎么实现,以及我在生产环境里踩过哪些坑。

1. 为什么 Word 粘贴是所有富文本编辑器的"老大难"

1.1 一次真实的粘贴事故

先讲一个典型场景。用户从 Word 文档里复制了带三级标题、一个无序列表和一张 3 列的表格,粘到网页编辑器的正文区。保存之后再看,页面标题变成了带一大堆lang="EN-US"、style="font-size: 10.5pt"的 span,列表全部消失变成了普通段落,表格的宽度直接撑破容器,最难受的是段落之间莫名多出十几个 空格。

这类问题的本质不是某个 JS 库 bug,而是 Word 往剪贴板里放的内容,设计目标根本不是给你网页编辑器看的。它是给 Word 自己看的,是给 Outlook 邮件客户端看的,是一套完整的、带状态还原能力的专有文档描述。网页编辑器只是"捡"到了这堆东西,能不能处理好完全取决于你有没有做过滤。

1.2 Word 剪贴板到底塞了什么

你在 Word 里按 Ctrl+C,Word 会同时向剪贴板写入好几种格式的数据。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 class="MsoNormal" style="margin-bottom: 0cm; mso-pagination: none;"> <span style="font-size: 10.5pt; mso-bidi-font-size: 11.0pt;" lang="EN-US">Hello</span> <span style="font-size: 10.5pt; font-family: &quot;Microsoft YaHei&quot;, sans-serif;">世界</span> </p> <ul style="list-style-type: disc;"> <li style="mso-list: l0 level1 lfo1; text-indent: -21.35pt;">项目 A</li> </ul> <table class="MsoTableGrid" style="border-collapse: collapse; border: none; width: 495.35pt;"> <tr style="height: 22.2pt;"> <td style="width: 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 根节点上声明一堆 xmlns:

  • xmlns:o="urn:schemas-microsoft-com:office:office"
  • xmlns:w="urn:schemas-microsoft-com:office:word"
  • xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
  • xmlns:v="urn:schemas-microsoft-com:vml"

对应的<o:p>(段落标记)、<w:...>标签、<m:oMath>公式节点、<v:shape>矢量图形,都是这些命名空间下的产物。正常情况下网页渲染引擎不会把<o:p>当块级元素处理,所以这些标签会造成结构错乱,过滤时必须整块移除或者做语义替换。

条件注释是另一个重量级噪音。Word 的 HTML 里会夹杂大量类似<!--[if gte mso 9]><xml><w:WordDocument>...</w:WordDocument></xml><![endif]-->的注释块,里面塞的是文档属性、样式信息。DOMParser 解析后它们是注释节点,如果不处理,序列化时原样保留,编辑器里会残留一堆看不见却拖慢性能的东西。

2.3 浏览器差异:同一份剪贴板,四个结局

同一份 Word 内容,不同浏览器提供的text/html并不完全一致。Chrome 和 Edge(Chromium 内核)对 CF_HTML 支持得很好,getData('text/html')拿到的就是干净片段,通常从StartFragment开始。Safari 的行为略有差异,偶尔会把整个<html><body>结构也包进来,需要额外剥壳。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 class="MsoListParagraph" style="mso-list: l0 level1 lfo1; text-indent: -21.35pt;"> <span style="font-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 里;另一种是src="file:///C:/Users/..."或src="blob:..."这种本地路径,网页端根本拿不到。

处理图片的策略不能一刀切。我的建议是: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 class="math-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-样式,不过签名部分往往嵌套层级更深。这时候一套基于白名单的规则就体现出优势了:不用区分来源,反正只保留结构白名单,其余全清理。唯一要额外留意的是blockquote,Outlook 回复邮件时经常有多层引文,业务上如果支持邮件排版通常会保留一层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 粘贴过滤这件事,真正值钱的部分不是那几行清理代码,而是"你决定留下什么、丢到什么程度、以及规则能不能随业务变化灵活调整"。把这个设计想清楚,后面所有技术细节就都顺理成章了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询