BurningTimesAi/scripts/md_to_docx.js

268 lines
9.7 KiB
JavaScript
Raw Normal View History

feat: 팀 재량 작업 일괄 + 감사 시정 + P27-1 감사관 호출 주체 명시 ## PD님 승인 범위 팀 재량 작업 (2팀 병렬, 일괄 승인 하에 마무리) ### 개발팀 (PD 지시 #1·#5 후속) - Tier 1 잔여 9종 구현: Attribute 3(ReadOnly·ShowIf·ArrayTitle) + Util 6(EnumToInt·EnumEx·FormatEx·MathEx·KeyMaker·ValidationEx) + 테스트 7파일 - Phase 0-C Q-P 응답서 (Q-P1 기획 환송·Q-P2 초벌·시뮬레이터 전략 v2) - 11_UI아키텍처_v1·12_메타시스템_v1 신설 (수상한잡화점 파악 40% 해소) - PD 지시 로그 경로 정규화 (verify_log_paths 18건 전수 통과) ### 기획팀 (기획 #33·#34·#35) - REQ-템플릿_밸런스수치 신설 - 전문가 에이전트 6종(balance/content/level/narrative/system/ux-designer) 기록 의무 명시 + 구 P20 제거 - 밸런싱 md 4종 변경 이력 테이블 표준화(스테이지난이도곡선·밸런싱전략·전체테이블감사·빌드_조건_충돌점검) ## 감사 결과 및 즉시 시정 (PD님 체크 강화 지시 반영) ### dev-auditor 모드 B / plan-auditor 모드 B 수행 - Critical·Major: plan M1(수상한잡화점 대화로그 기획팀 3건 누락) — 즉시 시정 완료 - Minor: dev(Tier 1 엔트리 C30 git 점검 결과 누락) — 즉시 시정 완료 - 감사 보고 2건 `공유/소통/완료/` 이동 ### 프로세스 개선 (P27-1 개정) "감사관 호출 주체 = 항상 상위 세션 PM" 명시화. 근거: Claude Code 서브에이전트는 자기 세션 내부에서 Task 재호출 불가 (양 팀장 실증). 팀장이 감사관 호출 필요 판단 시 PM에게 이관 의무화. ## 조직 기록 체계 정상 작동 확인 - 개발팀 PD 지시 로그·대화로그·소통 채널 4중 동기화 양호 - 기획팀 PD 지시 로그 #33·#34·#35 아카이브 등재, 대화로그 엔트리 append - Inbox 17건 완료/ 이동, 남은 6건은 진행중·상시 참조용 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:17:37 +00:00
#!/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 <src.md> <dst.docx>'); 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: '너드나비스 개발팀',
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');
});