Writing

Converting fancy Word documents to pixel perfect HTML in pure Python

How a simple .docx to HTML task turned into writing an Office Open XML rendering engine from scratch, and what I learned about fonts, painting order, and measuring perfect.

Python · OOXML · Open source

Designed Word documents turning into HTML

It started with a deceptively small request: take thousands of designed Word documents, resumes with sidebars and circular photos, cover letters, posters with organic blob shapes, and turn each one into a single self contained HTML file. The catch was the standard everyone secretly wants and nobody says out loud. Zero visual mismatch. It should look exactly like Word.

If you have ever tried this, you know the usual tools give up fast. Mammoth produces clean semantic HTML that throws the entire design away. Pandoc is similar. The pragmatic answer is to render it with LibreOffice headless and move on, but that means shipping a 400 MB office suite, and I wanted something that could pip install in two seconds and run anywhere with no binaries.

So I did the unreasonable thing. I wrote an OOXML reader from scratch, in pure Python standard library. It is on PyPI now as fancydocx.

import fancydocx
fancydocx.convert("resume.docx", "resume.html")

Here is what that innocent one liner actually has to do.

A .docx is a zip full of surprises

Rename any .docx to .zip and open it. Inside is a little filesystem of XML: word/document.xml for the body, styles.xml, theme1.xml, numbering.xml, relationship files that map IDs to images, and a media folder. The format is Office Open XML, and its first gift to you is units.

Word measures things in twips, one 1440th of an inch, for margins, indents and table columns. EMU, one 914400th of an inch, for image and shape geometry. Half points for font sizes, so a value of 24 means 12pt. Eighth points for border widths.

Get one conversion factor wrong and everything drifts. At the CSS reference of 96 DPI, px equals twips divided by 15, and px equals EMU divided by 9525. These constants are the foundation the whole thing balances on.

Formatting is a cascade, so treat it as data

A run of text does not carry its own formatting. It inherits through a chain: document defaults, then the paragraph's style, then that style's parent style, and so on, then the run's own overrides. Character styles stack on top of that.

The trap is to convert each layer to CSS and glue the strings together. Inheritance breaks the moment two layers touch the same property. The fix that made everything click was to keep properties as data. Parse every run and paragraph property into a normalised dictionary, deep merge the dictionaries in cascade order, and only emit CSS at the very end. Suddenly style chains, theme colours and paragraph mark defaults all just worked.

The parts that fought back

Tables are not a grid of cells. They are a grid with holes. A horizontal merge is simple enough, but a vertical merge is expressed as restart here and continue markers on separate rows. To recover real rowspans you build an occupancy grid, walk it row by row, and let the continue cells extend the span of the cell that started above them. Add a fixed table layout with explicit column widths and the columns land exactly where Word put them.

Shapes come in two dialects, modern DrawingML and legacy VML, often both in the same file wrapped in a fallback block. Backgrounds, sidebars and those trendy organic blobs are freeform custom geometry paths, which map beautifully to inline SVG once you translate the coordinate space.

And then the bugs that teach you something. Three favourites.

  • The invisible title. A resume's 50 point name was rendering at 10 points. I had quoted font families with double quotes inside a double quoted style attribute, so the browser silently truncated the style at the first inner quote, dropping the font size on every element that had a font. One character, a double quote to a single quote, fixed the entire document.
  • The blank page. A whole resume vanished behind a cream rectangle. It was the page background shape, which I had given a z-index of 0. But per CSS painting order, a positioned element at z-index 0 still paints on top of normal in flow content. Background art needs a negative z-index.
  • The Times New Roman headings. Every heading rendered in a serif font nobody asked for. Word's heading styles carried only a complex script font with no Latin font, and my code was falling back to it. Latin text is supposed to inherit the theme font instead. Deleting one fallback branch restored the intended typeface.

The font rabbit hole, where it got interesting

Here is the thing almost every converter gets wrong. Word's single line spacing is not 1.0, and not the mythical 1.15. It is the font's own design line height, baked into its metric tables.

single = (winAscent + winDescent + externalLeading) / unitsPerEm

That is about 1.22 for Calibri, about 1.28 for Aptos, and a whopping 1.59 for Century Gothic, which is why Century Gothic documents always look double spaced. Emit a line height of 1.0 and you are 20 to 30 percent too tight. Emit normal and you inherit whatever the viewer's fallback font decides.

So fancydocx parses the metric tables out of TTF, OTF and TTC files itself, a few binary unpack calls, computes the real factor, and emits the number. Now the layout keeps Word's geometry even when the browser substitutes a different family.

Two more font tricks fell out of the same rabbit hole. First, Word style substitution: when Futura is not installed, Word quietly swaps in Century Gothic, so the fallback chain mirrors Word's own substitutions instead of collapsing to the browser default sans. Second, embedded font recovery: some documents embed their fonts, obfuscated by XOR ing the first 32 bytes against the byte reversed GUID stored in the file. Reverse that and you can re emit the real font as a base64 face, so the text renders with the exact intended glyphs on a machine that has never heard of it.

How do you even measure pixel perfect?

You cannot improve what you cannot measure, and I had no reference renderer. That was the whole point. Then I found the trick. Word bakes its own render of page one into the file, as a thumbnail image, every time you save. Ground truth was hiding inside the input the whole time.

So the verification harness does two things. First, offline conformance checks that assert the HTML encodes the exact intended geometry: page size, margins, column widths, colours, font sizes. Second, a visual diff against Word's own thumbnail, producing a fidelity score and a difference heatmap.

The honest part: the score was about 75 out of 100, and it will never be 100. Two different rasterizers, Word's and a browser engine, antialias text differently, so even a geometrically perfect layout shows per pixel noise. The heatmap is what matters. Diffuse fuzz over text is antialiasing. Concentrated blocks or shifted edges are real bugs. On my machine the errors were almost entirely fonts I did not have installed. On a machine with the real fonts, it is visually indistinguishable.

What I would tell you before you try this

  • Model formatting as data, not strings. The cascade is the whole game.
  • Respect the units. Twips, EMU, half points. One wrong divisor and everything slides.
  • Fonts are the fidelity ceiling. Line height is a font metric. Substitution and embedding are where close becomes exact.
  • Your ground truth may already be in the file. That embedded thumbnail saved the project.
  • Pixel perfect is a heatmap, not a number. Chasing 100 out of 100 across two rendering engines is a fool's errand. Chasing no structural difference is the real goal.

The unreasonable path, writing the parser yourself, turned out to be the only one that fit in a pip install. And honestly, it was the most fun I have had debugging a blank cream rectangle.

Also published on Medium.

Next articleI benchmarked 5 Python web frameworks on 10 million rows

Have a system that needs real engineering?

Send the product, stack, deadline, current bottleneck, and what success should look like. I will reply with a concrete technical next step.