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.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."
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
Release files for docx-to-prosemirror 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| docx_to_prosemirror-0.1.0.tar.gz | 45.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| docx_to_prosemirror-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 64.1 kB
Release files / docx_to_prosemirror-0.1.0.tar.gz
| Download URL | docx_to_prosemirror-0.1.0.tar.gz |
|---|---|
| Size | 45.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8d14ce36ca29f04f392c815978a4c83476fa52f2235ad40db382993f2ec04d8f
|
|
BLAKE2b-256 checksum How to use checksums |
98b05b21cafe22276295950327c67efe7ad822f88c3fbae36e7d9b85ccb1fa94
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / docx_to_prosemirror-0.1.0-py3-none-any.whl
| Download URL | docx_to_prosemirror-0.1.0-py3-none-any.whl |
|---|---|
| Size | 18.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ae71554fc34567b957de52345e1172b1046d78032ee449e3d2c7e258097e0be1
|
|
BLAKE2b-256 checksum How to use checksums |
ec5040ac50ff3e4d9f0e680bc34f9fcac359e35ba0ed0e7f448dac0ec8dcb17f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|