Engineering

Equations that survive a round trip through Word

Word stores maths as a tree in its own XML namespace, sitting between the runs of a paragraph. A converter that walks runs never sees it — so the formula vanishes while the sentence around it imports perfectly.

9 min read

Open a Word document containing equations in most browser editors and the prose arrives perfectly. The headings are right, the tables are right, the numbered lists are right. The equations are simply not there — no placeholder, no warning, no gap. The sentence that said “substituting into” now leads into nothing.

This is a particularly bad failure mode, because the document still looks complete. Nobody notices until the person who needed the formula reads it.

Why the equation is invisible to the importer

A .docx paragraph is a w:p containing runs, w:r. Every importer walks that structure. Word stores an equation as an m:oMath tree — in a different XML namespace, as a sibling of the runs, not inside one:

<w:p>
  <w:r><w:t xml:space="preserve">Inline </w:t></w:r>
  <m:oMath>                          <!-- different namespace, sibling of w:r -->
    <m:f>
      <m:num><m:r><m:t>x</m:t></m:r></m:num>
      <m:den><m:r><m:t>2</m:t></m:r></m:den>
    </m:f>
  </m:oMath>
  <w:r><w:t> ends here.</w:t></w:r>
</w:p>

A loop that says if (child.localName === "r") skips it in complete silence. There is no error to catch and no unrecognised element to log, because the code never asks about elements it does not expect. The fix is one branch — but you only write it if you know the tree is shaped this way.

Pick a target format the editor already understands

Before converting anything, decide what the equation becomes. Three options, in increasing order of usefulness: an image (dead), MathML (well-specified, patchily rendered), or whatever inline-math representation your editor already has.

The third is almost always right. If the editor has a maths dialog, it has a storage format — for us a span carrying LaTeX:

<span class="rte-math-inline" data-tex="\frac{x^{2}+1}{2}">\frac{x^{2}+1}{2}</span>

An imported equation is then indistinguishable from one typed by hand. It opens in the same dialog, renders through the same KaTeX or MathJax the page already loads, and — importantly — the export path that already exists for hand-typed maths now covers imported maths too. One representation, not two.

The operator is an attribute, and its default is invisible

OMML mostly maps cleanly. m:f is a fraction, m:sSup a superscript, m:rad a radical, m:d a delimiter. The one that catches people is the n-ary operator — sums, products, integrals:

<m:nary>
  <m:naryPr><m:chr m:val="∑"/></m:naryPr>
  <m:sub>...</m:sub><m:sup>...</m:sup><m:e>...</m:e>
</m:nary>

The operator is not text content. It is m:chr, an attribute of the properties element. Read only the children and every summation imports as a bare pair of limits with nothing to apply them to.

Worse, m:chr is frequently absent. The OOXML default for m:naryis the integral sign, so a definite integral is commonly written with no operator attribute at all. Treat missing as “no operator” and integrals vanish; treat it as the default and they work:

const chr = prop(el, "naryPr", "chr") || "∫";   // the specified default

We hit this from the other direction too. Our export used a library whose integral class emits no m:chr — correct behaviour, relying on the default — and our test asserted the attribute was present. The test was wrong, not the output. It is worth being careful about that distinction: an assertion that demands output the format never produces will send you chasing a bug that does not exist.

Three angle brackets that look identical

This one cost us a real debugging session. Unicode has three pairs of characters that render as angle brackets and are visually indistinguishable in any editor or terminal:

U+27E8 / U+27E9   ⟨ ⟩   MATHEMATICAL LEFT/RIGHT ANGLE BRACKET  (the correct one)
U+3008 / U+3009   〈 〉   CJK ANGLE BRACKET
U+2329 / U+232A   〈 〉   LEFT/RIGHT-POINTING ANGLE BRACKET      (deprecated)

Different producers emit different ones. Our reader recognised the mathematical pair. The library we exported through emitted the deprecated pair. Round-tripping \left\langle \gamma \right\rangle returned \left〈\gamma \right〉 — a stray glyph instead of a command, in a test whose expected and actual values looked identical in the failure output.

Two lessons, both general:

const cp = ch.charCodeAt(0);
if (cp === 0x27E8 || cp === 0x3008 || cp === 0x2329) return "\\langle";

We eventually took control of the output by writing the m:delement ourselves rather than using the library’s bracket classes, which also gained floor, ceiling and norm fences it did not model, and support for mismatched pairs like \left. x \right| — the evaluation bar, which is common and which a pairing assumption silently rewrites.

Degrade visibly, never silently

No converter covers every construct. What matters is what happens at the edge. Two rules serve well:

Unknown constructs keep their content. An unrecognised OMML element should fall through to walking its children rather than returning empty. A formula that reads oddly is recoverable by a human; one that disappeared is not.

Braces only where they bind. Emitting them everywhere is correct and produces {x}^{2} and {{i}^{2}} — valid LaTeX that is unreadable the moment the user opens the equation in a dialog. A lone letter, digit or command needs none:

function atom(t) {
  return /^[A-Za-z0-9]$/.test(t) || /^\\[A-Za-z]+$/.test(t) ? t : "{" + t + "}";
}

Verify the loop, not the two halves

Import and export are usually written separately, often months apart. They can both look correct while disagreeing with each other — which is exactly what the angle bracket bug was.

Two tests, and the second is the one that finds things:

Does the LaTeX render? Feed every string your converter can emit to KaTeX with throwOnError. This catches malformed output immediately and runs in a second:

for (const tex of CASES) katex.renderToString(tex, { throwOnError: true });

Does it round-trip? Export a document to .docx, re-import it, and compare the LaTeX to what you started with. We run fifteen formulas — fractions, sums, products, integrals, radicals with degrees, all four fence types, functions, limits, accents, bars, boxes, combined sub/superscripts, Greek — and require every one to come back byte-identical.

That suite found the angle bracket problem, an accent that was being flattened away, and a spurious space run that pushed integral limits off-centre. None of those were visible in either direction alone.

It also converts a vague claim into a checkable one. “We support equations” means very little. “Fifteen formulas covering these constructs return byte-identical after a full round trip through OOXML” is a statement someone can hold you to — which is the reason to write it down.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.