5.2 KiB
Raw Blame History

name description version metadata
markdown-to-docx Convert Markdown to .docx with CJK fonts, no pandoc. 1.0.0
hermes
tags category related_skills
docx
word
markdown
cjk
chinese
conversion
docx-js
node
productivity
docx
pdf
officecli

Markdown → DOCX (no pandoc / LibreOffice)

When to use

Trigger on: "转成 Word / 导出 docx / Word 版本 / 白皮书 Word 版" — any request to turn a Markdown document (whitepaper, report, spec, README) into a .docx, especially on hosts where pandoc, LibreOffice/soffice, or python-docx are NOT installed.

The bundled docx skill covers creating .docx by hand and editing existing files, but assumes pandoc/soffice for reading/verification. When those binaries are missing, this skill is the fallback: parse Markdown with marked and emit .docx with docx-js, fully controlling CJK fonts.

Prerequisites (no sudo)

# node + npm are usually already present; registry reachable via `npm ping`
mkdir -p /tmp/md2docx && cd /tmp/md2docx && npm init -y >/dev/null
npm install docx marked adm-zip

docx (docx-js) and marked generate/parse; adm-zip is for verification only.

The approach

marked.lexer(md) returns a flat token stream. Walk it once and map each type to a docx-js element:

marked token docx-js output
heading Paragraph({ heading: HeadingLevel.HEADING_<depth> })
paragraph Paragraph from inline runs
code single Paragraph — one TextRun per line joined with new TextRun({ break: 1 }), gray shading + box border
table Table — columnWidths + per-cell width (both DXA), header row shaded + bold
list bullet (•) / numbered (N.) paragraphs with indent + hanging
blockquote indented paragraph with left border, italic gray
hr empty paragraph with bottom border (NOT a table)

A complete, tested converter is shipped at scripts/convert_md_to_docx.js. Run:

node scripts/convert_md_to_docx.js input.md output.docx [title]

Optional 4th arg sets the Word title property (defaults to "Converted from Markdown" — pass the real doc title so the .docx metadata isn't a stale leftover). The converter auto-inserts a PageBreak before every H1 (after the first) so each # chapter starts on a fresh page — right for multi-chapter whitepapers and 投标文件 (bid documents); for continuous-flow docs, delete the firstH1Seen block in the main loop.

Inline runs are built recursively from tok.tokens, mapping strong/em/codespan/link to run formatting. codespan (`...`) renders as red Consolas — critical for documents full of ${param}$, sqlExe, load_xxx().

CJK font handling (the main gotcha)

Word does NOT reliably auto-fallback Chinese glyphs. Set the eastAsia attribute on every run's font, alongside ascii/hAnsi:

const FONT_BODY = { ascii: "Times New Roman", hAnsi: "Times New Roman", eastAsia: "宋体" };
const FONT_HEAD = { ascii: "Arial", hAnsi: "Arial", eastAsia: "微软雅黑" };
const FONT_CODE = { ascii: "Consolas", hAnsi: "Consolas", eastAsia: "宋体" };
// ...new TextRun({ text, font: FONT_BODY })

Convention for Chinese technical docs: 标题 微软雅黑 navy, 正文 宋体, 代码 Consolas. Omit eastAsia and the whole document renders in a Latin fallback font.

docx-js footguns (reinforced from the bundled docx skill)

  • Tables need dual widths: columnWidths on the Table AND width on every TableCell, both WidthType.DXA (PERCENTAGE breaks in Google Docs). Sum to the usable page width (A4 11906 DXA − 1440×2 margins = 9026 DXA).
  • Shading type is ShadingType.CLEAR, never SOLID (SOLID renders black).
  • No \n in a TextRun — multi-line code blocks use new TextRun({ break: 1 }) between line runs.
  • PageBreak must live inside a Paragraph.
  • Lists: either use a numbering config or prefix the marker manually (• / N. ); never assume a literal bullet is styled as a list marker.

Verification without soffice

No LibreOffice available to render → verify by reading the OOXML directly with adm-zip:

const AdmZip = require("adm-zip");
const z = new AdmZip("out.docx");
const xml = z.readAsText("word/document.xml");
(xml.match(/<w:tbl>/g) || []).length        // table count
(xml.match(/w:fill="F5F5F5"/g) || []).length // code blocks
(xml.match(/w:fill="D9E2F3"/g) || []).length // header cells
xml.includes("eastAsia")                      // CJK fonts present

Spot-check key strings (proper nouns, code identifiers) with xml.includes(...) to catch content loss. The docx-js Packer.toBuffer output is well-formed OOXML — adm-zip reading word/document.xml is sufficient structural proof of validity in the absence of a renderer.

Pitfalls

  • require('docx/package.json') throws ERR_PACKAGE_PATH_NOT_EXPORTED — the exports map blocks it. Verify install with typeof require('docx').Document === 'function' instead.
  • Don't reach for pandoc/soffice installs on sudo-less hosts as a first move — npm + docx-js is self-contained and faster to stand up.
  • Keep the .md source alongside the .docx; regenerate by rerunning the script after any content edit (single source of truth).