#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, HeadingLevel, AlignmentType, BorderStyle, WidthType, ShadingType, LevelFormat, TableOfContents, PageBreak, TabStopType, TabStopPosition } = require('docx'); const srcPath = process.argv[2]; const dstPath = process.argv[3]; if (!srcPath || !dstPath) { console.error('usage: md_to_docx.js '); process.exit(1); } const FONT = 'Malgun Gothic'; const raw = fs.readFileSync(srcPath, 'utf8'); // Strip YAML frontmatter let md = raw; if (md.startsWith('---')) { const end = md.indexOf('\n---', 3); if (end > 0) md = md.slice(end + 4).replace(/^\s*\n/, ''); } const lines = md.split(/\r?\n/); const border = { style: BorderStyle.SINGLE, size: 6, color: 'CCCCCC' }; const cellBorders = { top: border, bottom: border, left: border, right: border }; function run(text, opts = {}) { return new TextRun({ text, font: FONT, size: opts.size || 22, bold: !!opts.bold, italics: !!opts.italic }); } // Parse inline **bold**, *italic*, `code` -> TextRun[] function parseInline(text, baseOpts = {}) { const runs = []; const re = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g; let last = 0; let m; while ((m = re.exec(text)) !== null) { if (m.index > last) runs.push(run(text.slice(last, m.index), baseOpts)); const tok = m[0]; if (tok.startsWith('**')) runs.push(run(tok.slice(2, -2), { ...baseOpts, bold: true })); else if (tok.startsWith('`')) runs.push(new TextRun({ text: tok.slice(1, -1), font: 'Consolas', size: baseOpts.size || 22 })); else runs.push(run(tok.slice(1, -1), { ...baseOpts, italic: true })); last = m.index + tok.length; } if (last < text.length) runs.push(run(text.slice(last), baseOpts)); if (runs.length === 0) runs.push(run(text, baseOpts)); return runs; } function para(text, opts = {}) { return new Paragraph({ children: parseInline(text, opts), spacing: { before: 60, after: 60 }, ...(opts.heading ? { heading: opts.heading } : {}), }); } function heading(text, level) { const map = { 1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4 }; const size = { 1: 36, 2: 30, 3: 26, 4: 24 }[level] || 22; return new Paragraph({ heading: map[level] || HeadingLevel.HEADING_4, children: [new TextRun({ text, font: FONT, size, bold: true })], spacing: { before: 240, after: 120 }, }); } function bullet(text, level = 0) { return new Paragraph({ numbering: { reference: 'bullets', level }, children: parseInline(text), spacing: { before: 40, after: 40 }, }); } function numbered(text, level = 0) { return new Paragraph({ numbering: { reference: 'numbers', level }, children: parseInline(text), spacing: { before: 40, after: 40 }, }); } function codeBlock(text) { return new Paragraph({ children: [new TextRun({ text, font: 'Consolas', size: 20 })], shading: { type: ShadingType.CLEAR, fill: 'F4F4F4' }, spacing: { before: 60, after: 60 }, }); } function quote(text) { return new Paragraph({ children: parseInline(text, { italic: true }), indent: { left: 360 }, spacing: { before: 60, after: 60 }, border: { left: { style: BorderStyle.SINGLE, size: 18, color: '2E75B6', space: 12 } }, }); } // Parse pipe table starting at index i, returns { table, nextIndex } function parseTable(startIdx) { const rows = []; let i = startIdx; while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i].trim()); i++; } if (rows.length < 2) return null; // Header | separator | body const split = (r) => r.slice(1, -1).split('|').map(c => c.trim()); const header = split(rows[0]); const body = rows.slice(2).map(split); const colCount = header.length; const totalWidth = 9000; const colWidth = Math.floor(totalWidth / colCount); const columnWidths = new Array(colCount).fill(colWidth); const makeCell = (txt, isHeader) => new TableCell({ borders: cellBorders, width: { size: colWidth, type: WidthType.DXA }, shading: isHeader ? { type: ShadingType.CLEAR, fill: 'D5E8F0' } : undefined, margins: { top: 80, bottom: 80, left: 120, right: 120 }, children: [new Paragraph({ children: parseInline(txt, { bold: isHeader, size: 20 }) })], }); const tableRows = [ new TableRow({ children: header.map(h => makeCell(h, true)) }), ...body.map(r => new TableRow({ children: r.concat(new Array(Math.max(0, colCount - r.length)).fill('')).slice(0, colCount).map(c => makeCell(c, false)) })) ]; return { table: new Table({ width: { size: totalWidth, type: WidthType.DXA }, columnWidths, rows: tableRows }), nextIndex: i, }; } const children = []; // Cover children.push(new Paragraph({ children: [new TextRun({ text: '인간 서버 개발자 업무 지시서', font: FONT, size: 44, bold: true })], alignment: AlignmentType.CENTER, spacing: { before: 400, after: 200 }, })); children.push(new Paragraph({ children: [new TextRun({ text: '수상한잡화점 서버 파트 — v1.0', font: FONT, size: 28 })], alignment: AlignmentType.CENTER, spacing: { after: 120 }, })); children.push(new Paragraph({ children: [new TextRun({ text: '발행: 개발팀장 · 수신: 인간 서버 개발자 · 일자: 2026-04-17', font: FONT, size: 22, italics: true })], alignment: AlignmentType.CENTER, spacing: { after: 600 }, })); children.push(new Paragraph({ children: [new PageBreak()] })); // Manual index (no TOC field, avoids Word's external-reference warning) children.push(new Paragraph({ children: [new TextRun({ text: '목차', font: FONT, size: 32, bold: true })], spacing: { before: 120, after: 240 }, })); for (const L of lines) { const hm = /^(#{1,3})\s+(.*)$/.exec(L); if (!hm) continue; const lv = hm[1].length; const indent = (lv - 1) * 360; children.push(new Paragraph({ children: [new TextRun({ text: hm[2], font: FONT, size: 22 })], indent: { left: indent }, spacing: { before: 20, after: 20 }, })); } children.push(new Paragraph({ children: [new PageBreak()] })); let i = 0; while (i < lines.length) { const line = lines[i]; // Table if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:\-|]+\|\s*$/.test(lines[i + 1])) { const result = parseTable(i); if (result) { children.push(result.table); children.push(new Paragraph({ children: [new TextRun('')] })); i = result.nextIndex; continue; } } // Heading const hMatch = /^(#{1,6})\s+(.*)$/.exec(line); if (hMatch) { children.push(heading(hMatch[2], Math.min(hMatch[1].length, 4))); i++; continue; } // Code block if (/^```/.test(line)) { const buf = []; i++; while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; } i++; if (buf.length) children.push(codeBlock(buf.join('\n'))); continue; } // Quote if (/^>\s?/.test(line)) { children.push(quote(line.replace(/^>\s?/, ''))); i++; continue; } // Horizontal rule if (/^---+\s*$/.test(line)) { children.push(new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: '999999', space: 1 } }, spacing: { before: 120, after: 120 } })); i++; continue; } // Bullet const bulletMatch = /^(\s*)[-*]\s+(.*)$/.exec(line); if (bulletMatch) { const level = Math.min(Math.floor(bulletMatch[1].length / 2), 3); children.push(bullet(bulletMatch[2], level)); i++; continue; } // Numbered const numMatch = /^(\s*)\d+\.\s+(.*)$/.exec(line); if (numMatch) { const level = Math.min(Math.floor(numMatch[1].length / 2), 3); children.push(numbered(numMatch[2], level)); i++; continue; } // Empty if (/^\s*$/.test(line)) { i++; continue; } // Paragraph children.push(para(line)); i++; } const doc = new Document({ creator: 'BurningTimes 개발팀', title: '인간 서버 개발자 업무 지시서 — 수상한잡화점', styles: { default: { document: { run: { font: FONT, size: 22 } } }, paragraphStyles: [ { id: 'Heading1', name: 'Heading 1', basedOn: 'Normal', next: 'Normal', quickFormat: true, run: { size: 36, bold: true, font: FONT, color: '1F3864' }, paragraph: { spacing: { before: 360, after: 180 }, outlineLevel: 0 } }, { id: 'Heading2', name: 'Heading 2', basedOn: 'Normal', next: 'Normal', quickFormat: true, run: { size: 30, bold: true, font: FONT, color: '2E75B6' }, paragraph: { spacing: { before: 280, after: 140 }, outlineLevel: 1 } }, { id: 'Heading3', name: 'Heading 3', basedOn: 'Normal', next: 'Normal', quickFormat: true, run: { size: 26, bold: true, font: FONT }, paragraph: { spacing: { before: 200, after: 100 }, outlineLevel: 2 } }, { id: 'Heading4', name: 'Heading 4', basedOn: 'Normal', next: 'Normal', quickFormat: true, run: { size: 24, bold: true, font: FONT }, paragraph: { spacing: { before: 160, after: 80 }, outlineLevel: 3 } }, ], }, numbering: { config: [ { reference: 'bullets', levels: [0, 1, 2, 3].map(lv => ({ level: lv, format: LevelFormat.BULLET, text: ['•','◦','▪','▫'][lv], alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720 * (lv + 1), hanging: 360 } } } })) }, { reference: 'numbers', levels: [0, 1, 2, 3].map(lv => ({ level: lv, format: LevelFormat.DECIMAL, text: `%${lv+1}.`, alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720 * (lv + 1), hanging: 360 } } } })) }, ], }, sections: [{ properties: { page: { size: { width: 11906, height: 16838 }, // A4 margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, }, }, children, }], }); Packer.toBuffer(doc).then(buf => { fs.mkdirSync(path.dirname(dstPath), { recursive: true }); fs.writeFileSync(dstPath, buf); console.log('OK', dstPath, buf.length, 'bytes'); });