DEV Community

smrifat1411
smrifat1411

Posted on

What Word actually puts on your clipboard

I was building an exam authoring tool. Teachers wrote their questions in Word — full of maths — and pasted them into the browser.

The equations came through as pictures. Flat images of what used to be an equation. Nobody could click into one to fix a typo in a denominator; the only option was to delete it and rebuild the whole thing by hand.

I assumed that would take an afternoon to fix.

The thing I got wrong first

My first instinct was the obvious one. Word pastes garbage, so strip the garbage. Kill the mso- styles, kill the XML namespaces, kill the classes, keep the plain tags underneath.

That works. The output is clean. It is also how you throw away the two most valuable things Word gave you.

Because Word does not just send a mess. It sends a mess with the good stuff buried in it, and the naive clean deletes the good stuff along with the noise.

What is actually on your clipboard

Copy something out of Word and you do not get a fragment. You get an entire HTML document, and it announces itself in the first line:

<html 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">
Enter fullscreen mode Exit fullscreen mode

That third namespace is the one that matters. xmlns:m is the maths namespace, and it is there because Word puts equations on the clipboard as OMML — Office Math Markup Language. The same structured maths that lives inside a .docx. Not a picture. The real thing.

Word also puts a rasterized picture of the equation on the clipboard, tucked inside a conditional comment as a fallback for anything that cannot do better.

So the equation is there twice. Once as an image. Once as actual maths.

And here is the annoying part: the image is a plain <img>, so it survives naive cleaning without a scratch. The OMML is a namespaced element, so it gets swept out with all the other Word noise.

Every paste handler I looked at keeps the picture and deletes the maths. Mine did too. That was the whole bug.

Then I found the lists

I fixed the equations, felt good about myself for roughly a day, and then someone pasted a numbered list.

Word does not paste a list as a list. There is no <ul>. There is no <li>. Every single item is a paragraph:

<p class=MsoListParagraphCxSpFirst style='mso-list:l0 level1 lfo1'>
  <![if !supportLists]>
    <span style='mso-list:Ignore'>1.<span style='font:7.0pt'>&nbsp;&nbsp;</span></span>
  <![endif]>
  First item
</p>
Enter fullscreen mode Exit fullscreen mode

I stared at this for a while before it clicked.

mso-list:l0 level1 lfo1 is the only thing on the entire clipboard that says this paragraph belongs to a list. l0 is which list. level1 is how deep it is nested. That is your entire structure, living in a CSS property.

The 1. is not a marker the browser draws. It is literal text. An actual character sitting in the paragraph.

Which means if you strip the styling and keep the text — the obvious thing to do — you get a paragraph with a number welded onto the front of it. It looks fine. Then the user inserts a new second item and gets 1, 1, 2, 3, and nothing renumbers, because nothing is numbering anything. It is not a list. It is five paragraphs wearing a list costume.

And the span holding that number is labelled mso-list:Ignore.

Word is telling you. Right there in the markup. This is presentation, not content, please do not treat it as text. And every naive cleaner treats it as text.

The bug that took me longest

Here is the one I actually want to write down, because it cost me an evening and it fails in the worst possible way.

I had a pipeline. Convert the maths, strip the conditional comments, strip the styles, done. Sensible order. Each step obviously correct.

The problem is step two. Word wraps those list markers in a conditional comment, and I was deleting every conditional comment I found, because the equation image fallbacks live in conditional comments too and I wanted those gone.

So by the time I got around to thinking about lists, the evidence was already in the bin. The mso-list styles were still there, but the markers — the only record of whether the list was numbered or bulleted, and where it started — were gone.

The correct order turns out to be:

  1. Convert the maths first, on the raw string. The HTML parser lowercases tag names, which quietly breaks anything looking for <m:oMath>.
  2. Do not nuke every conditional comment. Unwrap the list ones, delete the image ones.
  3. Rebuild the lists — group paragraphs by list id, nest by level, read each marker, then drop the marker spans.
  4. Now strip the styling. The structure is safe, so the mso- stuff carries no information any more.

Clean before you reconstruct and step four eats what step three needed.

What makes this genuinely nasty is that it does not throw. You do not get an error. You get clean, valid, perfectly reasonable HTML. It is just the wrong shape, and you will not find out until a user complains that their list will not renumber.

Three traps in the markers

That o is not the letter o. Word's second-level bullet is a hollow circle drawn in Courier New, and it comes through as the character o. Treat markers as ordinals and your bulleted list comes back with stray o's scattered through it. The reliable test is punctuation — ordered markers always end in . or ), bullets never do.

i. is ambiguous, i. ii. iii. is not. A list starting with i. could be roman numerals or the ninth letter of the alphabet. One marker cannot tell you. The run can. So read the whole sequence before you decide the list type.

There are two kinds of conditional comment, and they are not the same thing:

<!--[if gte mso 9]>  ...  <![endif]-->   downlevel-hidden — a real comment
<![if !supportLists]> ...  <![endif]>     downlevel-revealed — content is live
Enter fullscreen mode Exit fullscreen mode

The second one has no --. Its contents are meant to be displayed. Browsers parse it as a bogus comment, so you have to unwrap it as a string before the DOM ever sees it. One missing pair of hyphens is the difference between keeping your list structure and losing it.

Before and after

Naive cleaning:

<p><span>1.</span>First</p>
<p><span>a.</span>Nested</p>
<p><span>2.</span>Second</p>
Enter fullscreen mode Exit fullscreen mode

Reading the markers first:

<ol>
  <li>First
    <ol type="a"><li>Nested</li></ol>
  </li>
  <li>Second</li>
</ol>
Enter fullscreen mode Exit fullscreen mode

The first one looks clean. It is permanently broken. The second one is a list again, and the browser owns the numbering.

I put it in a package

The afternoon turned into rather more than an afternoon, so I pulled it out of the exam builder and published it: wordpaste. MIT, zero dependencies, about 3.5 kB gzipped.

npm install wordpaste
Enter fullscreen mode Exit fullscreen mode

One function in, clean HTML out:

import { transformPastedHTML } from 'wordpaste';

new Editor({
  extensions: [StarterKit],
  editorProps: { transformPastedHTML },
});
Enter fullscreen mode Exit fullscreen mode

That is Tiptap. ProseMirror takes the same prop because Tiptap is built on it. Lexical needs you to claim the paste command, and plain contenteditable takes about three lines. It also handles LibreOffice, Outlook, Excel and Google Docs, and leaves ordinary HTML alone.

If you would rather not take a dependency, honestly, fine — the ordering is the part worth stealing. Read the markers, rebuild, then clean. That is the whole insight, and it is about forty lines of work.

You can paste one of your own documents into the playground and see what your clipboard is really carrying. It runs entirely in your browser, nothing gets uploaded. I would recommend trying it with a document that has equations in it. It is a bit horrifying.

Top comments (0)