For Developers

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:

SymptomCauseFix
Table columns collapse to one character wide, digits stack verticallyNo column grid (w:tblGrid), only a percentage table widthSet column widths in twips and use a fixed layout
Code blocks collapse into one lineA newline character inside text is not a line break in WordPut each line in its own paragraph
Headings are the same size as body textSizes set directly on runs override the heading styleKeep formatting in the heading styles
Numbered lists have no indentation or nesting"1. " was written as plain textUse numbering definitions
The second numbered list starts at "4."Restarting numbers per list was ignored by some appsCreate a separate numbering definition per list
Japanese text uses an unexpected fontCSS generic font names, and no East Asian font settingUse real Word font names and set eastAsia
Images and Mermaid diagrams disappearDOCX can't reference images by URLEmbed image data and add a PNG fallback for SVG
Blockquote bars are misplaced or become boxesParagraph border placement differs between appsDraw 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:

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


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