Clean Microsoft Word clipboard HTML on paste and keep the equations editable. OMML and MathML to LaTeX. 3.6 kB, zero dependencies, works with Tiptap, ProseMirror, Lexical or plain contenteditable.
7
stars
40
commits
TypeScript
primary language
Aug 23, 2026
updated
Clean Microsoft Word clipboard HTML — and keep the equations editable.
Try the live playground →
Paste your own document into a real editor. Runs in your browser.
Paste from Word into a web editor and two things go wrong. You get a wall of invisible formatting, and every equation turns into a flat picture nobody can edit again.
wordpaste is one function. Clipboard HTML in, clean HTML out.
import { transformPastedHTML } from 'wordpaste';
You do not need to know where the paste came from. Word, LibreOffice, Outlook, Excel, Google Docs — it handles all of them, and leaves ordinary HTML alone.
npm install wordpaste
pnpm add wordpaste
yarn add wordpaste
bun add wordpaste
No build step? Import it straight from a CDN. Pin the version — an unpinned URL is served from a stale browser cache after a release.
<script type="module">
import { transformPastedHTML } from 'https://esm.sh/wordpaste@0.10.1';
</script>
One line, wherever your editor lets you see a paste. Every example below is a single HTML file you can open and try.
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
new Editor({
element,
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
editorProps is a documented Tiptap option, typed as ProseMirror's
EditorProps. There is no plugin or extension to write.
The same prop, because Tiptap is built on ProseMirror.
import { EditorView } from 'prosemirror-view';
import { transformPastedHTML } from 'wordpaste';
new EditorView(element, { state, transformPastedHTML });
Lexical has no equivalent prop, so claim the paste command:
import { PASTE_COMMAND, COMMAND_PRIORITY_HIGH, $getRoot, $insertNodes } from 'lexical';
import { $generateNodesFromDOM } from '@lexical/html';
import { transformPastedHTML } from 'wordpaste';
editor.registerCommand(
PASTE_COMMAND,
(event) => {
const html = event.clipboardData?.getData('text/html');
if (!html) return false;
event.preventDefault();
const dom = new DOMParser().parseFromString(transformPastedHTML(html), 'text/html');
editor.update(() => {
$getRoot().selectEnd();
$insertNodes($generateNodesFromDOM(editor, dom));
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
No editor library. The native paste event carries the HTML:
import { transformPastedHTML } from 'wordpaste';
element.addEventListener('paste', (event) => {
const html = event.clipboardData.getData('text/html');
if (!html) return;
event.preventDefault();
document.execCommand('insertHTML', false, transformPastedHTML(html));
});
This inserts HTML directly, so sanitise it before you store it or show it to anyone else — see Security.
wordpaste has no UI and no state — it is a function, and the line that uses it
is editorProps: { transformPastedHTML } in every framework. Only your editor's
binding package changes.
// React — @tiptap/react
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
export function WordEditor() {
const editor = useEditor({
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
return <EditorContent editor={editor} />;
}
<!-- Vue 3 — @tiptap/vue-3 -->
<script setup>
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
const editor = useEditor({
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
</script>
<template>
<EditorContent :editor="editor" />
</template>
Next.js: importing wordpaste on the server is safe, but calling it there
throws ReferenceError: DOMParser is not defined. So the editor component needs
'use client' — which it needs anyway, since paste is a browser event. You do
not need next/dynamic or ssr: false.
To clean HTML on the server on purpose, supply a DOM first:
import { JSDOM } from 'jsdom';
globalThis.DOMParser = new JSDOM().window.DOMParser;
An equation comes out like this:
<span data-type="inline-math" data-latex="\frac{a}{b}">\frac{a}{b}</span>
The LaTeX is in the attribute and in the text. So if you do nothing, you see
the raw \frac{a}{b} on screen. That is deliberate — a visible clue beats a
blank space you cannot debug.
To make it look like maths, pick one:
Tiptap — install
@tiptap/extension-mathematics.
The output above is already its markup, so it just works.
Anything else — render it with KaTeX or MathJax:
document.querySelectorAll('[data-latex]').forEach((el) => {
katex.render(el.dataset.latex, el, {
displayMode: el.dataset.type === 'block-math',
throwOnError: false,
});
});
| You copy from | Formatting junk removed | Equations kept editable |
|---|---|---|
| Microsoft Word | Yes | Yes — OMML |
| LibreOffice Writer | Yes | Yes — MathML |
| Google Docs | Yes | No |
| Outlook | Yes | n/a |
| Excel | Yes | n/a |
| Anything else | Colour only | — |
Bold, italic, underline, super/subscript, links, lists, tables and text-align
survive. Colour, highlight, font family and font size do not — that is the
point, so the source document's design does not leak into your app.
Lists become real lists. Word does not paste a list as a list — every item
is a <p> with the bullet or number sitting in the text as literal characters.
Strip the styling naively and you keep "1." and "2." frozen in place, so
reordering or inserting an item leaves the numbering wrong forever. wordpaste
reads Word's markers before discarding them and rebuilds <ul>/<ol>, with
nesting, the original sequence (1. a. i. I.) and a start when the list
does not begin at 1.
Your editor has to want text-align. wordpaste emits
style="text-align:center", but an editor drops any style its schema has no
rule for. In Tiptap that means adding
@tiptap/extension-text-align.
Google Docs needs its own handling. It wraps every paste in
<b style="font-weight:normal"> — strip that style and the whole paste turns
bold. It also stores bold and italic as inline styles rather than tags, so those
become real <strong> and <em> before the fonts are dropped.
Its equations cannot be recovered by anyone: Docs puts them on the clipboard as images already. Word is the unusual one — it sends the picture and the real maths, which is the gap this package exploits.
wordpaste is not a sanitiser. It removes formatting junk, not dangerous
markup — <script>, <iframe>, inline onclick/onerror handlers and
javascript: URLs pass straight through.
Inside Tiptap, ProseMirror or Lexical their schema drops all of that before
rendering, so nothing more is needed. If you insert the output yourself with
innerHTML or insertHTML, sanitise first:
import DOMPurify from 'dompurify';
import { transformPastedHTML } from 'wordpaste';
element.innerHTML = DOMPurify.sanitize(transformPastedHTML(html), {
ADD_ATTR: ['data-latex', 'data-type'],
});
ADD_ATTR keeps the equation attributes, which DOMPurify strips by default.
See SECURITY.md.
file:///C:/…,
a dead link on the web. https: and data: images are kept. The real bytes
arrive separately as clipboardData.files — uploading those is your app's job..docx reader. This handles what an editor puts on the clipboard.Two extra exports for cases the main function does not cover. You will probably never need them.
hasWordMath(html): booleanTrue when the paste carries recoverable equations — use it to decide whether to run the OMML conversion.
Do not use it to skip pasted images. An earlier version of this page showed that, and it loses figures:
// DON'T — this discards every picture in the paste to avoid one
onPaste: (editor, files, pasteContent) => {
if (pasteContent && hasWordMath(pasteContent)) return;
// …your normal image upload
},
Word puts a screenshot of each equation on the clipboard alongside the real
figures, and nothing in the clipboard says which file is which:
DataTransfer.files
guarantees no ordering and has no documented relationship to the text/html
flavour. Bailing out of the whole paste is the only way to be sure you skipped
the equation screenshot — and it is also the way to be sure you lost the
author's diagrams, silently.
Upload them all instead. An extra picture of an equation is visible and takes one click to delete; a missing figure is invisible until someone sits the exam:
import { hasWordMath } from 'wordpaste';
FileHandler.configure({
// Upload every pasted file. The HTML path still converts the OMML into
// editable math, so the equation is not lost either way.
onPaste: (editor, files) => {
files.forEach((file) => upload(file));
},
});
ommlToLatex(omml): stringConverts Word's equation markup to a LaTeX string on its own. This is for
reading a .docx file, not the clipboard —
mammoth has no equation support,
so this fills that gap.
Handles fractions, sub/superscripts, radicals, delimiters, n-ary operators (∑ ∫ ∏), functions, limits, overline, accent and matrices, plus the unicode maths glyphs Word emits as plain text.
Clean Office paste is a paid feature nearly everywhere:
| Free | Works outside its own editor | Equations | |
|---|---|---|---|
| TinyMCE PowerPaste | No — paid subscriptions only | No | No |
| CKEditor paste-from-office-enhanced | No — premium | No | No |
| CKEditor paste-from-office | GPL or commercial | No | No |
| Tiptap Conversion | No — from $49/mo | No | — |
| tinymce-word-paste-filter | Yes | Yes | No |
| wordpaste | Yes, MIT | Yes | Yes |
Bug reports are welcome — please include the raw clipboard HTML that reproduces
it. Pull requests too; run npm test first.
New features are considered but not promised. This package is deliberately small, and staying small is the point.
40 commits
TypeScript
93.4%
JavaScript
6.6%
Clean Microsoft Word clipboard HTML on paste and keep the equations editable. OMML and MathML to LaTeX. 3.6 kB, zero dependencies, works with Tiptap, ProseMirror, Lexical or plain contenteditable.
7
stars
40
commits
TypeScript
primary language
Aug 23, 2026
updated
Clean Microsoft Word clipboard HTML — and keep the equations editable.
Try the live playground →
Paste your own document into a real editor. Runs in your browser.
Paste from Word into a web editor and two things go wrong. You get a wall of invisible formatting, and every equation turns into a flat picture nobody can edit again.
wordpaste is one function. Clipboard HTML in, clean HTML out.
import { transformPastedHTML } from 'wordpaste';
You do not need to know where the paste came from. Word, LibreOffice, Outlook, Excel, Google Docs — it handles all of them, and leaves ordinary HTML alone.
npm install wordpaste
pnpm add wordpaste
yarn add wordpaste
bun add wordpaste
No build step? Import it straight from a CDN. Pin the version — an unpinned URL is served from a stale browser cache after a release.
<script type="module">
import { transformPastedHTML } from 'https://esm.sh/wordpaste@0.10.1';
</script>
One line, wherever your editor lets you see a paste. Every example below is a single HTML file you can open and try.
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
new Editor({
element,
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
editorProps is a documented Tiptap option, typed as ProseMirror's
EditorProps. There is no plugin or extension to write.
The same prop, because Tiptap is built on ProseMirror.
import { EditorView } from 'prosemirror-view';
import { transformPastedHTML } from 'wordpaste';
new EditorView(element, { state, transformPastedHTML });
Lexical has no equivalent prop, so claim the paste command:
import { PASTE_COMMAND, COMMAND_PRIORITY_HIGH, $getRoot, $insertNodes } from 'lexical';
import { $generateNodesFromDOM } from '@lexical/html';
import { transformPastedHTML } from 'wordpaste';
editor.registerCommand(
PASTE_COMMAND,
(event) => {
const html = event.clipboardData?.getData('text/html');
if (!html) return false;
event.preventDefault();
const dom = new DOMParser().parseFromString(transformPastedHTML(html), 'text/html');
editor.update(() => {
$getRoot().selectEnd();
$insertNodes($generateNodesFromDOM(editor, dom));
});
return true;
},
COMMAND_PRIORITY_HIGH,
);
No editor library. The native paste event carries the HTML:
import { transformPastedHTML } from 'wordpaste';
element.addEventListener('paste', (event) => {
const html = event.clipboardData.getData('text/html');
if (!html) return;
event.preventDefault();
document.execCommand('insertHTML', false, transformPastedHTML(html));
});
This inserts HTML directly, so sanitise it before you store it or show it to anyone else — see Security.
wordpaste has no UI and no state — it is a function, and the line that uses it
is editorProps: { transformPastedHTML } in every framework. Only your editor's
binding package changes.
// React — @tiptap/react
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
export function WordEditor() {
const editor = useEditor({
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
return <EditorContent editor={editor} />;
}
<!-- Vue 3 — @tiptap/vue-3 -->
<script setup>
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import { transformPastedHTML } from 'wordpaste';
const editor = useEditor({
extensions: [StarterKit],
editorProps: { transformPastedHTML },
});
</script>
<template>
<EditorContent :editor="editor" />
</template>
Next.js: importing wordpaste on the server is safe, but calling it there
throws ReferenceError: DOMParser is not defined. So the editor component needs
'use client' — which it needs anyway, since paste is a browser event. You do
not need next/dynamic or ssr: false.
To clean HTML on the server on purpose, supply a DOM first:
import { JSDOM } from 'jsdom';
globalThis.DOMParser = new JSDOM().window.DOMParser;
An equation comes out like this:
<span data-type="inline-math" data-latex="\frac{a}{b}">\frac{a}{b}</span>
The LaTeX is in the attribute and in the text. So if you do nothing, you see
the raw \frac{a}{b} on screen. That is deliberate — a visible clue beats a
blank space you cannot debug.
To make it look like maths, pick one:
Tiptap — install
@tiptap/extension-mathematics.
The output above is already its markup, so it just works.
Anything else — render it with KaTeX or MathJax:
document.querySelectorAll('[data-latex]').forEach((el) => {
katex.render(el.dataset.latex, el, {
displayMode: el.dataset.type === 'block-math',
throwOnError: false,
});
});
| You copy from | Formatting junk removed | Equations kept editable |
|---|---|---|
| Microsoft Word | Yes | Yes — OMML |
| LibreOffice Writer | Yes | Yes — MathML |
| Google Docs | Yes | No |
| Outlook | Yes | n/a |
| Excel | Yes | n/a |
| Anything else | Colour only | — |
Bold, italic, underline, super/subscript, links, lists, tables and text-align
survive. Colour, highlight, font family and font size do not — that is the
point, so the source document's design does not leak into your app.
Lists become real lists. Word does not paste a list as a list — every item
is a <p> with the bullet or number sitting in the text as literal characters.
Strip the styling naively and you keep "1." and "2." frozen in place, so
reordering or inserting an item leaves the numbering wrong forever. wordpaste
reads Word's markers before discarding them and rebuilds <ul>/<ol>, with
nesting, the original sequence (1. a. i. I.) and a start when the list
does not begin at 1.
Your editor has to want text-align. wordpaste emits
style="text-align:center", but an editor drops any style its schema has no
rule for. In Tiptap that means adding
@tiptap/extension-text-align.
Google Docs needs its own handling. It wraps every paste in
<b style="font-weight:normal"> — strip that style and the whole paste turns
bold. It also stores bold and italic as inline styles rather than tags, so those
become real <strong> and <em> before the fonts are dropped.
Its equations cannot be recovered by anyone: Docs puts them on the clipboard as images already. Word is the unusual one — it sends the picture and the real maths, which is the gap this package exploits.
wordpaste is not a sanitiser. It removes formatting junk, not dangerous
markup — <script>, <iframe>, inline onclick/onerror handlers and
javascript: URLs pass straight through.
Inside Tiptap, ProseMirror or Lexical their schema drops all of that before
rendering, so nothing more is needed. If you insert the output yourself with
innerHTML or insertHTML, sanitise first:
import DOMPurify from 'dompurify';
import { transformPastedHTML } from 'wordpaste';
element.innerHTML = DOMPurify.sanitize(transformPastedHTML(html), {
ADD_ATTR: ['data-latex', 'data-type'],
});
ADD_ATTR keeps the equation attributes, which DOMPurify strips by default.
See SECURITY.md.
file:///C:/…,
a dead link on the web. https: and data: images are kept. The real bytes
arrive separately as clipboardData.files — uploading those is your app's job..docx reader. This handles what an editor puts on the clipboard.Two extra exports for cases the main function does not cover. You will probably never need them.
hasWordMath(html): booleanTrue when the paste carries recoverable equations — use it to decide whether to run the OMML conversion.
Do not use it to skip pasted images. An earlier version of this page showed that, and it loses figures:
// DON'T — this discards every picture in the paste to avoid one
onPaste: (editor, files, pasteContent) => {
if (pasteContent && hasWordMath(pasteContent)) return;
// …your normal image upload
},
Word puts a screenshot of each equation on the clipboard alongside the real
figures, and nothing in the clipboard says which file is which:
DataTransfer.files
guarantees no ordering and has no documented relationship to the text/html
flavour. Bailing out of the whole paste is the only way to be sure you skipped
the equation screenshot — and it is also the way to be sure you lost the
author's diagrams, silently.
Upload them all instead. An extra picture of an equation is visible and takes one click to delete; a missing figure is invisible until someone sits the exam:
import { hasWordMath } from 'wordpaste';
FileHandler.configure({
// Upload every pasted file. The HTML path still converts the OMML into
// editable math, so the equation is not lost either way.
onPaste: (editor, files) => {
files.forEach((file) => upload(file));
},
});
ommlToLatex(omml): stringConverts Word's equation markup to a LaTeX string on its own. This is for
reading a .docx file, not the clipboard —
mammoth has no equation support,
so this fills that gap.
Handles fractions, sub/superscripts, radicals, delimiters, n-ary operators (∑ ∫ ∏), functions, limits, overline, accent and matrices, plus the unicode maths glyphs Word emits as plain text.
Clean Office paste is a paid feature nearly everywhere:
| Free | Works outside its own editor | Equations | |
|---|---|---|---|
| TinyMCE PowerPaste | No — paid subscriptions only | No | No |
| CKEditor paste-from-office-enhanced | No — premium | No | No |
| CKEditor paste-from-office | GPL or commercial | No | No |
| Tiptap Conversion | No — from $49/mo | No | — |
| tinymce-word-paste-filter | Yes | Yes | No |
| wordpaste | Yes, MIT | Yes | Yes |
Bug reports are welcome — please include the raw clipboard HTML that reproduces
it. Pull requests too; run npm test first.
New features are considered but not promised. This package is deliberately small, and staying small is the point.
40 commits
TypeScript
93.4%
JavaScript
6.6%