refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -1,31 +1,90 @@
// src/main/utils/markdown-to-docx.ts
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티
// packages/core/src/utils/markdown-to-docx.ts
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티.
// 마크다운 해석은 meeting-markdown 의 parseMarkdownBlocks 가 단일 정본이다 —
// 여기서는 블록을 docx 요소로 렌더링만 한다(docx 의존은 이 서브패스에만 격리).
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
import {
Document,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
HeadingLevel,
WidthType,
} from 'docx'
import {
CHECKBOX_CHECKED,
CHECKBOX_UNCHECKED,
parseMarkdownBlocks,
type MarkdownBlock,
} from './meeting-markdown'
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
const paragraphs: Paragraph[] = []
const HEADING_LEVELS = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
} as const
// 타이틀 추가
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE }))
function tableCell(text: string, bold: boolean): TableCell {
return new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold })] })],
})
}
// 줄 단위 파싱
for (const line of markdown.split('\n')) {
if (line.startsWith('## ')) {
paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
} else if (line.startsWith('### ')) {
paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
} else if (line.startsWith('# ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
} else if (line.startsWith('- [ ] ')) {
paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
} else if (line.trim()) {
paragraphs.push(new Paragraph({ children: [new TextRun(line)] }))
/** 블록을 docx 문서 요소(문단/표)로 바꾼다. 순수 매핑이라 개수·종류로 검증할 수 있다. */
export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array<Paragraph | Table> {
const elements: Array<Paragraph | Table> = []
for (const block of blocks) {
switch (block.type) {
case 'heading':
elements.push(new Paragraph({ text: block.text, heading: HEADING_LEVELS[block.level] }))
break
case 'bullet':
elements.push(new Paragraph({ text: block.text, bullet: { level: 0 } }))
break
case 'checkbox':
elements.push(
new Paragraph({
text: `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} ${block.text}`,
bullet: { level: 0 },
}),
)
break
case 'paragraph':
// 줄마다 문단 하나 — 한 줄짜리 항목이 한 문단으로 합쳐지지 않게 한다
for (const line of block.lines) {
elements.push(new Paragraph({ children: [new TextRun(line)] }))
}
break
case 'table': {
const rows: TableRow[] = []
if (block.header.length > 0) {
rows.push(new TableRow({ children: block.header.map((c) => tableCell(c, true)), tableHeader: true }))
}
for (const row of block.rows) {
rows.push(new TableRow({ children: row.map((c) => tableCell(c, false)) }))
}
if (rows.length > 0) {
elements.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
}
break
}
case 'rule':
// 구분선은 문서 흐름상 빈 문단으로 둔다
elements.push(new Paragraph({ text: '' }))
break
}
}
return elements
}
const doc = new Document({ sections: [{ children: paragraphs }] })
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
const children: Array<Paragraph | Table> = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
...blocksToDocxElements(parseMarkdownBlocks(markdown)),
]
const doc = new Document({ sections: [{ children }] })
return Buffer.from(await Packer.toBuffer(doc))
}