# Cleaning a Word file without breaking its layout

> A .docx is a zip of XML parts, not a flat text file. How a text cleaner reaches every part without corrupting styles or layout.

A `.docx` file is not text with some formatting bolted on.
Open one in an archive tool instead of Word and you will find a zip
containing a folder of XML files: `document.xml` holds the body text,
separate parts hold headers, footers, comments, and footnotes, and a
relationships file ties them together.
Cleaning the words in that document means walking into that structure,
finding every part that holds visible text, fixing it in place, and leaving
everything else, meaning styles, images, and layout, exactly as it was.
That second half is the part that is easy to get wrong.

## Fixing the text is the easy 90 percent

Once you have located a run of visible text inside `document.xml`, applying
the same rules a plain-text cleaner already applies is straightforward: walk
the string, find invisible characters, curly quotes, stray em dashes, and
fix them the same way, character for character, with the same offsets
approach that keeps `highlight()` and `clean()` reading from a single pass
over the text.
Nothing about being inside a `<w:t>` element changes what an invisible
character is or when a dash should turn into a hyphen instead of a spaced
dash.

## The part that is genuinely hard: runs

Word does not store a paragraph as one clean string.
It stores a sequence of "runs", each wrapped in its own `<w:r>` element with
its own formatting, and it splits a sentence across as many runs as its
internal editing history happens to produce.
This is normal, documented Word behavior, not a corrupted file: changing the
case of a word, applying bold to half a sentence, or even just editing text
over time can leave a single sentence looking like this in the underlying
XML:

```xml
<w:r><w:t>The plan</w:t></w:r>
<w:r><w:t>&#8212;which no</w:t></w:r>
<w:r><w:t>body read&#8212;</w:t></w:r>
<w:r><w:t>failed.</w:t></w:r>
```

Four runs, one sentence, and the em dashes sit at run boundaries in the
middle of the text a reader sees as continuous.
This is not a hand-picked worst case: it is the ordinary result of a
document being edited over time, and it is the reason this project treats
run-splitting as a real design problem rather than an edge case to hand-wave
past.

This matters because dash resolution in this project's engine is
context-sensitive by design.
An en dash between two names with no spaces, like `Bose` and `Einstein`
joined directly by the dash character, becomes a tight hyphen.
An em dash used as a sentence interrupter, like the plan example above,
becomes a spaced dash instead.
Deciding which is which means looking at the character immediately before
and after the dash.
Concatenating the four runs above back into one string reproduces the
sentence correctly: `The plan—which nobody read—failed.`
But a per-run pass never does that concatenation.
It resolves each run's dash using only the text inside that one run, and
that is where it goes wrong: run three, `body read—`, ends with a dash and
nothing after it within that run, so a per-run pass resolves it as if it
sat at the very end of the text, producing `--` instead of a spaced
interrupter, because `failed.` lives in the next run and is invisible to
this pass.
Run two, `—which no`, starts with a dash and nothing before it within that
run, so a per-run pass can resolve it as a line-initial list marker instead
of a sentence interrupter, because `The plan` lives in the previous run.

The same problem applies to the invisible-character logic.
Deciding whether a zero-width joiner between two characters is meaningful
punctuation in a joining script or leftover paste junk requires seeing both
neighbours.
Split those neighbours across a run boundary and the decision is being made
on incomplete information, silently, with no error to signal that anything
went wrong.

## Why this is not shipped yet

The correct fix is to reconstruct the logical text of a paragraph across all
of its runs, run the existing rules against that reconstructed text using
the same offset-based approach already used for plain text, and then map
each fix back to the specific run and position it came from, splitting or
merging runs as needed without disturbing the formatting attached to them.
That mapping step, going from an offset in reconstructed paragraph text back
to a specific byte range inside a specific run, while preserving every
run's own formatting properties, is a real design problem, not a small
addition to the existing rule table.
Getting it wrong in either direction, losing formatting or leaving dash and
joiner decisions wrong at run boundaries, would make the tool worse than
useless: worse, because it would look like it worked and quietly corrupt a
subset of edits every time.

That is why `.docx` support is not part of this tool yet rather than shipped
half-working.
The plain-text pipeline already does the character-level work correctly.
What is missing is the layer above it that reassembles a Word document's
runs into something that pipeline can see whole, and ships the fix back out
without touching anything else in the file.

---

Originally published at https://plainkit.tools/blog/cleaning-a-word-file-without-breaking-its-layout on Jun 30, 2026.
