Skip to main content

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.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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

docx_to_prosemirror-0.1.0.tar.gz (45.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

docx_to_prosemirror-0.1.0-py3-none-any.whl (18.5 kB view details)

Uploaded Python 3

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

Hashes for docx_to_prosemirror-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8d14ce36ca29f04f392c815978a4c83476fa52f2235ad40db382993f2ec04d8f
MD5 4896d3a26a690bef73c7d931ed8e7050
BLAKE2b-256 98b05b21cafe22276295950327c67efe7ad822f88c3fbae36e7d9b85ccb1fa94

See more details on using hashes here.

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

Hashes for docx_to_prosemirror-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ae71554fc34567b957de52345e1172b1046d78032ee449e3d2c7e258097e0be1
MD5 f89f845f5989a05545d214e989b46904
BLAKE2b-256 ec5040ac50ff3e4d9f0e680bc34f9fcac359e35ba0ed0e7f448dac0ec8dcb17f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page