smrifat1411/wordpaste

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

smrifat1411.github.io/wordpaste/
clipboard
contenteditable
docx
katex
latex
lexical
mathml
omml
paste-from-word
prosemirror
rich-text-editor
tiptap
typescript
word
wysiwyg
zero-dependency
Browse cluster: LaTeX and Math Typesetting for Web

README

wordpaste

Clean Microsoft Word clipboard HTML — and keep the equations editable.

View on npm CI npm version minified and gzipped size zero dependencies MIT license

Tiptap ProseMirror Lexical JavaScript React Next.js Vue

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.

  • Equations survive as editable LaTeX instead of screenshots
  • Lists become real lists, nested, numbered by the browser again
  • 3.6 kB gzipped, zero dependencies, types included
  • One line to wire into any editor
  • JavaScript and TypeScript, no framework

Contents

Install

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>

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

▶ Run this example

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

▶ Run this example

The same prop, because Tiptap is built on ProseMirror.

import { EditorView } from 'prosemirror-view';
import { transformPastedHTML } from 'wordpaste';

new EditorView(element, { state, transformPastedHTML });

Lexical

▶ Run this example

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,
);

Vanilla JavaScript

▶ Run this example

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

▶ React · ▶ 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;

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 fromFormatting junk removedEquations kept editable
Microsoft WordYesYes — OMML
LibreOffice WriterYesYes — MathML
Google DocsYesNo
OutlookYesn/a
ExcelYesn/a
Anything elseColour 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.

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: and data: images are kept. The real bytes arrive separately as clipboardData.files — uploading those is your app's job.
  • Browser only, unless you supply jsdom as shown under Next.js.
  • Not a .docx reader. This handles what an editor puts on the clipboard.

Advanced

Two extra exports for cases the main function does not cover. You will probably never need them.

hasWordMath(html): boolean

True 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): string

Converts 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.

Why this exists

Clean Office paste is a paid feature nearly everywhere:

FreeWorks outside its own editorEquations
TinyMCE PowerPasteNo — paid subscriptions onlyNoNo
CKEditor paste-from-office-enhancedNo — premiumNoNo
CKEditor paste-from-officeGPL or commercialNoNo
Tiptap ConversionNo — from $49/moNo
tinymce-word-paste-filterYesYesNo
wordpasteYes, MITYesYes

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.

Licence

MIT

Contributors

smrifat1411

40 commits

smrifat1411/wordpaste

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

smrifat1411.github.io/wordpaste/
clipboard
contenteditable
docx
katex
latex
lexical
mathml
omml
paste-from-word
prosemirror
rich-text-editor
tiptap
typescript
word
wysiwyg
zero-dependency
Browse cluster: LaTeX and Math Typesetting for Web

README

wordpaste

Clean Microsoft Word clipboard HTML — and keep the equations editable.

View on npm CI npm version minified and gzipped size zero dependencies MIT license

Tiptap ProseMirror Lexical JavaScript React Next.js Vue

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.

  • Equations survive as editable LaTeX instead of screenshots
  • Lists become real lists, nested, numbered by the browser again
  • 3.6 kB gzipped, zero dependencies, types included
  • One line to wire into any editor
  • JavaScript and TypeScript, no framework

Contents

Install

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>

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

▶ Run this example

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

▶ Run this example

The same prop, because Tiptap is built on ProseMirror.

import { EditorView } from 'prosemirror-view';
import { transformPastedHTML } from 'wordpaste';

new EditorView(element, { state, transformPastedHTML });

Lexical

▶ Run this example

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,
);

Vanilla JavaScript

▶ Run this example

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

▶ React · ▶ 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;

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 fromFormatting junk removedEquations kept editable
Microsoft WordYesYes — OMML
LibreOffice WriterYesYes — MathML
Google DocsYesNo
OutlookYesn/a
ExcelYesn/a
Anything elseColour 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.

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: and data: images are kept. The real bytes arrive separately as clipboardData.files — uploading those is your app's job.
  • Browser only, unless you supply jsdom as shown under Next.js.
  • Not a .docx reader. This handles what an editor puts on the clipboard.

Advanced

Two extra exports for cases the main function does not cover. You will probably never need them.

hasWordMath(html): boolean

True 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): string

Converts 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.

Why this exists

Clean Office paste is a paid feature nearly everywhere:

FreeWorks outside its own editorEquations
TinyMCE PowerPasteNo — paid subscriptions onlyNoNo
CKEditor paste-from-office-enhancedNo — premiumNoNo
CKEditor paste-from-officeGPL or commercialNoNo
Tiptap ConversionNo — from $49/moNo
tinymce-word-paste-filterYesYesNo
wordpasteYes, MITYesYes

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.

Licence

MIT

Contributors

smrifat1411

40 commits

Languages

TypeScript

93.4%

JavaScript

6.6%