Paste one sentence from Microsoft Word into a contenteditableand inspect what arrives. It is routinely 30–40 KB. There will be conditional comments, a <style> block of mso- declarations, several nested <span>s carrying font stacks you have never heard of, and a class name like MsoNormal doing more work than seems reasonable.
The instinct is to strip everything and keep the text. That is the wrong correction, because some of that markup is the only record of what the author meant — and once you have discarded it on paste, no later migration gets it back.
Why the markup is so heavy
Word and Google Docs are not producing HTML for the web. They are producing HTML as an interchange format for themselves, so it has to round-trip back into their own model without loss. That means encoding everything explicitly, because nothing can be inferred from a stylesheet that will not travel with it.
Three specific consequences worth knowing:
Word’s lists are not lists. A bulleted list frequently arrives as a sequence of <p class="MsoListParagraph"> elements, each beginning with a literal bullet character in a symbol font, with indentation expressed as margin-left and the list structure encoded in an mso-list property. There is no <ul> anywhere. Strip the presentational attributes naively and you have destroyed the only evidence that this was a list at all.
Google Docs uses ids, not semantics. Its clipboard HTML wraps content in <b style="font-weight:normal"> — a bold element explicitly styled not to be bold — and applies formatting through generated class names defined in an accompanying <style> block. Drop the style block and keep the classes and everything becomes unstyled. Keep the style block and you have injected a stylesheet into your document.
Everything is inline-styled. Both produce explicit font-family, font-size, color and line-heighton nearly every element, because they cannot assume your CSS. Preserved verbatim, pasted content permanently ignores your site’s typography and looks like a ransom note.
The organising principle: semantics in, presentation out
The useful line is not “how much markup” but whether an attribute records a decision the author made or a rendering detail their word processor happened to apply.
“This is a heading” is a decision. “This is 14pt Calibri with 1.08 line height” is almost never one — it is the document default. But “this word is red” usually is a decision, and blanket-stripping colour throws it away.
So the filter is not one rule. Roughly:
- Keep: headings, lists, tables, links, bold/italic/underline, block quotes, images with their alt text, and superscript/subscript.
- Keep, but normalise: colour and highlight (they are usually intentional), text alignment, and explicit font size where it differs from the document default.
- Discard: font families, line heights, margins that merely reproduce Word’s defaults,
mso-*anything, empty spans, conditional comments, and class names that reference a stylesheet you are not keeping. - Reconstruct: Word’s pseudo-lists into real
<ul>/<ol>before stripping the evidence that they were lists.
That reconstruction step is the one most implementations skip, and it is the one users notice, because a pasted list that is not a list cannot be continued by pressing Enter.
Order of operations matters
The steps interact, and doing them in the wrong sequence destroys information you needed two steps later:
- Parse inertly first. Use
DOMParser, never assignment to a live element’sinnerHTML. Assigning hostile markup to a live node fires<img onerror>before your filter has run a single line. - Reconstruct structure — pseudo-lists into real lists — while
mso-list, margins and marker characters are still present. - Resolve class-based styling from any accompanying
<style>block into inline styles you intend to keep, so the block itself can be discarded. - Filter attributes and styles per the keep/discard policy.
- Sanitise for security as a separate, final, non-negotiable pass. Cleaning for tidiness and filtering for safety are different jobs with different failure modes, and a tidiness pass must never be mistaken for a security boundary.
- Collapse the leftovers — spans with no remaining attributes, nested identical tags, empty paragraphs.
Filter styles per declaration
When you do keep a style attribute, filter it one declaration at a time rather than by editing the string. Substring removal manufactures values that pass your next check:
// Wrong — this creates a value that then looks harmless:
// background: url(javascript:steal()) -> background: url(steal())
value.replace(/javascript:/gi, "");
// Right — drop the whole declaration, keep its siblings:
const kept = value.split(";")
.map(d => d.trim())
.filter(Boolean)
.filter(d => !/expression\s*\(|(javascript|vbscript)\s*:|-moz-binding|@import/i.test(d))
.filter(d => !/font-family|line-height|mso-/i.test(d)); // presentation, not decision
// color: red; font-family: Calibri; height: 10px -> color: red; height: 10pxThe same reasoning applies to nested constructs: a regex like /expression\s*\([^)]*\)/ stops at the inner parenthesis of expression(alert(1)) and leaves debris behind.
Offer the escape hatch
However good the filter, some paste will bring something unwanted. Two affordances cost almost nothing and remove most of the frustration:
- Ctrl+Shift+V for plain text.Users already expect it. It is one keybinding and it converts “your editor mangled my paste” into a solved problem the user controls.
- An undoable “keep formatting / remove formatting” prompt immediately after a heavy paste. The key word is undoable — the prompt must not commit anything the user cannot reverse with a single Ctrl+Z.
How to test it
Paste filters rot, because the sources change — a Word update alters its clipboard output and nobody notices for a year. Capture real clipboard payloads as fixtures and assert on the result:
// Fixture: actual clipboard HTML captured from Word 365
const cleaned = editor.sanitizeHtml(pasteFilter(WORD_BULLETED_LIST));
assert.match(cleaned, /<ul>/); // reconstructed, not <p class=MsoListParagraph>
assert.equal((cleaned.match(/<li>/g) || []).length, 3);
assert.doesNotMatch(cleaned, /mso-|MsoNormal|font-family/i);
assert.match(cleaned, /color:\s*#?red|#FF0000/i); // an intentional colour SURVIVED
assert.doesNotMatch(cleaned, /<style|<!--\[if/);Note the fourth assertion. Most paste tests only check that unwanted things are gone, which passes trivially if you strip everything. Assert that wanted things survived— that is the half that catches an over-aggressive filter, and over-aggressive filters generate more support tickets than permissive ones, because they lose work the user did on purpose.
And keep the security pass tested separately, against payloads rather than fixtures. We wrote that up in “nothing executes in the editor” is the wrong bar — including why testing inside the editor misses the case that matters.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.