Skip to content

Repository files navigation

docx-to-prosemirror

Convert Word OpenXML (.docx) documents to ProseMirror / Tiptap JSON, as accurately as possible — direct XML parsing, zero required dependencies, stdlib only.

from docx_to_prosemirror import to_prosemirror

doc = to_prosemirror("report.docx")
# {"type": "doc", "content": [{"type": "heading", "attrs": {"level": 1}, ...}, ...]}

Why

Word's WordprocessingML is a flat run model — list membership, headings, and inheritance all live in properties (w:pStyle, w:numPr, w:rPr...), not tree structure. ProseMirror is a nested tree. Most converters go through an intermediate (Markdown, HTML) and lose fidelity on the way. This one parses document.xml, styles.xml, and numbering.xml directly and rebuilds the nesting itself.

Install

pip install docx-to-prosemirror

No required dependencies — parsing uses stdlib xml.etree.ElementTree and zipfile only.

Usage

Python API

from docx_to_prosemirror import to_prosemirror

# from a path
doc = to_prosemirror("report.docx")

# from bytes (e.g. an uploaded file)
doc = to_prosemirror(uploaded_file.read())

# from raw document.xml (degraded: no styles/numbering/media resolution)
doc = to_prosemirror(document_xml_bytes)

CLI

docx-to-prosemirror report.docx -o report.json
docx-to-prosemirror report.docx   # prints to stdout

Feeding straight into Tiptap

Output node/mark names match Tiptap's schema exactly, so the JSON drops directly into an editor with no remapping:

import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";

const res = await fetch("/api/convert", { method: "POST", body: formData });
const { doc } = await res.json();

new Editor({
  element: document.querySelector("#editor"),
  extensions: [StarterKit /* + Table, Image, Underline, ... as needed */],
  content: doc,
});

A full working example (FastAPI backend + standalone HTMX/Tiptap frontend) is in app.py / static/index.html — see "Demo app" below.

Framework-agnostic

The output is plain JSON matching the ProseMirror document schema shape — it works with any ProseMirror-based editor (Tiptap, remirror, a hand-rolled schema), not just Tiptap, as long as your schema defines matching node/mark names.

What it converts

Word feature ProseMirror output
Paragraphs, headings (style or w:outlineLvl) paragraph, heading (levels 1-6)
Bold, italic, underline, strikethrough bold, italic, underline, strike marks
Text color, highlight textStyle (color), highlight marks
Superscript / subscript superscript, subscript marks
Hyperlinks link mark, resolved via document.xml.rels
Bulleted / numbered lists, nested, custom start bulletList, orderedList, listItem
Tables, incl. colspan/rowspan cell merges table, tableRow, tableCell
Inline images image (embedded as base64 data: URI)
Paragraph alignment, indentation attrs.align, attrs.indent
Style inheritance (w:basedOn chains) resolved before conversion
Content controls / structured doc tags (w:sdt) unwrapped transparently
Field codes (TOC, PAGE, REF via fldSimple/fldChar) cached display result kept, field code discarded
Track changes (w:ins / w:del) insertions kept, deletions dropped
Footnotes / endnotes inline superscript reference + trailing "Notes" section
Quote styles, code styles blockquote, codeBlock

Node/mark names match Tiptap's schema (bold/italic, not the ProseMirror-basic strong/em) so the output can be fed straight into a Tiptap editor.

Known gaps

Judged low-value relative to complexity and left unhandled:

  • Floating textboxes, WordArt, and shapes without a blip (no image data to extract)
  • Per-level numbering restart overrides (w:lvlOverride)
  • Page/section breaks render as a line break, not a real page boundary (no block-level equivalent fits mid-paragraph in Tiptap's inline content model)

Performance

Pure stdlib xml.etree.ElementTree, no intermediate format, single-pass tree walk. Measured on an M-series Mac (Python 3.12, warm cache, mean of 30 runs):

Document Size Time/conversion Throughput
Simple template (3 tables, 20 headings) 58 KB 6.0 ms ~165/sec
Complex report (16 tables, 96 headings, nested lists, footnotes) 118 KB 29.6 ms ~34/sec

Scales with document complexity (paragraph/run count), not file size — a 865-paragraph, 16-table, 478-cell document converts in under 30ms.

Test results

Last run against this repo:

  • Offline suite (tests/test_convert.py) — 7/7 passed. Covers both bundled fixture docs, the raw-document.xml degraded input path, JSON round-tripping, and a pinned node/mark-count regression check.
  • Real-world corpus (tests/test_corpus.py, against superdoc-dev/docx-corpus, 736,706 real .docx files scraped from the public web across 20+ languages) — 120/120 sampled documents converted and passed schema validation, 0 failures. Sample is stratified across offsets spanning the full dataset, not one contiguous block, so it isn't accidentally all one language or source batch.

Every converted document is checked against a structural validator (tests/schema.py) that encodes Tiptap's actual node-nesting rules (e.g. tableRow may only contain tableCell, heading.attrs.level must be 1-6) — a document can be valid JSON and still be a tree Tiptap would reject; this catches that class of bug specifically, not just "didn't crash."

Demo app

A small FastAPI + HTMX + Tiptap app is included for interactively trying conversions:

pip install "docx-to-prosemirror[demo]"
uvicorn app:app --reload

Open http://127.0.0.1:8000, pick or upload a .docx, see it rendered live in a real Tiptap editor.

Testing

uv sync --group dev
uv run pytest tests/test_convert.py    # fast, offline
uv run pytest tests/test_corpus.py     # real-world corpus, needs network

test_corpus.py pulls a stratified sample (default 60, spread across languages/ document types) from superdoc-dev/docx-corpus — 736K+ real .docx files from the public web — converts each, and validates the output against the Tiptap schema's node-nesting rules. Override the sample size:

DOCX_CORPUS_SAMPLE_SIZE=500 uv run pytest tests/test_corpus.py

Downloaded files cache under tests/.corpus_cache/ (gitignored) so repeat runs don't re-fetch. The full 736K-document corpus isn't run in CI — a stratified sample catches the same class of bug (an XML shape the converter has never seen) without hammering a third-party mirror for hours.

License

MIT

About

Convert Word OpenXML (.docx) documents to ProseMirror / Tiptap JSON, as accurately as possible — direct XML parsing, zero required dependencies, stdlib only.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages