npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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

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/[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

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

Quill

▶ Run this example

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

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

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: and data: images are kept. The real bytes arrive separately as clipboardData.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 .docx reader. transformPastedHTML handles what an editor puts on the clipboard. To get equations out of a file, unzip it yourself and hand the <m:oMath> fragments to ommlToLatex.

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.

Licence

MIT