Convert Word OpenXML (.docx) documents to ProseMirror JSON, as accurately as possible
Project description
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file docx_to_prosemirror-0.1.0.tar.gz.
File metadata
- Download URL: docx_to_prosemirror-0.1.0.tar.gz
- Upload date:
- Size: 45.6 kB
- Tags: Source
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d14ce36ca29f04f392c815978a4c83476fa52f2235ad40db382993f2ec04d8f
|
|
| MD5 |
4896d3a26a690bef73c7d931ed8e7050
|
|
| BLAKE2b-256 |
98b05b21cafe22276295950327c67efe7ad822f88c3fbae36e7d9b85ccb1fa94
|
File details
Details for the file docx_to_prosemirror-0.1.0-py3-none-any.whl.
File metadata
- Download URL: docx_to_prosemirror-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.5 kB
- Tags: Python 3
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae71554fc34567b957de52345e1172b1046d78032ee449e3d2c7e258097e0be1
|
|
| MD5 |
f89f845f5989a05545d214e989b46904
|
|
| BLAKE2b-256 |
ec5040ac50ff3e4d9f0e680bc34f9fcac359e35ba0ed0e7f448dac0ec8dcb17f
|