wordpaste
v0.12.1
Published
Clean clipboard HTML from Microsoft Word, LibreOffice Writer, Google Docs, Outlook and Excel on paste, and keep equations editable as LaTeX (OMML and MathML). Zero dependencies. Tiptap, ProseMirror, Lexical or plain contenteditable.
Maintainers
Readme
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.
- Equations survive as editable LaTeX instead of screenshots
- Lists become real lists, nested, numbered by the browser again
- 4.3 kB gzipped, zero dependencies, types included
- One line to clean in any editor; equations render natively in Tiptap, elsewhere with a small math node of your own
- JavaScript and TypeScript, no framework
Contents
- Install
- Use it — Tiptap · ProseMirror · Lexical · Quill · Vanilla JavaScript · React, Next.js, Vue · Other editors
- Showing the maths — read this if equations look wrong
- What it handles
- Reading equations from a .docx —
ommlToLatexon its own, and Node - Security
- Limits
- Advanced
- Why this exists
Install
npm install wordpaste
# pnpm add wordpaste / yarn add wordpaste / bun add wordpasteNo 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/[email protected]';
</script>Use it
One line, wherever your editor lets you see a paste. Every example below is a single HTML file you can open and try.
Tiptap
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.
ProseMirror
The same prop, because Tiptap is built on ProseMirror.
import { EditorView } from 'prosemirror-view';
import { transformPastedHTML } from 'wordpaste';
new EditorView(element, { state, transformPastedHTML });Lexical
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,
);Quill
Quill 2 has no paste prop, so subclass its clipboard module. Quill's own
formula format renders the equations; one matcher maps the span onto it.
import Quill from 'quill';
import { transformPastedHTML } from 'wordpaste';
const Clipboard = Quill.import('modules/clipboard');
const Delta = Quill.import('delta');
class WordClipboard extends Clipboard {
onPaste(range, { html, text }) {
super.onPaste(range, { html: html && transformPastedHTML(html), text });
}
}
Quill.register('modules/clipboard', WordClipboard, true);
new Quill('#editor', {
theme: 'snow',
modules: {
clipboard: {
matchers: [['span[data-latex]', (node) => new Delta().insert({ formula: node.dataset.latex })]],
},
},
});formula needs KaTeX on the page as window.katex. Block equations become
inline; Quill has no display mode.
Vanilla JavaScript
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.
React, Next.js, Vue
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;Other editors
Where each editor lets you see the clipboard HTML, and what it does with the equation span afterwards. Cleaning is one line everywhere. Rendering the maths is native in Tiptap; elsewhere it is a small node, blot or rule of your own.
| Editor | Where to call transformPastedHTML | The equation span |
| --- | --- | --- |
| TinyMCE | paste_preprocess: (editor, args) => { args.content = transformPastedHTML(args.content); } | kept, shown as its LaTeX text |
| Froala | events: { 'paste.beforeCleanup': (html) => transformPastedHTML(html) } and wordPasteModal: false | kept, shown as its LaTeX text |
| CKEditor 5 | editor.editing.view.document.on('clipboardInput', (evt, data) => { if (typeof data.content === 'string') data.content = transformPastedHTML(data.content); }); and leave out PasteFromOffice, which would clean it again | dropped unless General HTML Support allows span[data-type][data-latex]; a math plugin then renders it |
| Plate | a plugin with inject: { plugins: { [HtmlPlugin.key]: { parser: { transformData: ({ data }) => transformPastedHTML(data) } } } }, the shape of Plate's own DocxPlugin, which you then leave out | needs one deserializer rule per math plugin |
| BlockNote | pasteHandler: ({ event, editor }) => { const html = event.clipboardData?.getData('text/html'); if (!html) return false; editor.pasteHTML(transformPastedHTML(html)); return true; } | dropped; BlockNote has no math node |
| Milkdown | ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, transformPastedHTML })), with @milkdown/plugin-clipboard 7.19.2 or later | rename to Milkdown's data-type="math_inline" in a parse rule |
| Trix | addEventListener('trix-before-paste', (e) => { if (e.paste.html) e.paste.html = transformPastedHTML(e.paste.html); }) | dropped |
| Svelte, Angular | you construct new Editor({ editorProps: { transformPastedHTML } }) yourself, so the Tiptap line is unchanged | rendered by @tiptap/extension-mathematics |
| Markdown | clean, then Turndown with a rule that turns [data-latex] into $…$ | needs that rule |
Showing the maths
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,
});
});What it handles
| 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.
Reading equations from a .docx
Everything above is about the clipboard. But the same OMML sits inside every
.docx, in word/document.xml, and many tools that read a .docx —
mammoth, docx-to-Markdown
converters, document ingestion for RAG — drop the equations on the floor.
ommlToLatex is the converter on its own, for that job.
import { ommlToLatex } from 'wordpaste';
ommlToLatex(
'<m:oMath><m:f><m:num><m:r>a</m:r></m:num><m:den><m:r>b</m:r></m:den></m:f></m:oMath>',
);
// '\\frac{a}{b}'String in, string out. It returns '' when the fragment cannot be parsed, so it
never throws mid-document. It reads OMML only — LibreOffice's MathML is handled
by transformPastedHTML, not here.
On Node there is no DOMParser. Supply one before the first call; that is
the only setup:
import { JSDOM } from 'jsdom';
globalThis.DOMParser = new JSDOM().window.DOMParser;It handles fractions (including \binom), sub-, super- and pre-scripts,
radicals, delimiters, ∑ ∫ ∏, functions, limits, over/underlines, accents
(\vec, \bar, \dot, \hat …), matrices and equation arrays. The full
construct table, with a live converter, is on the
OMML to LaTeX page.
Security
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.
Limits
- Images on the writer's disk are dropped. Word points at
file:///C:/…, a dead link on the web.https:anddata:images are kept. The real bytes arrive separately asclipboardData.files— uploading those is your app's job. - Browser first. It uses the native
DOMParser; on Node, supply one from jsdom as shown under Reading equations from a .docx. - Not a
.docxreader.transformPastedHTMLhandles what an editor puts on the clipboard. To get equations out of a file, unzip it yourself and hand the<m:oMath>fragments toommlToLatex.
Advanced
One extra export for a case the main function does not cover. You will probably never need it.
hasWordMath(html): boolean
True when the paste carries recoverable equations. Use it to decide whether the OMML conversion is worth running.
Do not use it to skip pasted images. Word puts a screenshot of each
equation on the clipboard next to the real figures, and nothing in
clipboardData.files says which is which — so bailing out of a paste to avoid
the screenshot silently drops the author's diagrams as well. Upload every
pasted file: an extra picture of an equation is one click to delete, a missing
figure is invisible until someone sits the exam.
Why this exists
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 |
Contributing
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.
