Key Takeaways: DOCX is structured OOXML like PPTX, so it looks like it should finally be the easy entry in this series — and mostly it is: real heading styles, no slide boundaries to lose, no speaker notes stream, no charts to flatten. What still trips a naive converter is Word’s own run-level formatting modelA mathematical function trained on data that maps inputs to outputs. In ML, a model is the artifact produced after training — it encapsulates learned patterns and is used to make predictions or… (
w:val="0"inheritance, split punctuation runs) and its block-level structure (manually-numbered list items that read as nested lists, paragraphs that silently merge into one line). Fix all four and the rest of the conversion — real heading hierarchy, tables with per-cell formatting, images referenced by filename — is close to a straight read of the XML. The bug that actually matters if you want this running unattended, though, is upstream of any of that: image filenames from a DOCX exported by Google Docs are not stable between exports, so a converter that trusts them will hand a pipeline a multi-hundred-file diff for a one-paragraph edit. Naming images by content hash instead, and reusing whatever a target directory already has, fixes that. Everything below is packaged into the/utils:docxskill.
Where This Fits
In Part 2 we covered PowerPoint: a deck that is structured XML on paper but hard to convert in practice, because its meaning is spatial — split across slides, speaker notes, tables, and charts. Part 1 before that covered PDF, hard for the opposite reason: no structure at all to recover.
This part is Word. Also OOXML, also nominally “structured” the same way PPTX is. The natural assumption, having just read Part 2, is that DOCX will repeat the lesson: looks easy, isn’t. It does not. DOCX is genuinely simpler to convert than PPTX. That is the honest headline. It is just not free.
DOCX Is Structured — and Mostly Delivers
Start with what DOCX gets right that PPTX does not. A Word document has no slide boundaries to dissolve, because it has no slides. There is no separate speaker-notes stream to remember to walk. There are no charts that need to be flattened into a data table instead of narrated as prose. A document is one linear stream of paragraphs, headings, tables, and inline images, in reading order — the way you would expect a “document” to work.
Headings are the clearest win. Word stores heading level as an actual paragraph style name: “Heading 1”, “Heading 2”, and so on, up to “Heading 4” for this skill’s purposes. A converter can read that style name directly off the paragraph and know the level with certainty. Compare that to PDF, where a converter has to guess a heading from font size and boldness and hope the document was formatted consistently. DOCX skips the guessing entirely — this is the one place in the whole series so far where the format is easier than what came before it, not harder.
So the parsing problem PPTX had — the structure exists but the meaning is spatial — mostly does not apply here. What replaces it is smaller and sharper: four specific behaviors in how Word encodes formatting and structure, each of which looks like an edge case until you convert a real document and hit it on the first page. Two live inside a single run’s character formatting. Two live one level up, in how paragraphs and list items get joined into the output stream — and those two only showed up once this converter was pointed at a real internal document instead of a test fixture.
Four Bugs a Naive Converter Walks Into
The w:val=“0” Trap
Word’s XML represents bold with a <w:b/> tag on a run’s properties. The naive assumption is: tag present means bold, tag absent means not bold. That assumption is wrong for a specific and common case. When a run inherits bold from its paragraph or character style and the author explicitly turns it off for that one run, Word does not remove the tag — it writes <w:b w:val="0"/>. The tag is still there, but its value says off.
A converter that only checks for the tag’s presence renders that run as bold when it should not be, scattering unwanted bold text through the output of any document with styled headings, pull quotes, or table headers.
The fix is a small function, _is_on(), that checks the tag’s w:val attribute against the values "0", "false", and "off" rather than treating tag presence alone as the answer. If the value is one of those, the property reads as off regardless of the tag being there.
Punctuation Run Splitting
The second bug is subtler and shows up in the output rather than in a code review. Word frequently gives a single punctuation character — a quote mark, an opening or closing parenthesis — its own separate XML run with different bold or italic formatting than the word sitting right next to it. A naive converter, wrapping each run independently in Markdown emphasis markers, turns that into something like ***"****text* — a malformed marker that neither renders nor parses back out cleanly.
The fix treats punctuation-only runs as a special case: a run whose text is entirely punctuation or whitespace inherits the formatting of the adjacent content run instead of keeping its own. The script does this in two passes, to catch punctuation sitting at either edge of a formatted phrase, then re-merges adjacent runs that end up with matching formatting. The malformed three-asterisk sequence collapses into a single, correctly nested italic marker around the quote and the word together.
Neither bug is exotic. Both come from ordinary Word documents: a template with inherited character styles, a pull quote with a stray punctuation mark. A converter that does not handle them looks fine on a plain-text paragraph, then quietly corrupts the first heading or block quote it meets. The next two bugs come from equally ordinary documents — they just live one level up, in how paragraphs and list items get joined together rather than in a single run’s formatting.
A List Item That Types Its Own Number
Word’s native numbered lists store the number as a property, not as text — the digits never appear in a run. But authors do not always use that featureAn individual measurable property or characteristic of the data used as input to a model. Feature engineering — selecting, transforming, and creating features — is a critical step in the ML pipeline.. It is common for a bullet paragraph to start with a hand-typed 14. before the actual sentence, especially in documents that have been re-numbered or copy-pasted from somewhere else. A naive converter takes the paragraph’s own bullet marker, - , and the literal text as-is, and emits:
That - 14. is not cosmetically wrong, it is structurally wrong: CommonMark treats a line matching the \d+\. pattern as the start of an ordered list wherever it appears, including as the entire content of another list item. The renderer sees a bullet whose only child is a new one-item ordered list, and draws two markers stacked on top of each other instead of one bullet with plain text. The fix strips a leading hand-typed number from real list items, since the bullet already carries that meaning, and escapes it (14\.) in ordinary paragraphs where the digits are meaningful text that just happens to sit at the start of a line.
Paragraphs That Quietly Become One Line
The second bug does not corrupt anything visibly malformed — it just erases a paragraph break. Two separate <w:p> paragraphs in the source, joined by a single \n in the emitted Markdown, are not two paragraphs to a CommonMark renderer. A lone newline between two lines of text is a soft break, rendered as nothing more than a space. Block-level separation needs a blank line.
This is invisible in the raw Markdown — both versions look like reasonable text files — and only shows up once rendered, as walls of text where the source clearly had separate steps or sentences. The fix tracks which output line starts a new Word paragraph versus continues the current one (via a <w:br/> soft break inside the same paragraph), and inserts a real blank line between distinct paragraphs while turning genuine soft breaks into an explicit hard break instead of leaving both cases to collapse into a stray space.
Two Operations
Where PPTX needed five operations and three external tools depending on what a deck was made of, DOCX needs two, and one tool.
markdown(the default) — converts the whole document to a single Markdown stream. Real heading hierarchy from the style names, bold and italic handled correctly (including the two fixes above), pipe tables with per-cell formatting preserved, and images referenced by filename rather than embedded as base64, with the filename stem used as alt text. Consecutive images are separated by a single blank line instead of stacking several. Chapter splitting is left to the caller — the skill hands back one document, not a set of files.images— extracts every embedded image to a directory and prints the mapping from each relationship ID to its filename. Word stores that relationship ID, not a direct paragraph-to-image link, so the mapping is what tells you which extracted file corresponds to which reference. Use this when a caller needs that mapping on its own, separate from a full conversion. Both operations also take an--existing-images-dirflag — more on why that matters below, once this stops being a one-off conversion and starts being something a pipeline runs on a schedule.
The only prerequisite is uv, which installs the Python dependencies (python-docx and lxml) automatically on first run. No LibreOffice, no poppler, no tesseract branch for special cases — DOCX has no equivalent of PPTX’s graphics-only slides that need to be rasterized for Vision, so there is nothing here that needs a rendering pipeline.
A conversion run looks like this:
uv run --project "${CLAUDE_PLUGIN_ROOT}/skills/docx/scripts" \
python "${CLAUDE_PLUGIN_ROOT}/skills/docx/scripts/docx-to-markdown.py" \
document.docx --output document.md
Flags let you set the image path prefix used in references (--image-prefix) and prepend a table of contents built from the heading hierarchy (--toc).
The Part Everyone Skips: Verification
Part 1’s version of this was stripping repeating PDF footers. Part 2’s was two greps: one for leftover page-number chrome, one to confirm every embedded image made it into the output. DOCX has its own version, scoped to the two bugs above plus the same image discipline.
First, check that no malformed formatting markers survived the punctuation fix. A pattern like three consecutive asterisks followed immediately by a quote or bracket character is the signature of an unmerged punctuation run:
# Malformed marker signature — should return nothing
grep -nE '\*{3}["'"'"'()]' document.md
Anything that matches is worth a manual look. Second, compare the embedded image count in the source document against the image references in the output:
# Both counts should match
unzip -l document.docx | grep -c 'word/media/'
grep -c '!\[' document.md
If those two numbers do not match, an image was referenced somewhere the extraction did not catch, or the document has media the paragraph walk skipped. Third, spot-check that headings in the output actually match the source document’s style hierarchy: open the original in Word or LibreOffice, confirm which paragraphs are “Heading 2” versus body text styled to look similar, and check the same paragraphs came out as ## and not as plain text.
The Bug That Matters Once This Runs Unattended
Everything above is about correctness on a single conversion. It matters, but it is the kind of bug you catch once, fix once, and move on from. The next one only shows up if you stop running the skill by hand and start wiring it into something that re-syncs a document on a schedule — a source Google Doc that an internal team keeps editing, converted into a docs site on every change.
Try that, and the first thing you notice is that image filenames are not stable. A .docx exported from Google Docs is not a persistent file Google incrementally edits — “Download as .docx” re-serializes word/document.xml and every image relationship from scratch, every time. The filename a converter reads for an embedded picture, something like image1339.png, comes from that relationship numbering, which is just the order the exporter happened to walk the document’s media this round. It is not derived from the image’s content, and it is not stable across exports. Add one paragraph anywhere in a 60-page document, export again, and every image in the file can get renumbered — not just the one near your edit.
A converter that trusts that name and writes files under it hands a downstream pipeline a diff sized to the whole document, every single time, regardless of how small the actual edit was. On one internal document this played out concretely: a content update — a handful of new paragraphs, a few updated screenshots — produced a 1598-file diff when resynced naively. 870 of those files were images that had not changed at all, renamed because the exporter numbered them differently this time.
Naming Images by What They Are, Not What They’re Called
The fix is to stop trusting the exporter’s name and hash the image bytes instead: sha256(data)[:16] plus the original extension. The same picture, re-exported under a different internal number ten times over, hashes to the same sixteen hex characters every time. A converter that names files this way is deterministic in the sense that matters — output depends only on content, never on which run produced it.
That alone does not stop the churn, though — it just makes the new names consistent with each other. A resync into a site that already has the old, exporter-numbered files still has to reconcile two naming schemes. The second half of the fix is a --existing-images-dir flag: before writing an image, hash it and check whether a file with that hash already exists in the target directory, under any name, hash-based or not. If it does, reuse that name and skip writing anything. Only images with no match — genuinely new or genuinely changed — get written under a fresh hash name.
Zero renames, because an unchanged image now genuinely does not change — not its bytes, not its name, not its path. The diff finally tracks the size of the edit instead of the size of the document.
One wrinkle: a site converted before this fix existed still has its old images sitting under exporter-assigned names, and --existing-images-dir only helps once something in the directory is already hash-named to match against. For that one-time transition, the skill ships a third script, rename-images-to-hash.py — point it at an images directory and the content directories that reference them, and it renames everything to its content hash in one pass, rewriting every reference to match, merging any byte-identical duplicates it finds along the way. Run once, and every resync after it starts from zero renames instead of building up to it gradually.
That is what makes this safe to put behind a webhook or a cron job rather than a person: a pipeline you can trust to produce a reviewable diff is one where the diff size is an honest signal of how much actually changed.
We Packaged This Into a Skill
Everything above — the heading-hierarchy read, the w:val="0" fix, the punctuation-run normalization, the list-marker and paragraph-break fixes, content-hash image naming with --existing-images-dir reuse, the one-time rename migration, pipe tables with formatting preserved, and the verification checks — is packaged into a public agent skill: /utils:docx.
Install it from the Trobz public skills repository and point it at a .docx. It needs only uv; the skill reports what is missing if that is not installed.
That closes out three parts: PDF in Part 1, PowerPoint in Part 2, and now Word. More formats are on the way in this series.