DEV Community

appierun
appierun

Posted on

Convert Word (DOCX) to Markdown With Python (Standard Library Only) for LLM Prompts and Chatbot Knowledge

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 in word/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

  1. Find headings by style name, not style ID. IDs such as Heading1 can differ between Word languages and between editors. The name (heading 1) and the outline level are more reliable.
  2. 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**.
  3. Handle "off" switches. <w:b w:val="0"/> means not bold. A naive check for the <w:b> tag gets this wrong.
  4. Handle both kinds of lists: numbering set directly on the paragraph, and numbering that comes from a style such as "List Bullet 2".
  5. Keep tables as tables, with pipes escaped and multi-paragraph cells joined.
  6. Keep tracked insertions and skip tracked deletions, so you convert the text a reader sees.
  7. 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"![{alt}]({self.media_dir.name}/{name})"  # relative to the .md file
        return f"![{alt}]({name})"

    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 = self.num_formats.get((num_id, ilvl), "bullet")
            depth = int(ilvl) + self.style_depth.get(sid, 0)
            # restart deeper levels when a shallower item appears
            for key in [k for k in self.counters if k[0] == num_id and int(k[1]) > depth]:
                del self.counters[key]
            indent = "    " * depth
            if fmt == "bullet":
                return f"{indent}- {text}"
            n = self.counters[(num_id, ilvl)] = self.counters.get((num_id, ilvl), 0) + 1
            return f"{indent}{n}. {text}"
        if sid and sid.lower() in ("code", "sourcecode", "htmlpreformatted"):
            return f"```\n{''.join(t.text or '' for t in p.iter(W + 't'))}\n```"
        return text.replace("\n", "  \n")

    def table(self, tbl):
        rows = []
        for tr in tbl.findall(W + "tr"):
            cells = []
            for tc in tr.findall(W + "tc"):
                parts = [self.inline(p).strip() for p in tc.iter(W + "p")]
                cell = "<br>".join(x for x in parts if x).replace("\n", "<br>")
                cells.append(cell.replace("|", "\\|"))
            rows.append(cells)
        if not rows:
            return None
        width = max(len(r) for r in rows)
        rows = [r + [""] * (width - len(r)) for r in rows]
        lines = ["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width]
        lines += ["| " + " | ".join(r) + " |" for r in rows[1:]]
        return "\n".join(lines)

    def convert(self):
        body = read_xml(self.z, "word/document.xml").find(W + "body")
        blocks, prev_list, prev_num, prev_nested = [], False, None, False
        for el in body:
            if el.tag == W + "p":
                md = self.paragraph(el)
            elif el.tag == W + "tbl":
                md = self.table(el)
            elif el.tag == W + "sdt":  # content control, e.g. a table of contents
                inner = el.find(W + "sdtContent")
                paras = inner.findall(W + "p") if inner is not None else []
                md = "\n\n".join(filter(None, (self.paragraph(p) for p in paras)))
            else:
                continue
            if not md:
                continue
            if md.startswith("```\n") and blocks and blocks[-1].endswith("\n```"):
                blocks[-1] = blocks[-1][:-3] + md[4:]  # join consecutive code lines
                continue
            is_list = bool(re.match(r"\s*(-|\d+\.) ", md))
            same_list = is_list and prev_list and (md[0] == " " or prev_nested or self.last_num == prev_num)
            sep = "\n" if same_list else "\n\n"
            blocks.append((sep if blocks else "") + md)
            prev_list, prev_num = is_list, (self.last_num if is_list else None)
            prev_nested = is_list and md[0] == " "
        return "".join(blocks).strip() + "\n"


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: docx2md.py input.docx [media_dir] > output.md")
    print(Docx(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None).convert(), end="")
Enter fullscreen mode Exit fullscreen mode

Run it:

python3 docx2md.py policy.docx > policy.md          # images referenced by file name
python3 docx2md.py policy.docx media > policy.md    # also extracts images into ./media
Enter fullscreen mode Exit fullscreen mode

How the pieces fit

  • _load_styles maps each style ID to a heading level, using the style name (title, heading 1 to heading 6) or an outline level. It also records list styles such as "List Bullet" that carry their own numbering, and treats "List Bullet 2" or "List Number 3" as one or two levels deeper.
  • _load_numbering resolves each numId and level to a format. Word stores list formats in two hops (num points to abstractNum), which is why there are two loops.
  • runs walks a paragraph and yields (text, bold, italic, link) segments. It follows hyperlinks, tracked insertions (w:ins) and content controls. Deleted text lives in w:delText, which it never reads, so tracked deletions drop out.
  • inline merges neighbouring segments with identical formatting, then puts the ** and * markers inside any leading or trailing spaces, because ** bold** is not valid Markdown emphasis.
  • paragraph decides between heading, list item, code block and plain paragraph. Numbered items keep a counter per list and level, and deeper counters reset when a shallower item appears.
  • convert walks the body in order, so text and tables stay in their original sequence, and puts list items on consecutive lines so they render as one list.

Try it on a test document

We built a small test file with python-docx (used only to create the fixture; the converter does not need it). It has a Title, Heading 1 and Heading 2, a paragraph with a bold run split in two, an italic run, a bold external link and a run with bold explicitly turned off, a numbered list, a bullet list with a "List Bullet 2" item, a table with a pipe in a cell and a two-paragraph cell, a soft line break, and an image with alt text.

python3 docx2md.py sample.docx media > sample.md
Enter fullscreen mode Exit fullscreen mode

Output:

# Refund policy

Last updated in the Word file. This intro has an empty paragraph after it.

# Who can ask for a refund

Customers on **annual plans** can ask within *30 days*. Read the [**full terms**](https://example.com/terms) first. Not bold

## Steps

1. Open Billing
2. Click Request refund
3. Pick a reason

What you need:

- Order ID
- Account email
    - Found in the receipt footer
- Reason

## Plans

| Plan | Window | Notes |
| --- | --- | --- |
| Monthly | 7 days | Pro \| Team |
| Annual | 30 days | Prorated<br>Second line in cell |

Line one  
Line two after a soft break

![Refund flow diagram](media/image1.png)
Enter fullscreen mode Exit fullscreen mode

The split bold run came out as one **annual plans**, "Not bold" stayed plain, the nested bullet is indented under its parent, the pipe in "Pro | Team" stayed inside its cell, and the image was written to media/image1.png with its alt text.

We also checked three other inputs:

  • A LibreOffice-made file (HTML converted to DOCX with soffice --headless), which puts numbering on each paragraph instead of on styles. Nested numbered items came out as 1., 2. under 1., and a bullet list that followed the numbered list started a new list instead of joining it.
  • calibre's public demo DOCX, a Word document built to exercise many features. Headings, the multi-level numbered and bulleted lists, external and internal links, inline images and simple tables came through. Its "continued list" section, where Word resumes numbering at 3 after an interrupting paragraph, also came out as 3 and 4. Its word/document.xml is about 138 KB; the Markdown is about 10 KB.
  • Edge cases: a tracked change file (the deleted text was dropped and the inserted text kept), a document with a javascript: link (the link was removed and its text kept, while a mailto: link survived), and consecutive paragraphs in a "Code" style (merged into one fenced block).

Convert a folder of Word files

For a shared drive export, convert everything in one pass and keep the folder structure:

from pathlib import Path
from docx2md import Docx

src, dst = Path("docs"), Path("markdown")
for path in sorted(src.rglob("*.docx")):
    if path.name.startswith("~$"):  # Word lock files
        continue
    out = dst / path.relative_to(src).with_suffix(".md")
    out.parent.mkdir(parents=True, exist_ok=True)
    media = out.parent / (out.stem + "_media")
    out.write_text(Docx(path, media).convert(), encoding="utf-8")
    print(f"{path} -> {out}")
Enter fullscreen mode Exit fullscreen mode

Each Markdown file gets its own <name>_media folder, and image links are written relative to the .md file, so the output can be moved as a unit. The ~$ check skips the lock files Word leaves next to open documents.

Known limits (and how to extend it)

  • Footnotes, endnotes and comments are dropped. They live in word/footnotes.xml, word/endnotes.xml and word/comments.xml. To keep footnotes, read w:footnoteReference in runs, emit [^n], and append the footnote text at the end.
  • Merged and nested table cells are flattened. Markdown tables cannot express gridSpan, vMerge or a table inside a cell. On calibre's demo, a nested table was folded into a single cell, and a calendar built from merged cells came out with empty columns. Keep such tables as HTML or rebuild them by hand.
  • Formatting from character styles is ignored. Only direct bold and italic are read. Text that is bold because of a style such as "Strong" comes out plain. Add those style IDs to the bold check if your documents rely on them.
  • Text boxes, shapes, headers, footers and drop caps are skipped or come out oddly. A drop cap shows up as a one-letter paragraph.
  • Markdown characters in the text are not escaped. A paragraph that starts with # or 1. will be read as a heading or list by a Markdown renderer. This rarely matters for prompts but matters for published docs.
  • Table of contents entries come through as links with page numbers attached (for example [Tables 3](#_Toc...)). Drop paragraphs whose style name starts with "toc" if you do not want them.
  • Old .doc files are a different binary format. Convert them to .docx first, for example with soffice --headless --convert-to docx file.doc.
  • Untrusted uploads: the script reads files only inside the ZIP and writes images using the base name only, but it does not limit file sizes. If users upload documents, check the uncompressed sizes in zipfile.ZipFile.infolist() before converting and run it with a timeout.

Why not mammoth or pandoc?

Both are mature and handle more of the edge cases above. mammoth is built to produce clean HTML from Word files; its own documentation says its Markdown output is deprecated and recommends converting to HTML and then to Markdown (checked 2 Nov 2026). If you can install dependencies, that route (mammoth, then an HTML-to-Markdown step) or pandoc -f docx -t gfm is a sound choice. The standard-library script makes sense when you cannot add dependencies, want to read and change every rule, or only need the structure that matters for LLM input.

Is Markdown the right target for chatbot knowledge?

Usually yes. Headings give chunkers natural split points, lists and tables keep their shape, and the file is small and easy to review before it goes live. Two habits help: split on ## headings and keep the source document name with each chunk so answers can cite it, and read the converted file once before you index it. A converter cannot tell you that the refund policy in a 2023 Word file is out of date.

When you do not want to run a script

Appie has free browser tools for Word files. Each run asks for a Cloudflare Turnstile check:

  • Convert DOCX to Markdown: upload a .docx; the page says heading styles become Markdown headings and Word tables become Markdown tables. For older .doc files it says to save as .docx first.
  • AI Chat with Your Word Document: upload a .docx and ask what it says. The page says answers have to come from the text you provided, and that headings and tables are included.
  • DOCX to FAQ Generator: writes a Markdown FAQ from a Word file, and the page says it is instructed to skip questions the file cannot answer.

If the end goal is a website chatbot, Appie's AI Chatbots app takes DOCX, PDF, CSV and text uploads as knowledge directly, so you may not need the conversion step at all. If you work from Claude Code or Cursor, Appie's MCP server can add text content to a chatbot (its add_chatbot_content tool takes text, URLs, sitemaps or Content Writer articles, not files), which is where the Markdown from this script fits.

Honest limits of Appie's Word tools

  • One file per run, with a fresh captcha each time. There is no batch mode on the tool pages, so a folder of 200 SOPs is a job for the script.
  • No options are described for footnotes, comments or merged cells, so test a representative file before relying on the output.

FAQ

Does this work with Google Docs exports?
It should, since Google Docs can download a doc as .docx (File > Download > Microsoft Word (.docx)), but we did not test a Google Docs export for this post. Convert one file and check the headings and lists before running a whole folder.

Why are my headings coming out as plain paragraphs?
The author probably made text big and bold instead of applying a Heading style. The script only sees styles. Apply Heading styles in Word, or add a rule that treats short, fully bold paragraphs as headings.

Can I keep underline, strikethrough or colours?
Markdown has no underline or colour. Strikethrough (~~text~~) is supported by GitHub-flavored Markdown; read w:strike in runs the same way as w:b.

How do I split the output for a RAG index?
Split on lines starting with ##, keep the # title with each chunk, and store the source file path as metadata.

Is it safe to run on files from customers?
Treat them as untrusted input: limit sizes, run with a timeout, and review the Markdown before it reaches a public chatbot. The script drops javascript: and file: links, but that is not a full sanitiser.

If you want a website chatbot trained on your documents, you can try Appie's AI Chatbots on the Free plan (500 credits, no credit card) at appie.run.

RG, Founder, Appie.run

Top comments (0)