A paragraph can look like a heading without declaring a heading style. A converter has to choose whether to preserve the document's explicit structure or guess the author's intent. Those are different products, and mixing them silently makes migrations hard to audit.
I tested a small example on September 17, 2026, using docx 9.7.1 to generate inputs, Mammoth 1.12.0 to read them, and Turndown 7.2.4 to serialize HTML as Markdown. The interesting result was not just a missing heading: a table survived the first stage and disappeared structurally in the second.
Start with two independent signals
The semantic fixture uses a paragraph style:
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [new TextRun('Real heading')]
})
The visual fixture uses direct run formatting:
new Paragraph({
children: [new TextRun({
text: 'Visual heading', bold: true, size: 32
})]
})
These are structural test fixtures, not a claim that two rendered pages are pixel-identical. Mammoth returned an h1 for the first and a paragraph containing strong for the second. With ATX headings enabled, the Markdown became # Real heading and **Visual heading** respectively.
That distinction follows the separation between paragraph properties and run properties in WordprocessingML. Reading a font size is not the same as reading a section level.
A custom style needs an agreed meaning
The next fixture used a custom paragraph style named Section Label, with bold 16pt formatting and outline level 0. Mammoth's default conversion produced a paragraph and an unknown-style warning. Adding this mapping changed that paragraph to an h1:
const result = await mammoth.convertToHtml(
{ buffer },
{ styleMap: [
"p[style-name='Section Label'] => h1:fresh"
] }
)
Mammoth documents this mapping mechanism. The map is only justified when the document template actually uses that style for first-level headings. It is not permission to infer a heading from every unfamiliar style name.
This suggests treating template changes like schema changes: version the style map, retain unknown-style warnings, and keep fixtures from the templates you support.
Test the intermediate representation
My difficult fixture also contained a two-level numbered list and a 2-by-2 table. Mammoth's HTML contained both nested lists and a table. Default Turndown, without a table plugin, preserved the list but emitted the cell texts as separate paragraphs. All four cell values survived; their row/column relationship did not.
This is why a text-presence assertion is insufficient. Check the HTML table first, then check the Markdown representation expected by your destination. Turndown exposes plugins, but enabling an extension still requires a new compatibility test.
I reproduced these fixtures in MDFold's Word-to-Markdown converter, which I develop. The live table output had the same limitation. This is a boundary to review, not evidence of lossless conversion.
Boundaries that change the acceptance criteria
An independent comparison with LibreOfficeDev 26.8.0.0.alpha0's HTML exporter produced an h1 for the built-in heading and a visually formatted paragraph for the custom style. For an empty Heading 1, LibreOffice retained an empty heading with line breaks while Mammoth omitted it. This development build and these small fixtures are not a ranking of converter quality.
A deliberately invalid DOCX produced a ZIP error locally. In the live UI, selecting it after a valid file displayed an error but left the previous output available. That is a separate state-management defect: successful old text must not be mistaken for a successful new conversion. It was observed, not fixed, in this test.
My acceptance checks now separate three questions:
- Did the reader recognize the declared structure and surface warnings?
- Did serialization preserve the structures the destination supports?
- Does the displayed result still belong to the currently selected input?
Images, tracked changes, footnotes, and merged cells were outside this experiment. A green result on these fixtures says nothing about those features.
Would you make visual heading inference opt-in, or show proposed headings for review before changing the output?












