{"kind":"Skill","metadata":{"namespace":"community","name":"md-to-docx","version":"0.1.0"},"spec":{"description":"Convert Markdown files to professionally formatted Word (.docx) documents with embedded PNG images — pure JavaScript, no external tools required","files":{"SKILL.md":"---\nname: md-to-docx\ndescription: Convert Markdown files to professionally formatted Word (.docx) documents with embedded PNG images — pure JavaScript, no external tools required\n---\n\n# Markdown to Word (.docx) Skill\n\nConvert 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.\n\n## How to Convert\n\n```bash\n# Install dependencies (one-time, from the scripts folder)\ncd skills/md-to-docx/scripts \u0026\u0026 npm install\n\n# Convert (run from workspace root)\nnode skills/md-to-docx/scripts/md-to-docx.mjs \u003cinput.md\u003e [output.docx]\n```\n\nIf `output.docx` is omitted, it defaults to `\u003cinput-basename\u003e.docx` in the current directory.\n\n## Skill Folder Contents\n\n| File | Purpose |\n|------|---------|\n| `SKILL.md` | This instruction file |\n| `scripts/md-to-docx.mjs` | Node.js Markdown-to-Word converter |\n| `scripts/package.json` | Dependencies (`docx`, `marked`) |\n\n## Prerequisites\n\n| Requirement | Version | Notes |\n|-------------|---------|-------|\n| **Node.js** | 18+ | Required runtime |\n| **`docx`** | 9+ | Pure JS Word document generator |\n| **`marked`** | 15+ | Markdown parser |\n\nNo native binaries. No system-level installs. Works on Windows, macOS, and Linux.\n\n## Features\n\nThe converter:\n\n- **Extracts YAML front-matter** — uses `title`, `date`, `version`, `audience` for the title page\n- **Generates a title page** — with project name, subtitle, date, version, and audience\n- **Generates a table of contents** — built from H1-H3 headings\n- **Embeds PNG images** — resolves `![alt](path)` references relative to the input `.md` file, reads the PNG, and embeds it inline in the Word document\n- **Styled output** — Calibri font, colored headings (`#1F3864`), styled tables with alternating row colors, code blocks in Consolas\n- **Handles all Markdown elements** — headings, paragraphs, tables, code blocks, lists, images, links, horizontal rules\n\n## Image Embedding\n\nThe converter automatically embeds PNG images referenced in the Markdown:\n\n```markdown\n![High-Level Architecture](diagrams/high-level-architecture.drawio.png)\n```\n\nThe 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.\n\nIf an image file is not found, a placeholder `[Image not found: \u003cpath\u003e]` is inserted.\n\n## Front-Matter Format\n\n```yaml\n---\ntitle: Project Name — Project Summary\ndate: 2025-01-15\nversion: 1.0\naudience: Engineering Team, Architects, Stakeholders\n---\n```\n\nThe title is split on `—` or `–` into main title and subtitle for the title page.\n","scripts/md-to-docx.mjs":"/**\n * md-to-docx.mjs - Markdown to Word converter\n * Pure JavaScript, no external tools required.\n * Usage: node md-to-docx.mjs \u003cinput.md\u003e [output.docx]\n */\n\nimport { readFileSync, writeFileSync, existsSync } from \"fs\";\nimport { dirname, join, resolve } from \"path\";\nimport { marked } from \"marked\";\nimport {\n  Document, Packer, Paragraph, TextRun, HeadingLevel, ImageRun,\n  TableRow, TableCell, Table, WidthType, BorderStyle,\n  AlignmentType, ShadingType, PageBreak\n} from \"docx\";\n\n// --- Image dimensions from PNG header ---\nfunction pngDimensions(buffer) {\n  // PNG signature check + IHDR chunk at offset 16 (width) and 20 (height)\n  if (buffer[0] === 0x89 \u0026\u0026 buffer[1] === 0x50) {\n    return {\n      width: buffer.readUInt32BE(16),\n      height: buffer.readUInt32BE(20),\n    };\n  }\n  return { width: 600, height: 400 }; // fallback\n}\n\n// --- CLI argument parsing ---\nconst inputPath = process.argv[2];\nif (!inputPath) {\n  console.error(\"Usage: node md-to-docx.mjs \u003cinput.md\u003e [output.docx]\");\n  process.exit(1);\n}\nconst outputPath = process.argv[3] || inputPath.replace(/\\.md$/i, \".docx\");\nconst inputDir = dirname(resolve(inputPath));\n\nconst mdSource = readFileSync(inputPath, \"utf-8\");\n\n// --- Extract YAML front-matter metadata ---\nlet title = \"Document\";\nlet subtitle = \"\";\nlet date = new Date().toISOString().slice(0, 10);\nlet version = \"1.0\";\nlet audience = \"\";\n\nconst fmMatch = mdSource.match(/^---\\n([\\s\\S]*?)\\n---/m);\nif (fmMatch) {\n  const fm = fmMatch[1];\n  title = fm.match(/^title:\\s*(.+)$/m)?.[1]?.trim().replace(/^[\"']|[\"']$/g, \"\") || title;\n  date = fm.match(/^date:\\s*(.+)$/m)?.[1]?.trim() || date;\n  version = fm.match(/^version:\\s*(.+)$/m)?.[1]?.trim() || version;\n  audience = fm.match(/^audience:\\s*(.+)$/m)?.[1]?.trim() || \"\";\n}\n\n// Strip front-matter from markdown content\nconst md = mdSource.replace(/^---[\\s\\S]*?---\\n*/m, \"\");\n\n// Derive title / subtitle from front-matter title or first H1\nconst titleParts = title.split(/\\s*[—–]\\s*/);\nconst mainTitle = titleParts[0] || title;\nsubtitle = titleParts[1] || \"\";\nif (!subtitle) {\n  const h1Match = md.match(/^#\\s+(.+)$/m);\n  if (h1Match) {\n    const h1Parts = h1Match[1].split(/\\s*[—–]\\s*/);\n    if (h1Parts.length \u003e 1) {\n      subtitle = h1Parts[1];\n      if (!mainTitle || mainTitle === \"Document\") title = h1Parts[0];\n    }\n  }\n}\n\n// --- Parse Markdown tokens ---\nconst tokens = marked.lexer(md);\n\n// --- Style constants ---\nconst FONT = \"Calibri\";\nconst HEADER_COLOR = \"1F3864\";\nconst ACCENT_COLOR = \"2E75B6\";\nconst TABLE_HEADER_BG = \"D6E4F0\";\nconst TABLE_ALT_BG = \"F2F7FB\";\nconst CODE_BG = \"F5F5F5\";\nconst CODE_FONT = \"Consolas\";\nconst BORDER_COLOR = \"B4C6E7\";\n\nconst tableBorder = { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR };\nconst tableBorders = {\n  top: tableBorder, bottom: tableBorder,\n  left: tableBorder, right: tableBorder,\n  insideHorizontal: tableBorder, insideVertical: tableBorder,\n};\n\n// --- Utility: decode HTML entities ---\nfunction decodeEntities(str) {\n  return str\n    .replace(/\u0026amp;/g, \"\u0026\").replace(/\u0026lt;/g, \"\u003c\").replace(/\u0026gt;/g, \"\u003e\")\n    .replace(/\u0026quot;/g, '\"').replace(/\u0026#39;/g, \"'\");\n}\n\n// --- Inline tokens to TextRun[] ---\nfunction inlineToRuns(inlineTokens, parentBold = false, parentItalic = false) {\n  const runs = [];\n  if (!inlineTokens) return runs;\n  for (const t of inlineTokens) {\n    switch (t.type) {\n      case \"text\":\n        runs.push(new TextRun({\n          text: decodeEntities(t.text || t.raw || \"\"),\n          bold: parentBold, italics: parentItalic, font: FONT, size: 22,\n        }));\n        break;\n      case \"strong\":\n        runs.push(...inlineToRuns(t.tokens, true, parentItalic));\n        break;\n      case \"em\":\n        runs.push(...inlineToRuns(t.tokens, parentBold, true));\n        break;\n      case \"codespan\":\n        runs.push(new TextRun({\n          text: t.text, font: CODE_FONT, size: 20, bold: parentBold,\n          shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },\n        }));\n        break;\n      case \"link\":\n        runs.push(new TextRun({\n          text: t.text || t.href, bold: parentBold, italics: parentItalic,\n          font: FONT, size: 22, color: ACCENT_COLOR, underline: {},\n        }));\n        break;\n      case \"image\":\n        // Images handled at paragraph level; skip inline\n        break;\n      case \"br\":\n        runs.push(new TextRun({ break: 1, font: FONT }));\n        break;\n      default:\n        if (t.raw) {\n          runs.push(new TextRun({\n            text: decodeEntities(t.raw), bold: parentBold, italics: parentItalic,\n            font: FONT, size: 22,\n          }));\n        }\n        break;\n    }\n  }\n  return runs;\n}\n\n// --- Paragraph inline runs ---\nfunction paragraphRuns(token) {\n  if (token.tokens) return inlineToRuns(token.tokens);\n  return [new TextRun({ text: token.text || token.raw || \"\", font: FONT, size: 22 })];\n}\n\n// --- Table builder ---\nfunction buildTable(token) {\n  const rows = [];\n  if (token.header) {\n    rows.push(new TableRow({\n      tableHeader: true,\n      children: token.header.map(cell =\u003e new TableCell({\n        shading: { type: ShadingType.SOLID, color: TABLE_HEADER_BG, fill: TABLE_HEADER_BG },\n        children: [new Paragraph({\n          children: inlineToRuns(cell.tokens, true),\n          spacing: { before: 40, after: 40 },\n        })],\n      })),\n    }));\n  }\n  if (token.rows) {\n    token.rows.forEach((row, idx) =\u003e {\n      rows.push(new TableRow({\n        children: row.map(cell =\u003e new TableCell({\n          shading: idx % 2 === 1\n            ? { type: ShadingType.SOLID, color: TABLE_ALT_BG, fill: TABLE_ALT_BG }\n            : undefined,\n          children: [new Paragraph({\n            children: inlineToRuns(cell.tokens),\n            spacing: { before: 30, after: 30 },\n          })],\n        })),\n      }));\n    });\n  }\n  return new Table({\n    rows, width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders,\n  });\n}\n\n// --- Code block builder ---\nfunction buildCodeBlock(token) {\n  const lines = (token.text || \"\").split(\"\\n\");\n  return lines.map(line =\u003e new Paragraph({\n    children: [new TextRun({ text: line || \" \", font: CODE_FONT, size: 18 })],\n    spacing: { before: 20, after: 20 },\n    shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },\n    indent: { left: 360 },\n  }));\n}\n\n// --- List builder ---\nfunction buildList(token, level = 0) {\n  const items = [];\n  for (const item of token.items) {\n    const textTokens = item.tokens?.find(t =\u003e t.type === \"text\");\n    const bullet = token.ordered ? `${item.raw?.match(/^\\d+/)?.[0] || \"1\"}.` : \"\\u2022\";\n    const indent = 720 + level * 360;\n    items.push(new Paragraph({\n      children: [\n        new TextRun({ text: `${bullet}  `, font: FONT, size: 22 }),\n        ...(textTokens ? inlineToRuns(textTokens.tokens) : [new TextRun({\n          text: decodeEntities(item.text || \"\"), font: FONT, size: 22,\n        })]),\n      ],\n      spacing: { before: 40, after: 40 },\n      indent: { left: indent },\n    }));\n    const nestedList = item.tokens?.find(t =\u003e t.type === \"list\");\n    if (nestedList) items.push(...buildList(nestedList, level + 1));\n  }\n  return items;\n}\n\n// --- Build document children ---\nconst children = [];\n\n// Title page (from front-matter metadata)\nchildren.push(\n  new Paragraph({ spacing: { before: 2400 } }),\n  new Paragraph({\n    children: [new TextRun({ text: mainTitle, font: FONT, size: 56, bold: true, color: HEADER_COLOR })],\n    alignment: AlignmentType.CENTER,\n  }),\n);\nif (subtitle) {\n  children.push(new Paragraph({\n    children: [new TextRun({ text: subtitle, font: FONT, size: 36, color: ACCENT_COLOR })],\n    alignment: AlignmentType.CENTER, spacing: { after: 400 },\n  }));\n}\nchildren.push(\n  new Paragraph({\n    children: [new TextRun({\n      text: `Date: ${date}  |  Version: ${version}`,\n      font: FONT, size: 22, color: \"666666\",\n    })],\n    alignment: AlignmentType.CENTER,\n  }),\n);\nif (audience) {\n  children.push(new Paragraph({\n    children: [new TextRun({ text: `Audience: ${audience}`, font: FONT, size: 22, color: \"666666\" })],\n    alignment: AlignmentType.CENTER, spacing: { after: 600 },\n  }));\n}\nchildren.push(new Paragraph({ children: [new PageBreak()] }));\n\n// Table of Contents (static, built from headings found in the markdown)\nchildren.push(\n  new Paragraph({\n    children: [new TextRun({ text: \"Table of Contents\", font: FONT, size: 32, bold: true, color: HEADER_COLOR })],\n    spacing: { before: 200, after: 400 },\n  }),\n);\n\n// Pre-scan tokens for headings to build the TOC\nfor (const tok of tokens) {\n  if (tok.type !== \"heading\" || tok.depth \u003e 3) continue;\n  // Skip the first H1 title and the TOC heading itself\n  if (tok.depth === 1 \u0026\u0026 mainTitle !== \"Document\" \u0026\u0026\n      decodeEntities(tok.text || \"\").includes(mainTitle)) continue;\n  if (tok.text === \"Table of Contents\") continue;\n\n  const indent = (tok.depth - 1) * 360;\n  const tocSize = tok.depth === 1 ? 24 : tok.depth === 2 ? 22 : 20;\n  const tocBold = tok.depth \u003c= 2;\n  const tocColor = tok.depth \u003c= 2 ? HEADER_COLOR : ACCENT_COLOR;\n\n  children.push(new Paragraph({\n    children: [new TextRun({\n      text: decodeEntities(tok.text),\n      font: FONT, size: tocSize, bold: tocBold, color: tocColor,\n    })],\n    spacing: { before: tok.depth === 2 ? 80 : 40, after: 40 },\n    indent: { left: indent },\n  }));\n}\n\nchildren.push(new Paragraph({ children: [new PageBreak()] }));\n\n// --- Token walker ---\nlet skipToc = false;\n\nfor (const token of tokens) {\n  switch (token.type) {\n    case \"heading\": {\n      // Skip first H1 if it matches the front-matter title (already on title page)\n      if (token.depth === 1 \u0026\u0026 mainTitle !== \"Document\" \u0026\u0026\n          decodeEntities(token.text || \"\").includes(mainTitle)) {\n        continue;\n      }\n      // Skip markdown TOC section\n      if (token.text === \"Table of Contents\") { skipToc = true; continue; }\n      if (skipToc \u0026\u0026 token.depth \u003e 2) continue;\n      skipToc = false;\n\n      const headingMap = {\n        1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2,\n        3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4,\n      };\n      children.push(new Paragraph({\n        heading: headingMap[token.depth] || HeadingLevel.HEADING_4,\n        children: [new TextRun({\n          text: decodeEntities(token.text),\n          font: FONT, bold: true,\n          color: token.depth \u003c= 2 ? HEADER_COLOR : ACCENT_COLOR,\n          size: token.depth === 2 ? 32 : token.depth === 3 ? 26 : 24,\n        })],\n        spacing: { before: token.depth === 2 ? 360 : 240, after: 120 },\n      }));\n      break;\n    }\n    case \"paragraph\": {\n      if (skipToc) continue;\n      // Check if the paragraph is a standalone image\n      const imgToken = token.tokens \u0026\u0026 token.tokens.length === 1 \u0026\u0026 token.tokens[0].type === \"image\"\n        ? token.tokens[0] : null;\n      if (imgToken) {\n        const href = imgToken.href || \"\";\n        const imgPath = resolve(inputDir, href);\n        if (existsSync(imgPath)) {\n          const imgBuf = readFileSync(imgPath);\n          const dims = pngDimensions(imgBuf);\n          const maxW = 580; // max width in points (~6 inches)\n          const scale = dims.width \u003e maxW ? maxW / dims.width : 1;\n          const w = Math.round(dims.width * scale);\n          const h = Math.round(dims.height * scale);\n          children.push(new Paragraph({\n            children: [new ImageRun({ data: imgBuf, transformation: { width: w, height: h }, type: \"png\" })],\n            alignment: AlignmentType.CENTER,\n            spacing: { before: 120, after: 40 },\n          }));\n          // Add caption if alt text exists\n          if (imgToken.text) {\n            children.push(new Paragraph({\n              children: [new TextRun({ text: imgToken.text, font: FONT, size: 18, italics: true, color: \"666666\" })],\n              alignment: AlignmentType.CENTER,\n              spacing: { before: 0, after: 120 },\n            }));\n          }\n        } else {\n          children.push(new Paragraph({\n            children: [new TextRun({ text: `[Image not found: ${href}]`, font: FONT, size: 20, italics: true, color: \"888888\" })],\n            spacing: { before: 80, after: 80 },\n          }));\n        }\n      } else {\n        children.push(new Paragraph({\n          children: paragraphRuns(token), spacing: { before: 80, after: 80 },\n        }));\n      }\n      break;\n    }\n    case \"table\":\n      if (skipToc) continue;\n      children.push(buildTable(token));\n      children.push(new Paragraph({ spacing: { after: 120 } }));\n      break;\n    case \"code\":\n      if (skipToc) continue;\n      if (token.lang === \"mermaid\") {\n        children.push(new Paragraph({\n          children: [new TextRun({\n            text: \"[Diagram: See source .md file for interactive Mermaid diagram]\",\n            font: FONT, size: 20, italics: true, color: \"888888\",\n          })],\n          spacing: { before: 80, after: 80 },\n          shading: { type: ShadingType.SOLID, color: CODE_BG, fill: CODE_BG },\n          indent: { left: 360 },\n        }));\n      } else {\n        children.push(...buildCodeBlock(token));\n      }\n      children.push(new Paragraph({ spacing: { after: 80 } }));\n      break;\n    case \"list\":\n      if (skipToc) continue;\n      children.push(...buildList(token));\n      break;\n    case \"hr\":\n      skipToc = false;\n      children.push(new Paragraph({\n        spacing: { before: 200, after: 200 },\n        border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: BORDER_COLOR } },\n      }));\n      break;\n    case \"space\":\n      break;\n    default:\n      if (token.raw \u0026\u0026 !skipToc) {\n        children.push(new Paragraph({\n          children: [new TextRun({ text: decodeEntities(token.raw.trim()), font: FONT, size: 22 })],\n          spacing: { before: 80, after: 80 },\n        }));\n      }\n      break;\n  }\n}\n\n// --- Create and write document ---\nconst doc = new Document({\n  styles: {\n    default: {\n      document: { run: { font: FONT, size: 22 } },\n      heading1: {\n        run: { font: FONT, size: 36, bold: true, color: HEADER_COLOR },\n        paragraph: { spacing: { before: 360, after: 160 } },\n      },\n      heading2: {\n        run: { font: FONT, size: 32, bold: true, color: HEADER_COLOR },\n        paragraph: { spacing: { before: 320, after: 120 } },\n      },\n      heading3: {\n        run: { font: FONT, size: 26, bold: true, color: ACCENT_COLOR },\n        paragraph: { spacing: { before: 240, after: 100 } },\n      },\n    },\n  },\n  sections: [{\n    properties: {\n      page: { margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 } },\n    },\n    children,\n  }],\n  features: { updateFields: false },\n});\n\nconst buffer = await Packer.toBuffer(doc);\nwriteFileSync(outputPath, buffer);\nconsole.log(`Generated: ${outputPath} (${(buffer.length / 1024).toFixed(0)} KB)`);\n","scripts/package.json":"{\n  \"private\": true,\n  \"type\": \"module\",\n  \"description\": \"Dependencies for the Markdown to Word converter skill\",\n  \"dependencies\": {\n    \"docx\": \"^9.6.1\",\n    \"marked\": \"^17.0.4\"\n  }\n}\n"},"import":{"commit_sha":"541b7819d8c3545c6df122491af4fa1eae415779","imported_at":"2026-05-18T20:05:35Z","license_text":"MIT License\n\nCopyright GitHub, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.","owner":"github","repo":"github/awesome-copilot","source_url":"https://github.com/github/awesome-copilot/tree/541b7819d8c3545c6df122491af4fa1eae415779/plugins/project-documenter/skills/md-to-docx"}},"content_hash":[14,195,176,81,28,127,29,48,172,183,176,93,198,203,171,23,182,68,254,145,12,104,40,146,231,50,214,133,74,170,7,111],"trust_level":"unsigned","yanked":false}
