Installation
Install with CLI
Recommended
gh skills-hub install md-to-docx Don't have the extension? Run gh extension install samueltauil/skills-hub first.
Download and extract to your repository:
.github/skills/md-to-docx/ Extract the ZIP to .github/skills/ in your repo. The folder name must match md-to-docx for Copilot to auto-discover it.
Skill Files (3)
SKILL.md 2.7 KB
---
name: md-to-docx
description: Convert Markdown files to professionally formatted Word (.docx) documents with embedded PNG images โ pure JavaScript, no external tools required
---
# Markdown to Word (.docx) Skill
Convert Markdown (`.md`) files into professionally formatted Word (`.docx`) documents with embedded PNG images. Uses **pure JavaScript** via the `docx` and `marked` npm packages โ no Pandoc, LibreOffice, or any native binary required.
## How to Convert
```bash
# Install dependencies (one-time, from the scripts folder)
cd skills/md-to-docx/scripts && npm install
# Convert (run from workspace root)
node skills/md-to-docx/scripts/md-to-docx.mjs <input.md> [output.docx]
```
If `output.docx` is omitted, it defaults to `<input-basename>.docx` in the current directory.
## Skill Folder Contents
| File | Purpose |
|------|---------|
| `SKILL.md` | This instruction file |
| `scripts/md-to-docx.mjs` | Node.js Markdown-to-Word converter |
| `scripts/package.json` | Dependencies (`docx`, `marked`) |
## Prerequisites
| Requirement | Version | Notes |
|-------------|---------|-------|
| **Node.js** | 18+ | Required runtime |
| **`docx`** | 9+ | Pure JS Word document generator |
| **`marked`** | 15+ | Markdown parser |
No native binaries. No system-level installs. Works on Windows, macOS, and Linux.
## Features
The converter:
- **Extracts YAML front-matter** โ uses `title`, `date`, `version`, `audience` for the title page
- **Generates a title page** โ with project name, subtitle, date, version, and audience
- **Generates a table of contents** โ built from H1-H3 headings
- **Embeds PNG images** โ resolves `` references relative to the input `.md` file, reads the PNG, and embeds it inline in the Word document
- **Styled output** โ Calibri font, colored headings (`#1F3864`), styled tables with alternating row colors, code blocks in Consolas
- **Handles all Markdown elements** โ headings, paragraphs, tables, code blocks, lists, images, links, horizontal rules
## Image Embedding
The converter automatically embeds PNG images referenced in the Markdown:
```markdown

```
The image path is resolved **relative to the input Markdown file**. The PNG is read, dimensions are extracted from the PNG header, and the image is scaled to fit within 6 inches width while preserving aspect ratio.
If an image file is not found, a placeholder `[Image not found: <path>]` is inserted.
## Front-Matter Format
```yaml
---
title: Project Name โ Project Summary
date: 2025-01-15
version: 1.0
audience: Engineering Team, Architects, Stakeholders
---
```
The title is split on `โ` or `โ` into main title and subtitle for the title page.
scripts/
md-to-docx.mjs 14.4 KB
/**
* md-to-docx.mjs - Markdown to Word converter
* Pure JavaScript, no external tools required.
* Usage: node md-to-docx.mjs <input.md> [output.docx]
*/
import { readFileSync, writeFileSync, existsSync } from "fs";
import { dirname, join, resolve } from "path";
import { marked } from "marked";
import {
Document, Packer, Paragraph, TextRun, HeadingLevel, ImageRun,
TableRow, TableCell, Table, WidthType, BorderStyle,
AlignmentType, ShadingType, PageBreak
} from "docx";
// --- Image dimensions from PNG header ---
function pngDimensions(buffer) {
// PNG signature check + IHDR chunk at offset 16 (width) and 20 (height)
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
}
return { width: 600, height: 400 }; // fallback
}
// --- CLI argument parsing ---
const inputPath = process.argv[2];
if (!inputPath) {
console.error("Usage: node md-to-docx.mjs <input.md> [output.docx]");
process.exit(1);
}
const outputPath = process.argv[3] || inputPath.replace(/\.md$/i, ".docx");
const inputDir = dirname(resolve(inputPath));
const mdSource = readFileSync(inputPath, "utf-8");
// --- Extract YAML front-matter metadata ---
let title = "Document";
let subtitle = "";
let date = new Date().toISOString().slice(0, 10);
let version = "1.0";
let audience = "";
const fmMatch = mdSource.match(/^---\n([\s\S]*?)\n---/m);
if (fmMatch) {
const fm = fmMatch[1];
title = fm.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") || title;
date = fm.match(/^date:\s*(.+)$/m)?.[1]?.trim() || date;
version = fm.match(/^version:\s*(.+)$/m)?.[1]?.trim() || version;
audience = fm.match(/^audience:\s*(.+)$/m)?.[1]?.trim() || "";
}
// Strip front-matter from markdown content
const md = mdSource.replace(/^---[\s\S]*?---\n*/m, "");
// Derive title / subtitle from front-matter title or first H1
const titleParts = title.split(/\s*[โโ]\s*/);
const mainTitle = titleParts[0] || title;
subtitle = titleParts[1] || "";
if (!subtitle) {
const h1Match = md.match(/^#\s+(.+)$/m);
if (h1Match) {
const h1Parts = h1Match[1].split(/\s*[โโ]\s*/);
if (h1Parts.length > 1) {
subtitle = h1Parts[1];
if (!mainTitle || mainTitle === "Document") title = h1Parts[0];
}
}
}
// --- Parse Markdown tokens ---
const tokens = marked.lexer(md);
// --- Style constants ---
const FONT = "Calibri";
const HEADER_COLOR = "1F3864";
const ACCENT_COLOR = "2E75B6";
const TABLE_HEADER_BG = "D6E4F0";
const TABLE_ALT_BG = "F2F7FB";
const CODE_BG = "F5F5F5";
const CODE_FONT = "Consolas";
const BORDER_COLOR = "B4C6E7";
const tableBorder = { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR };
const tableBorders = {
top: tableBorder, bottom: tableBorder,
left: tableBorder, right: tableBorder,
insideHorizontal: tableBorder, insideVertical: tableBorder,
};
// --- Utility: decode HTML entities ---
function decodeEntities(str) {
return str
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, '"').replace(/'/g, "'");
}
// --- Inline tokens to TextRun[] ---
function inlineToRuns(inlineTokens, parentBold = false, parentItalic = false) {
const runs = [];
if (!inlineTokens) return runs;
for (const t of inlineTokens) {
switch (t.type) {
case "text":
runs.push(new TextRun({
text: decodeEntities(t.text || t.raw || ""),
bold: parentBold, italics: parentItalic, font: FONT, size: 22,
}));
break;
case "strong":
runs.push(...inlineToRuns(t.tokens, true, parentItalic));
break;
case "em":
runs.push(...inlineToRuns(t.tokens, parentBold, true));
break;
case "codespan":
runs.push(new TextRun({
text: t.text, font: CODE_FONT, size: 20, bold: parentBold,
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
}));
break;
case "link":
runs.push(new TextRun({
text: t.text || t.href, bold: parentBold, italics: parentItalic,
font: FONT, size: 22, color: ACCENT_COLOR, underline: {},
}));
break;
case "image":
// Images handled at paragraph level; skip inline
break;
case "br":
runs.push(new TextRun({ break: 1, font: FONT }));
break;
default:
if (t.raw) {
runs.push(new TextRun({
text: decodeEntities(t.raw), bold: parentBold, italics: parentItalic,
font: FONT, size: 22,
}));
}
break;
}
}
return runs;
}
// --- Paragraph inline runs ---
function paragraphRuns(token) {
if (token.tokens) return inlineToRuns(token.tokens);
return [new TextRun({ text: token.text || token.raw || "", font: FONT, size: 22 })];
}
// --- Table builder ---
function buildTable(token) {
const rows = [];
if (token.header) {
rows.push(new TableRow({
tableHeader: true,
children: token.header.map(cell => new TableCell({
shading: { type: ShadingType.SOLID, color: TABLE_HEADER_BG, fill: TABLE_HEADER_BG },
children: [new Paragraph({
children: inlineToRuns(cell.tokens, true),
spacing: { before: 40, after: 40 },
})],
})),
}));
}
if (token.rows) {
token.rows.forEach((row, idx) => {
rows.push(new TableRow({
children: row.map(cell => new TableCell({
shading: idx % 2 === 1
? { type: ShadingType.SOLID, color: TABLE_ALT_BG, fill: TABLE_ALT_BG }
: undefined,
children: [new Paragraph({
children: inlineToRuns(cell.tokens),
spacing: { before: 30, after: 30 },
})],
})),
}));
});
}
return new Table({
rows, width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders,
});
}
// --- Code block builder ---
function buildCodeBlock(token) {
const lines = (token.text || "").split("\n");
return lines.map(line => new Paragraph({
children: [new TextRun({ text: line || " ", font: CODE_FONT, size: 18 })],
spacing: { before: 20, after: 20 },
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
indent: { left: 360 },
}));
}
// --- List builder ---
function buildList(token, level = 0) {
const items = [];
for (const item of token.items) {
const textTokens = item.tokens?.find(t => t.type === "text");
const bullet = token.ordered ? `${item.raw?.match(/^\d+/)?.[0] || "1"}.` : "\u2022";
const indent = 720 + level * 360;
items.push(new Paragraph({
children: [
new TextRun({ text: `${bullet} `, font: FONT, size: 22 }),
...(textTokens ? inlineToRuns(textTokens.tokens) : [new TextRun({
text: decodeEntities(item.text || ""), font: FONT, size: 22,
})]),
],
spacing: { before: 40, after: 40 },
indent: { left: indent },
}));
const nestedList = item.tokens?.find(t => t.type === "list");
if (nestedList) items.push(...buildList(nestedList, level + 1));
}
return items;
}
// --- Build document children ---
const children = [];
// Title page (from front-matter metadata)
children.push(
new Paragraph({ spacing: { before: 2400 } }),
new Paragraph({
children: [new TextRun({ text: mainTitle, font: FONT, size: 56, bold: true, color: HEADER_COLOR })],
alignment: AlignmentType.CENTER,
}),
);
if (subtitle) {
children.push(new Paragraph({
children: [new TextRun({ text: subtitle, font: FONT, size: 36, color: ACCENT_COLOR })],
alignment: AlignmentType.CENTER, spacing: { after: 400 },
}));
}
children.push(
new Paragraph({
children: [new TextRun({
text: `Date: ${date} | Version: ${version}`,
font: FONT, size: 22, color: "666666",
})],
alignment: AlignmentType.CENTER,
}),
);
if (audience) {
children.push(new Paragraph({
children: [new TextRun({ text: `Audience: ${audience}`, font: FONT, size: 22, color: "666666" })],
alignment: AlignmentType.CENTER, spacing: { after: 600 },
}));
}
children.push(new Paragraph({ children: [new PageBreak()] }));
// Table of Contents (static, built from headings found in the markdown)
children.push(
new Paragraph({
children: [new TextRun({ text: "Table of Contents", font: FONT, size: 32, bold: true, color: HEADER_COLOR })],
spacing: { before: 200, after: 400 },
}),
);
// Pre-scan tokens for headings to build the TOC
for (const tok of tokens) {
if (tok.type !== "heading" || tok.depth > 3) continue;
// Skip the first H1 title and the TOC heading itself
if (tok.depth === 1 && mainTitle !== "Document" &&
decodeEntities(tok.text || "").includes(mainTitle)) continue;
if (tok.text === "Table of Contents") continue;
const indent = (tok.depth - 1) * 360;
const tocSize = tok.depth === 1 ? 24 : tok.depth === 2 ? 22 : 20;
const tocBold = tok.depth <= 2;
const tocColor = tok.depth <= 2 ? HEADER_COLOR : ACCENT_COLOR;
children.push(new Paragraph({
children: [new TextRun({
text: decodeEntities(tok.text),
font: FONT, size: tocSize, bold: tocBold, color: tocColor,
})],
spacing: { before: tok.depth === 2 ? 80 : 40, after: 40 },
indent: { left: indent },
}));
}
children.push(new Paragraph({ children: [new PageBreak()] }));
// --- Token walker ---
let skipToc = false;
for (const token of tokens) {
switch (token.type) {
case "heading": {
// Skip first H1 if it matches the front-matter title (already on title page)
if (token.depth === 1 && mainTitle !== "Document" &&
decodeEntities(token.text || "").includes(mainTitle)) {
continue;
}
// Skip markdown TOC section
if (token.text === "Table of Contents") { skipToc = true; continue; }
if (skipToc && token.depth > 2) continue;
skipToc = false;
const headingMap = {
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4,
};
children.push(new Paragraph({
heading: headingMap[token.depth] || HeadingLevel.HEADING_4,
children: [new TextRun({
text: decodeEntities(token.text),
font: FONT, bold: true,
color: token.depth <= 2 ? HEADER_COLOR : ACCENT_COLOR,
size: token.depth === 2 ? 32 : token.depth === 3 ? 26 : 24,
})],
spacing: { before: token.depth === 2 ? 360 : 240, after: 120 },
}));
break;
}
case "paragraph": {
if (skipToc) continue;
// Check if the paragraph is a standalone image
const imgToken = token.tokens && token.tokens.length === 1 && token.tokens[0].type === "image"
? token.tokens[0] : null;
if (imgToken) {
const href = imgToken.href || "";
const imgPath = resolve(inputDir, href);
if (existsSync(imgPath)) {
const imgBuf = readFileSync(imgPath);
const dims = pngDimensions(imgBuf);
const maxW = 580; // max width in points (~6 inches)
const scale = dims.width > maxW ? maxW / dims.width : 1;
const w = Math.round(dims.width * scale);
const h = Math.round(dims.height * scale);
children.push(new Paragraph({
children: [new ImageRun({ data: imgBuf, transformation: { width: w, height: h }, type: "png" })],
alignment: AlignmentType.CENTER,
spacing: { before: 120, after: 40 },
}));
// Add caption if alt text exists
if (imgToken.text) {
children.push(new Paragraph({
children: [new TextRun({ text: imgToken.text, font: FONT, size: 18, italics: true, color: "666666" })],
alignment: AlignmentType.CENTER,
spacing: { before: 0, after: 120 },
}));
}
} else {
children.push(new Paragraph({
children: [new TextRun({ text: `[Image not found: ${href}]`, font: FONT, size: 20, italics: true, color: "888888" })],
spacing: { before: 80, after: 80 },
}));
}
} else {
children.push(new Paragraph({
children: paragraphRuns(token), spacing: { before: 80, after: 80 },
}));
}
break;
}
case "table":
if (skipToc) continue;
children.push(buildTable(token));
children.push(new Paragraph({ spacing: { after: 120 } }));
break;
case "code":
if (skipToc) continue;
if (token.lang === "mermaid") {
children.push(new Paragraph({
children: [new TextRun({
text: "[Diagram: See source .md file for interactive Mermaid diagram]",
font: FONT, size: 20, italics: true, color: "888888",
})],
spacing: { before: 80, after: 80 },
shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },
indent: { left: 360 },
}));
} else {
children.push(...buildCodeBlock(token));
}
children.push(new Paragraph({ spacing: { after: 80 } }));
break;
case "list":
if (skipToc) continue;
children.push(...buildList(token));
break;
case "hr":
skipToc = false;
children.push(new Paragraph({
spacing: { before: 200, after: 200 },
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR } },
}));
break;
case "space":
break;
default:
if (token.raw && !skipToc) {
children.push(new Paragraph({
children: [new TextRun({ text: decodeEntities(token.raw.trim()), font: FONT, size: 22 })],
spacing: { before: 80, after: 80 },
}));
}
break;
}
}
// --- Create and write document ---
const doc = new Document({
styles: {
default: {
document: { run: { font: FONT, size: 22 } },
heading1: {
run: { font: FONT, size: 36, bold: true, color: HEADER_COLOR },
paragraph: { spacing: { before: 360, after: 160 } },
},
heading2: {
run: { font: FONT, size: 32, bold: true, color: HEADER_COLOR },
paragraph: { spacing: { before: 320, after: 120 } },
},
heading3: {
run: { font: FONT, size: 26, bold: true, color: ACCENT_COLOR },
paragraph: { spacing: { before: 240, after: 100 } },
},
},
},
sections: [{
properties: {
page: { margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 } },
},
children,
}],
features: { updateFields: false },
});
const buffer = await Packer.toBuffer(doc);
writeFileSync(outputPath, buffer);
console.log(`Generated: ${outputPath} (${(buffer.length / 1024).toFixed(0)} KB)`);
package.json 0.2 KB
{
"private": true,
"type": "module",
"description": "Dependencies for the Markdown to Word converter skill",
"dependencies": {
"docx": "^9.6.1",
"marked": "^17.0.4"
}
}
License (MIT)
View full license text
MIT License Copyright GitHub, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.