Written and tested by the MD Converter editorial team
Why Markdown-to-Word (DOCX) Output Breaks in JavaScript, and How to Fix Tables, Lists, Code, and Images
Generating DOCX from Markdown in the browser or Node.js often collapses tables, flattens code into one line, ignores heading sizes, and breaks numbered lists. Learn the OOXML reasons behind each problem, fixes using the docx npm package, and how to check output without Word.
Most Markdown-to-Word (DOCX) output breaks because the Word document is assembled with an HTML and CSS mindset. A DOCX file is Office Open XML (OOXML), and Word has its own mechanisms for table column widths, line breaks, heading formatting, and list numbering. Generate output that follows those mechanisms and most of the breakage disappears.
This article documents what went wrong when we implemented in-browser Markdown-to-DOCX conversion with the docx npm package (v9), and how we fixed it. We parse Markdown with remark (mdast), but the same ideas apply to any parser.
What broke in the first version
The first version simply mapped each Markdown element to a roughly similar Word element. Opening the result showed all of these problems at once:
| Symptom | Cause | Fix |
|---|---|---|
| Table columns collapse to one character wide, digits stack vertically | No column grid (w:tblGrid), only a percentage table width | Set column widths in twips and use a fixed layout |
| Code blocks collapse into one line | A newline character inside text is not a line break in Word | Put each line in its own paragraph |
| Headings are the same size as body text | Sizes set directly on runs override the heading style | Keep formatting in the heading styles |
| Numbered lists have no indentation or nesting | "1. " was written as plain text | Use numbering definitions |
| The second numbered list starts at "4." | Restarting numbers per list was ignored by some apps | Create a separate numbering definition per list |
| Japanese text uses an unexpected font | CSS generic font names, and no East Asian font setting | Use real Word font names and set eastAsia |
| Images and Mermaid diagrams disappear | DOCX can't reference images by URL | Embed image data and add a PNG fallback for SVG |
| Blockquote bars are misplaced or become boxes | Paragraph border placement differs between apps | Draw the bar with a single-cell table |
The sections below explain each cause and fix. All code examples use the docx v9 API.
Tables: columns collapse without explicit widths
The first version only set width: { size: 100, type: WidthType.PERCENTAGE } on tables. That leaves column widths undefined, and some apps shrink columns to their minimum. In macOS Quick Look, "100,000" was squeezed until each character sat on its own line.
A Word table normally carries a list of column widths (w:tblGrid) plus a width for each cell. In docx, columnWidths becomes w:tblGrid. Widths are in twips (1 inch = 1440, 1 mm ≈ 56.7).
import { Paragraph, Table, TableCell, TableLayoutType, TableRow, WidthType } from "docx";
// A4 width (11906 twips) minus 22 mm margins on each side
const contentWidth = 11906 - 1247 * 2; // 9412
const widths = [2353, 2353, 4706]; // must add up to the content width
const table = new Table({
width: { size: contentWidth, type: WidthType.DXA },
columnWidths: widths,
layout: TableLayoutType.FIXED,
rows: rows.map((cells, r) =>
new TableRow({
tableHeader: r === 0, // repeat the header row on each page
cantSplit: true,
children: cells.map((text, c) =>
new TableCell({
width: { size: widths[c], type: WidthType.DXA },
children: [new Paragraph(text)],
})
),
})
),
});
Columns read better when widths are proportional to their content rather than equal. Count full-width (CJK) characters as two, and enforce a minimum width so no column becomes unreadably narrow. Apply Markdown column alignment (:---: and so on) through the cell paragraph's alignment.
Line breaks: "\n" inside text is not a line break
In OOXML, a newline character inside a text element (w:t) is treated as whitespace. To break a line in Word you need a break element (w:br) or a new paragraph. The first version put an entire code block into a single TextRun, so the code collapsed into one line.
For code blocks, one paragraph per line is the easiest approach. docx adds xml:space="preserve", so leading spaces survive, but tabs render at different widths across apps, so replace them with spaces.
import { Paragraph, TextRun } from "docx";
const mono = { ascii: "Consolas", hAnsi: "Consolas", eastAsia: "Yu Gothic" };
const codeParagraphs = code
.replace(/\t/g, " ")
.split("\n")
.map((line) => new Paragraph({ style: "CodeBlock", children: [new TextRun({ text: line, font: mono })] }));
For a line break inside a paragraph (two trailing spaces or <br> in Markdown), use new TextRun({ break: 1 }).
Applying background color and borders to each code paragraph can leave gaps between lines, and apps join paragraph borders differently. Putting the code paragraphs inside a single-cell table and shading the cell gives a consistent look across apps. For syntax highlighting, extract per-token colors from a highlighter such as highlight.js and pass them as each TextRun's color.
Headings: run-level sizes override the style
Word applies formatting in a priority order, and formatting set directly on a run wins over the paragraph style. The first version used the same code path for headings and body text, so heading runs carried size and font. That cancelled the heading styles, and every heading came out at body size.
Keep heading formatting in the heading styles, and put only Markdown emphasis such as bold or italic on the heading runs.
import { Document, HeadingLevel, Paragraph, TextRun } from "docx";
const doc = new Document({
styles: {
default: {
document: { run: { size: 21 } }, // 10.5 pt (half-points)
heading1: { run: { size: 40, bold: true }, paragraph: { spacing: { before: 360, after: 200 }, keepNext: true } },
heading2: { run: { size: 30, bold: true }, paragraph: { spacing: { before: 400, after: 160 }, keepNext: true } },
},
},
sections: [
{
children: [
// no size on the run
new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Heading")] }),
],
},
],
});
Using Word's built-in Heading 1–6 styles also makes the Navigation pane and Word's own table of contents work out of the box.
Numbered lists: use numbering definitions, not a typed "1."
The first version represented numbered lists as ordinary paragraphs prefixed with the text "1. ". That gives no indentation, places nested items at the same level as their parent, and the numbers don't update when someone adds an item in Word.
Word list numbering works by having paragraphs reference a numbering definition that describes the number format. Define the format and indentation for each level.
import { AlignmentType, Document, LevelFormat, Paragraph } from "docx";
const formats = [LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN];
const orderedList = (reference: string) => ({
reference,
levels: Array.from({ length: 9 }, (_, level) => ({
level,
format: formats[level % 3],
text: `%${level + 1}.`,
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 420 * (level + 1), hanging: 360 } } },
})),
});
const doc = new Document({
numbering: { config: [orderedList("list-1"), orderedList("list-2")] },
sections: [
{
children: [
new Paragraph({ text: "Step A", numbering: { reference: "list-1", level: 0 } }),
new Paragraph({ text: "Step A-1", numbering: { reference: "list-1", level: 1 } }),
// a separate list references its own definition and starts at 1
new Paragraph({ text: "A new list", numbering: { reference: "list-2", level: 0 } }),
],
},
],
});
Restarting numbers needs care. docx can reuse one definition through instance to restart numbering, and per the OOXML spec that restarts the list at 1. In macOS Quick Look, however, the second list continued the previous list's numbers, and the docx-preview browser viewer showed no numbers at all. If the file may be opened in apps other than Word, a separate numbering definition per list, as above, is the reliable choice.
Task lists (- [ ] / - [x]) have no Word equivalent. A practical approach is to skip numbering definitions, prefix the text with "☐" or "☑", and match the list indentation.
Fonts: CSS font names don't work in Word
Values like ui-sans-serif and system-ui are CSS generic font families, not actual font names. Passed to Word as-is, they're treated as missing fonts and replaced.
Word also sets fonts per script, and East Asian text needs an eastAsia font. If you only set a Latin font, Japanese, Chinese, and Korean text falls back to Word's default.
const doc = new Document({
styles: {
default: {
document: {
run: { font: { ascii: "Arial", hAnsi: "Arial", cs: "Arial", eastAsia: "Yu Gothic" }, size: 21 },
},
},
},
sections: [/* … */],
});
Choose East Asian fonts that your readers actually have. Meiryo, for example, is a Windows font that macOS doesn't include by default. For documents opened on both Windows and Mac, fonts commonly available in both environments, such as Yu Gothic or Yu Mincho, are less likely to be substituted.
Images and diagrams: embed data, not URLs
Unlike an HTML <img>, a DOCX file can't reference an image by URL; the image data must be embedded in the file. In the browser you fetch the image with fetch, which fails if the host doesn't allow cross-origin requests (CORS), so plan a fallback such as showing the alt text.
docx's ImageRun accepts PNG, JPEG, GIF, BMP, and SVG. Convert formats like WebP to PNG by drawing them on a canvas. SVG requires a PNG fallback, which apps without SVG support display instead.
import { ImageRun } from "docx";
new ImageRun({
type: "svg",
data: svgBytes, // Uint8Array
fallback: { type: "png", data: pngBytes },
transformation: { width: 480, height: 270 }, // display size in px
altText: { name: "diagram", description: "Processing flow", title: "Processing flow" },
});
Scale images down so they don't exceed the content width. Dividing the content width in twips by 15 gives pixels at 96 dpi.
Mermaid diagrams can be rendered to SVG and embedded the same way. By default, though, Mermaid embeds labels as HTML inside the SVG (foreignObject), which Word and other apps may not display. Prefixing the diagram with %%{init: {"flowchart": {"htmlLabels": false}}}%% makes Mermaid draw labels as SVG text, which is more reliable. Math works the same way: MathJax's SVG output with fontCache: "none" produces a self-contained SVG that doesn't depend on external fonts and can be embedded as an image.
Blockquotes: draw the bar with a single-cell table
The obvious way to draw a blockquote's left bar is a paragraph left border. But when a quote contains a bulleted list, the relationship between the hanging indent for the bullet and the border position differs between apps, and in a browser viewer the bar ran through the text.
Wrapping the whole quote in a single-cell table with only a left border keeps the bar in place even with lists, code, or nested quotes inside.
import { BorderStyle, Table, TableCell, TableRow, WidthType } from "docx";
// no border; white color because some viewers ignore "none" and draw a thin line
const none = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const bar = { style: BorderStyle.SINGLE, size: 18, color: "D6D3D1" };
new Table({
width: { size: contentWidth, type: WidthType.DXA },
columnWidths: [contentWidth],
borders: { top: none, bottom: none, right: none, left: bar, insideHorizontal: none, insideVertical: none },
rows: [
new TableRow({
children: [
new TableCell({
borders: { top: none, bottom: none, right: none, left: bar },
margins: { left: 220, right: 120 },
children: quoteParagraphs,
}),
],
}),
],
});
As the code comment says, one viewer ignored none and drew a thin black line. Word handles none correctly, but a white color keeps the border invisible in both.
Checking output without Word
The tricky part of DOCX bugs is that they often go unnoticed until someone opens the file in Word. Even without Word, combining these methods catches most problems:
- Read the XML directly: a DOCX file is a zip, so you can extract it and inspect
word/document.xml. This is the best way to confirm column widths are present or paragraphs reference numbering definitions. - Render it in other apps: use Quick Look (
qlmanage) on macOS, convert to PDF with LibreOffice if you have it, or use a browser viewer such as docx-preview. - Compare the results: when only one app shows a problem, check the XML to tell whether it's that app's rendering or the file itself.
mkdir -p out && unzip -o output.docx -d out
# are table column widths (w:gridCol) present?
grep -o '<w:gridCol w:w="[0-9]*"/>' out/word/document.xml
# macOS: render a thumbnail of the first page
qlmanage -t -s 1400 -o . output.docx
# LibreOffice: convert to PDF for review
soffice --headless --convert-to pdf output.docx
No viewer renders exactly like Word, though. In our case, viewers disagreed on numbered lists and borders. Confirm the final appearance in Word or Google Docs, whichever your readers use.
Pre-release checklist
- Tables have column widths (
columnWidths) and cell widths that add up to the content width - Code blocks use one paragraph per line, with tabs replaced by spaces
- Heading runs don't carry
sizeorfont - Numbered lists use numbering definitions, with a separate definition per list
- Fonts use real Word font names, with
eastAsiaset for East Asian text - Images are embedded as data, and SVGs have a PNG fallback
- Images that couldn't be fetched show alt text instead
- Output was checked in non-Word viewers, with the XML used to isolate causes
If you want to create a Word file from Markdown quickly, the MD to DOCX converter in Markdown Document Converter can help. It includes the fixes covered in this article and produces DOCX files with Word heading styles, numbered lists, width-balanced tables, highlighted code, Mermaid diagrams, and math, all in your browser (embedding https images connects to the image host).
Convert Markdown to Word (DOCX)
Related articles: How to Fix Broken or Overflowing Markdown Tables / Markdown to PowerPoint: Why the PPTX Isn't Editable / How to Export Mermaid Diagrams from Markdown to PDF