A lot of company knowledge still lives in Word files: refund policies, onboarding guides, SOPs, proposal templates. If you want that content in a prompt, a RAG index or a chatbot's knowledge base, Markdown is a better format than the .docx itself or a flat text dump. It keeps headings, lists, tables and links, and it is easy to diff, chunk and review.
This post builds a DOCX-to-Markdown converter with Python's standard library only (zipfile and xml.etree.ElementTree), so it runs in a CI job, a small container or a serverless function without a pip install. It handles headings, bold and italic, links, nested bullet and numbered lists, tables, line breaks, images and tracked changes.
Disclosure: Appie.run publishes this post. Appie has a free DOCX to Markdown converter and a few other Word tools, mentioned near the end. The script below does not depend on them.
Earlier posts in this series cover converting CSV and JSON to Markdown tables and converting PDFs to Markdown for RAG.
What is inside a .docx
A .docx file is a ZIP archive of XML files. Rename one to .zip and open it, and you will find:
-
word/document.xml: the body. Paragraphs are<w:p>, runs of text with the same formatting are<w:r>, the text itself is in<w:t>, tables are<w:tbl>. -
word/styles.xml: style definitions. A paragraph only says<w:pStyle w:val="Heading1"/>; this file tells you what that style is. -
word/numbering.xml: list definitions. A list item only says "numbering 3, level 1"; this file tells you whether that level is a bullet or a number. -
word/_rels/document.xml.rels: where hyperlinks point and which file inword/media/each image is.
So a converter has four jobs: read the body in order, look up styles to find headings, look up numbering to tell bullets from numbers, and resolve relationships for links and images.
What a useful converter has to do
- Find headings by style name, not style ID. IDs such as
Heading1can differ between Word languages and between editors. The name (heading 1) and the outline level are more reliable. - Merge runs. Word splits text into runs for reasons you cannot see (spell check, edits, language tags). Without merging, you get output like
**annual** **plans**. - Handle "off" switches.
<w:b w:val="0"/>means not bold. A naive check for the<w:b>tag gets this wrong. - Handle both kinds of lists: numbering set directly on the paragraph, and numbering that comes from a style such as "List Bullet 2".
- Keep tables as tables, with pipes escaped and multi-paragraph cells joined.
- Keep tracked insertions and skip tracked deletions, so you convert the text a reader sees.
- Not pass through dangerous links. A Word file can contain
javascript:link targets.
The script
Save this as docx2md.py. It was tested with Python 3.13.5 on the files described below.
#!/usr/bin/env python3 """Convert a .docx file to Markdown with the Python standard library only.""" import posixpath import re import sys import zipfile import xml.etree.ElementTree as ET from pathlib import Path W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}" PR = "{http://schemas.openxmlformats.org/package/2006/relationships}" def read_xml(z, name): try: return ET.fromstring(z.read(name)) except KeyError: return None def is_on(el): """<w:b/> means on; <w:b w:val="0"/> or "false" means off.""" return el is not None and el.get(W + "val", "true") not in ("0", "false", "none") class Docx: def __init__(self, path, media_dir=None): self.z = zipfile.ZipFile(path) self.media_dir = Path(media_dir) if media_dir else None self.heading_level = self._load_styles() self.num_formats = self._load_numbering() self.rels = self._load_rels() self.counters = {} def _load_styles(self): """Map styleId -> heading level, using the style name or outline level.""" levels, self.style_num, self.style_depth = {}, {}, {} root = read_xml(self.z, "word/styles.xml") if root is None: return levels for st in root.iter(W + "style"): sid = st.get(W + "styleId") name_el = st.find(W + "name") name = (name_el.get(W + "val") if name_el is not None else "").lower() m = re.fullmatch(r"heading (\d)", name) outline = st.find(f"{W}pPr/{W}outlineLvl") deep = re.fullmatch(r"list (?:bullet|number) (\d)", name) # "List Bullet 2" if deep: self.style_depth[sid] = int(deep.group(1)) - 1 num = st.find(f"{W}pPr/{W}numPr") # list styles such as "List Bullet" if num is not None and num.find(W + "numId") is not None: ilvl = num.find(W + "ilvl") self.style_num[sid] = (num.find(W + "numId").get(W + "val"), ilvl.get(W + "val") if ilvl is not None else "0") if name == "title": levels[sid] = 1 elif m: levels[sid] = int(m.group(1)) elif outline is not None: levels[sid] = int(outline.get(W + "val")) + 1 return levels def _load_numbering(self): """Map (numId, ilvl) -> numFmt such as 'bullet' or 'decimal'.""" fmts = {} root = read_xml(self.z, "word/numbering.xml") if root is None: return fmts abstract = {} for an in root.findall(W + "abstractNum"): lv = {} for lvl in an.findall(W + "lvl"): f = lvl.find(W + "numFmt") lv[lvl.get(W + "ilvl")] = f.get(W + "val") if f is not None else "bullet" abstract[an.get(W + "abstractNumId")] = lv for num in root.findall(W + "num"): ref = num.find(W + "abstractNumId").get(W + "val") for ilvl, fmt in abstract.get(ref, {}).items(): fmts[(num.get(W + "numId"), ilvl)] = fmt return fmts def _load_rels(self): rels = {} root = read_xml(self.z, "word/_rels/document.xml.rels") if root is not None: for rel in root.findall(PR + "Relationship"): rels[rel.get("Id")] = rel.get("Target") return rels # ---- inline content ------------------------------------------------- def runs(self, parent, link=None): """Yield (text, bold, italic, link) segments for a paragraph.""" for child in parent: if child.tag == W + "r": rpr = child.find(W + "rPr") bold = rpr is not None and is_on(rpr.find(W + "b")) ital = rpr is not None and is_on(rpr.find(W + "i")) for el in child: if el.tag == W + "t": yield el.text or "", bold, ital, link elif el.tag == W + "tab": yield " ", bold, ital, link elif el.tag in (W + "br", W + "cr"): yield "\n", False, False, None elif el.tag == W + "drawing": yield self.image(el), False, False, None elif child.tag == W + "hyperlink": rid = child.get(R + "id") target = self.rels.get(rid) if rid else None if target is None and child.get(W + "anchor"): target = "#" + child.get(W + "anchor") if target and not re.match(r"(https?:|mailto:|#)", target): target = None # drop javascript:, file: and other odd targets yield from self.runs(child, target) elif child.tag in (W + "ins", W + "smartTag", W + "sdt", W + "sdtContent"): yield from self.runs(child, link) # tracked insertions, content controls def image(self, drawing): blip = drawing.find(f".//{A}blip") doc_pr = drawing.find(f".//{WP}docPr") alt = (doc_pr.get("descr") or doc_pr.get("name") or "") if doc_pr is not None else "" target = self.rels.get(blip.get(R + "embed")) if blip is not None else None if not target: return "" name = posixpath.basename(target) if self.media_dir: self.media_dir.mkdir(parents=True, exist_ok=True) part = posixpath.normpath(posixpath.join("word", target)) (self.media_dir / name).write_bytes(self.z.read(part)) return f"" # relative to the .md file return f"" def inline(self, p): # merge neighbouring segments with the same formatting merged = [] for text, b, i, link in self.runs(p): if merged and merged[-1][1:] == [b, i, link] and not text.startswith("!["): merged[-1][0] += text else: merged.append([text, b, i, link]) out = [] for text, b, i, link in merged: core = text.strip() if not core: out.append(text) continue lead = text[: len(text) - len(text.lstrip())] trail = text[len(text.rstrip()):] mark = "**" * b + "*" * i core = f"{mark}{core}{mark[::-1]}" if link: core = f"[{core}]({link})" out.append(lead + core + trail) return "".join(out) # ---- blocks --------------------------------------------------------- def paragraph(self, p): self.last_num = None text = self.inline(p).strip() ppr = p.find(W + "pPr") style = ppr.find(W + "pStyle") if ppr is not None else None sid = style.get(W + "val") if style is not None else None num = ppr.find(W + "numPr") if ppr is not None else None if not text: return None if sid in self.heading_level: plain = text.replace("**", "").replace("\n", " ") # headings are bold already return "#" * min(self.heading_level[sid], 6) + " " + plain num_id, ilvl = self.style_num.get(sid, (None, "0")) if num is not None and num.find(W + "numId") is not None: num_id = num.find(W + "numId").get(W + "val") ilvl_el = num.find(W + "ilvl") ilvl = ilvl_el.get(W + "val") if ilvl_el is not None else ilvl self.last_num = num_id if num_id and num_id != "0": fmt Source:https://dev.to/appierun_66/convert-word-docx-to-markdown-with-python-standard-library-only-for-llm-prompts-and-chatbot-5d15