--- name: markdown-to-docx description: "Convert Markdown to .docx with CJK fonts, no pandoc." version: 1.0.0 metadata: hermes: tags: [docx, word, markdown, cjk, chinese, conversion, docx-js, node] category: productivity related_skills: [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) ```bash # 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_ })` | | `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: ```bash 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`: ```js 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`: ```js const AdmZip = require("adm-zip"); const z = new AdmZip("out.docx"); const xml = z.readAsText("word/document.xml"); (xml.match(//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).