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}, ...}, ...]}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.
pip install docx-to-prosemirrorNo required dependencies — parsing uses stdlib xml.etree.ElementTree and zipfile
only.
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)docx-to-prosemirror report.docx -o report.json
docx-to-prosemirror report.docx # prints to stdoutOutput 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.
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.
| 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.
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)
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.
Last run against this repo:
- Offline suite (
tests/test_convert.py) — 7/7 passed. Covers both bundled fixture docs, the raw-document.xmldegraded input path, JSON round-tripping, and a pinned node/mark-count regression check. - Real-world corpus (
tests/test_corpus.py, againstsuperdoc-dev/docx-corpus, 736,706 real.docxfiles 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."
A small FastAPI + HTMX + Tiptap app is included for interactively trying conversions:
pip install "docx-to-prosemirror[demo]"
uvicorn app:app --reloadOpen http://127.0.0.1:8000, pick or upload a .docx, see it rendered live in a
real Tiptap editor.
uv sync --group dev
uv run pytest tests/test_convert.py # fast, offline
uv run pytest tests/test_corpus.py # real-world corpus, needs networktest_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.pyDownloaded 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.
MIT