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

@ -0,0 +1,99 @@
// 회의 내보내기 마크다운 렌더러(core) 회귀 테스트.
// 예전 정규식 변환은 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라졌고,
// 한 줄짜리 줄들이 한 문단으로 붙었으며, 표가 파이프 그대로 찍혔다.
import { describe, expect, it } from 'vitest'
import { Paragraph, Table } from 'docx'
import {
escapeHtml,
markdownToSimpleHtml,
parseMarkdownBlocks,
renderMeetingPdfHtml,
} from '../src/utils/meeting-markdown'
import { blocksToDocxElements, markdownToDocx } from '../src/utils/markdown-to-docx'
describe('parseMarkdownBlocks', () => {
it('제목·불릿·체크박스(두 상태)·표·문단·구분선을 나눈다', () => {
const md = [
'## 결정',
'- 타입을 List<String> 으로 변경',
'- [ ] 문서화',
'- [x] 배포',
'',
'담당: <TBD>',
'줄1',
'---',
'| 시간 | 내용 |',
'|---|---|',
'| 00:01 | 시작 |',
].join('\n')
expect(parseMarkdownBlocks(md)).toEqual([
{ type: 'heading', level: 2, text: '결정' },
{ type: 'bullet', text: '타입을 List<String> 으로 변경' },
{ type: 'checkbox', checked: false, text: '문서화' },
{ type: 'checkbox', checked: true, text: '배포' },
{ type: 'paragraph', lines: ['담당: <TBD>', '줄1'] },
{ type: 'rule' },
{ type: 'table', header: ['시간', '내용'], rows: [['00:01', '시작']] },
])
})
it('빈 입력은 빈 목록', () => {
expect(parseMarkdownBlocks('')).toEqual([])
expect(parseMarkdownBlocks(null)).toEqual([])
})
})
describe('markdownToSimpleHtml', () => {
it('꺾쇠·앰퍼샌드·따옴표를 이스케이프해 PDF 에서 사라지지 않게 한다', () => {
const html = markdownToSimpleHtml('- List<String> & "q" <script>alert(1)</script>')
expect(html).toContain('List&lt;String&gt; &amp; &quot;q&quot; &lt;script&gt;')
expect(html).not.toContain('<script>')
})
it('연속된 한 줄짜리 줄은 <br> 로 보존하고, 불릿은 <ul> 로 감싼다', () => {
expect(markdownToSimpleHtml('줄1\n줄2')).toBe('<p>줄1<br>줄2</p>')
expect(markdownToSimpleHtml('- a\n- [x] b')).toBe('<ul>\n<li>a</li>\n<li>☑ b</li>\n</ul>')
})
it('파이프 표를 <table> 로 그린다', () => {
const html = markdownToSimpleHtml('| 시간 | 내용 |\n|------|------|\n| 00:01 | <시작> |')
expect(html).toBe(
'<table><thead><tr><th>시간</th><th>내용</th></tr></thead><tbody><tr><td>00:01</td><td>&lt;시작&gt;</td></tr></tbody></table>',
)
})
it('**굵게** 는 이스케이프 뒤 <strong> 으로 바꾼다', () => {
expect(markdownToSimpleHtml('**중요** <b>')).toBe('<p><strong>중요</strong> &lt;b&gt;</p>')
})
})
describe('renderMeetingPdfHtml', () => {
it('제목과 메타를 이스케이프하고 본문이 없으면 안내 문구를 쓴다', () => {
const html = renderMeetingPdfHtml({
title: '<img src=x onerror=alert(1)>',
meta: '일시: A & B',
markdown: null,
emptyMessage: '회의록이 생성되지 않았습니다.',
})
expect(html).toContain(`<h1>${escapeHtml('<img src=x onerror=alert(1)>')}</h1>`)
expect(html).not.toContain('<img')
expect(html).toContain('<p class="meta">일시: A &amp; B</p>')
expect(html).toContain('<p>회의록이 생성되지 않았습니다.</p>')
})
})
describe('markdown-to-docx', () => {
it('블록을 문단·표로 매핑한다 (문단 줄마다 하나)', () => {
const elements = blocksToDocxElements(
parseMarkdownBlocks('# 제목\n- 항목\n- [x] 완료\n줄1\n줄2\n\n| a | b |\n|---|---|\n| 1 | 2 |'),
)
expect(elements.map((e) => (e instanceof Table ? 'table' : e instanceof Paragraph ? 'p' : '?'))).toEqual([
'p', 'p', 'p', 'p', 'p', 'table',
])
})
it('DOCX 버퍼를 만든다', async () => {
const buffer = await markdownToDocx('## 결정\n- A', '회의록')
expect(buffer.subarray(0, 2).toString('latin1')).toBe('PK')
})
})

View file

@ -0,0 +1,99 @@
// 회의 전사 조립·화자 라벨·조각 나누기 (core meeting-transcript) 회귀 테스트.
// 예전엔 세그먼트의 epoch 시각을 그대로 써 `[29841999:45]` 가 저장됐고,
// 화자 구분을 다시 돌리면 라벨이 쌓였다.
import { describe, expect, it } from 'vitest'
import {
applySpeakerLabels,
buildTranscriptFromSegments,
chunkTranscriptByLines,
formatTranscriptLines,
looksTruncatedRewrite,
parseTranscriptLines,
} from '../src/meeting-transcript'
const START = 1_790_000_000_000 // epoch ms
describe('buildTranscriptFromSegments', () => {
it('epoch 시각을 회의 시작 기준 상대 시각으로 바꾼다', () => {
const text = buildTranscriptFromSegments(
[
{ text: '시작합니다', timestamp: START + 5_000 },
{ text: ' 다음 안건 ', timestamp: START + 65_000 },
],
START,
)
expect(text).toBe('[00:05] 시작합니다\n[01:05] 다음 안건')
// core 파서가 다시 읽을 수 있어야 한다 (동기화·상세 화면·화자 구분이 이 형식을 쓴다)
expect(parseTranscriptLines(text).map((l) => l.timestampMs)).toEqual([5_000, 65_000])
})
it('60분을 넘는 회의도 파싱 가능한 형식으로 왕복한다', () => {
const text = buildTranscriptFromSegments([{ text: '마무리', timestamp: START + 125 * 60_000 + 7_000 }], START)
expect(text).toBe('[125:07] 마무리')
const lines = parseTranscriptLines(text)
expect(lines).toEqual([{ timestampMs: (125 * 60 + 7) * 1000, timed: true, speaker: null, text: '마무리' }])
expect(formatTranscriptLines(lines)).toBe(text)
})
it('시작 전 시각은 0 으로 자르고 빈 세그먼트는 버린다', () => {
const text = buildTranscriptFromSegments(
[
{ text: '이전', timestamp: START - 3_000 },
{ text: ' ', timestamp: START + 1_000 },
],
START,
)
expect(text).toBe('[00:00] 이전')
})
})
describe('applySpeakerLabels', () => {
const diar = [
{ speaker: 'SPEAKER_00', start: 0, end: 10 },
{ speaker: 'SPEAKER_01', start: 10.5, end: 30 },
]
const label = (raw: string): string => raw.replace('SPEAKER_', '화자 ')
it('시각이 속한 구간의 화자로 라벨을 붙인다 (초 단위 구간)', () => {
const lines = parseTranscriptLines('[00:05] 안녕하세요\n[00:20] 반갑습니다\n[00:45] 혼잣말')
const labeled = applySpeakerLabels(lines, diar, label)
expect(formatTranscriptLines(labeled)).toBe('[00:05] [화자 00] 안녕하세요\n[00:20] [화자 01] 반갑습니다\n[00:45] 혼잣말')
})
it('이미 라벨이 있는 줄은 덧붙이지 않고 교체한다 (다시 돌려도 쌓이지 않음)', () => {
const lines = parseTranscriptLines('[00:05] [화자 1] 안녕하세요')
const once = applySpeakerLabels(lines, diar, label)
const twice = applySpeakerLabels(parseTranscriptLines(formatTranscriptLines(once)), diar, label)
expect(formatTranscriptLines(twice)).toBe('[00:05] [화자 00] 안녕하세요')
})
it('시각 없는 줄은 그대로 둔다', () => {
const lines = parseTranscriptLines('메모 없는 줄')
expect(applySpeakerLabels(lines, diar, label)).toEqual(lines)
})
it('parse→format 은 빈 줄을 버리고 공백을 정리한다', () => {
expect(formatTranscriptLines(parseTranscriptLines('[00:01] 가\n\n\n[00:02] 나 '))).toBe('[00:01] 가\n[00:02] 나')
})
})
describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
it('줄 경계에서만 나누고 빈 줄은 버린다', () => {
const text = ['[00:01] aaaa', '', '[00:02] bbbb', '[00:03] cccc'].join('\n')
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
})
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
const long = `[00:01] ${'x'.repeat(50)}`
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
})
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
const input = Array.from({ length: 10 }, (_, i) => `[00:0${i}] 문장 ${i}`).join('\n')
expect(looksTruncatedRewrite(input, input)).toBe(false)
expect(looksTruncatedRewrite(input, input.split('\n').slice(0, 5).join('\n'))).toBe(true)
expect(looksTruncatedRewrite(input, ' ')).toBe(true)
expect(looksTruncatedRewrite('시각 없는 긴 문장입니다', '시각 없는 긴 문장')).toBe(false)
expect(looksTruncatedRewrite('시각 없는 아주 긴 문장입니다 정말로', '시각')).toBe(true)
})
})

View file

@ -52,3 +52,110 @@ export function formatTranscriptLines(lines: readonly TranscriptLine[]): string
})
.join('\n')
}
/** 캡처 세그먼트(절대 epoch 타임스탬프)를 회의 시작 기준 전사 줄로 조립한다. */
export interface TimedSegmentInput {
text: string
/** 절대 시각(epoch ms). CaptionService 가 Date.now() 로 찍는다. */
timestamp: number
}
/**
* 회의 세그먼트로 전사 텍스트를 만든다.
*
* 세그먼트 시각은 epoch 이므로 반드시 회의 시작 시각을 빼서 상대 시각으로 바꾼다
* (예전엔 epoch 을 그대로 써 `[29841999:45]` 가 저장되어 파싱·화자 구분·동기화가 모두 깨졌다).
* 시작 전 시각은 0 으로 자르고, 빈 텍스트 세그먼트는 버린다.
*/
export function buildTranscriptFromSegments(
segments: readonly TimedSegmentInput[],
sessionStartedAt: number,
): string {
const lines: TranscriptLine[] = []
for (const segment of segments) {
const text = segment.text.trim()
if (!text) continue
lines.push({
timestampMs: Math.max(0, segment.timestamp - sessionStartedAt),
timed: true,
speaker: null,
text,
})
}
return formatTranscriptLines(lines)
}
/** 화자 구분 결과 구간. start/end 는 초 단위(pyannote 응답 형식). */
export interface SpeakerSegment {
speaker: string
start: number
end: number
}
/**
* 전사 줄에 화자 구분 결과를 적용한다. 시각이 있는 줄의 시각이 속한 구간의 화자로
* speaker 필드를 **교체**한다(앞에 덧붙이지 않으므로 다시 돌려도 라벨이 쌓이지 않는다).
* 구간에 속하지 않거나 시각이 없는 줄은 그대로 둔다.
*
* @param labelFor 원시 화자 id(예: SPEAKER_01)를 표시 라벨로 바꾸는 함수 — 표시 문구는 호출자 소관
*/
export function applySpeakerLabels(
lines: readonly TranscriptLine[],
segments: readonly SpeakerSegment[],
labelFor: (rawSpeaker: string) => string = (raw) => raw,
): TranscriptLine[] {
return lines.map((line) => {
if (!line.timed) return line
const seconds = line.timestampMs / 1000
const match = segments.find((s) => seconds >= s.start && seconds <= s.end)
if (!match) return line
return { ...line, speaker: labelFor(match.speaker) }
})
}
/**
* 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기의 조각들로 나눈다.
* 한 줄이 maxChars 보다 길면 그 줄 하나로 한 조각을 만든다(줄 중간은 자르지 않는다).
* 빈 줄은 버린다.
*/
export function chunkTranscriptByLines(text: string | null | undefined, maxChars: number): string[] {
if (!text) return []
const chunks: string[] = []
let current: string[] = []
let size = 0
for (const raw of text.split('\n')) {
const line = raw.trimEnd()
if (!line.trim()) continue
if (current.length > 0 && size + line.length + 1 > maxChars) {
chunks.push(current.join('\n'))
current = []
size = 0
}
current.push(line)
size += line.length + 1
}
if (current.length > 0) chunks.push(current.join('\n'))
return chunks
}
const TIMED_LINE = /^\s*\[\d{1,3}:\d{2}\]/
function countTimedLines(text: string): number {
return text.split('\n').filter((l) => TIMED_LINE.test(l)).length
}
/**
* LLM 이 전사 조각을 다시 쓴 결과가 잘렸는지(출력 토큰 한도 등) 추정한다.
* - 입력에 시각 줄이 있으면: 출력의 시각 줄이 입력의 80% 미만이면 잘린 것으로 본다.
* - 없으면: 출력 길이가 입력의 50% 미만이면 잘린 것으로 본다.
* 잘린 결과로 전사를 덮어쓰면 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라진다.
*/
export function looksTruncatedRewrite(input: string, output: string): boolean {
const trimmed = output.trim()
if (!trimmed) return true
const inputTimed = countTimedLines(input)
if (inputTimed > 0) {
return countTimedLines(trimmed) < Math.ceil(inputTimed * 0.8)
}
return trimmed.length < input.trim().length * 0.5
}

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))
}

View file

@ -1,7 +1,12 @@
// packages/core/src/utils/meeting-markdown.ts
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티.
//
// 회의 내보내기(PDF/DOCX)가 쓰는 마크다운 해석의 단일 정본이다. 예전엔 정규식 체인
// (markdownToSimpleHtml), DOCX 줄 스위치, 쓰이지 않던 parseMinutes 가 제각각 해석했고,
// HTML 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라지고 줄바꿈·표가 깨졌다.
// 이제 parseMarkdownBlocks 한 곳에서 블록으로 나누고, HTML/DOCX 는 블록을 렌더링만 한다.
import type { MeetingMinutes, MeetingSessionDetail } from '../types'
import type { MeetingSessionDetail } from '../types'
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
@ -24,55 +29,6 @@ export function formatDateRange(startedAt: number, endedAt: number | null): stri
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
}
export function parseMinutes(markdown: string): MeetingMinutes {
const minutes: MeetingMinutes = {
summary: '',
decisions: [],
actionItems: [],
timeline: [],
}
const sections = markdown.split(/^## /m)
for (const section of sections) {
const lines = section.trim().split('\n')
const heading = lines[0]?.toLowerCase() ?? ''
const body = lines.slice(1).join('\n').trim()
if (heading.includes('요약') || heading.includes('summary')) {
minutes.summary = body
} else if (heading.includes('결정') || heading.includes('decision')) {
minutes.decisions = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => l.replace(/^[-*]\s*/, '').trim())
} else if (heading.includes('할 일') || heading.includes('action')) {
minutes.actionItems = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => {
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
return { task: text }
})
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
const tableRows = body
.split('\n')
.filter((l) => l.includes('|') && !l.includes('---'))
for (const row of tableRows) {
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
if (cells.length >= 2) {
minutes.timeline.push({
time: cells[0],
content: cells[1],
type: cells[1].includes('📝') ? 'memo' : 'transcript',
})
}
}
}
}
return minutes
}
export function buildExportMarkdown(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const memoTable = session.memos.length > 0
@ -102,15 +58,228 @@ ${session.rawTranscript ?? '(없음)'}
`
}
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
export function markdownToSimpleHtml(md: string): string {
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^- \[.\] (.+)$/gm, '<li>$1</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/^(?!<[h|l|t|p])/gm, '')
// ============================================================
// 블록 파서 (순수)
// ============================================================
export type MarkdownBlock =
| { type: 'heading'; level: 1 | 2 | 3; text: string }
| { type: 'bullet'; text: string }
| { type: 'checkbox'; checked: boolean; text: string }
| { type: 'table'; header: string[]; rows: string[][] }
| { type: 'paragraph'; lines: string[] }
| { type: 'rule' }
const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/
const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/
const BULLET = /^\s*[-*+]\s+(.*)$/
const RULE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/
const TABLE_ROW = /^\s*\|.*\|?\s*$/
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/
function splitTableRow(line: string): string[] {
let body = line.trim()
if (body.startsWith('|')) body = body.slice(1)
if (body.endsWith('|')) body = body.slice(0, -1)
return body.split('|').map((cell) => cell.trim())
}
/** 마크다운을 블록 목록으로 나눈다 (제목, 불릿, 체크박스, 표, 문단, 구분선). */
export function parseMarkdownBlocks(markdown: string | null | undefined): MarkdownBlock[] {
if (!markdown) return []
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
const blocks: MarkdownBlock[] = []
let paragraph: string[] = []
const flushParagraph = (): void => {
if (paragraph.length > 0) {
blocks.push({ type: 'paragraph', lines: paragraph })
paragraph = []
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!line.trim()) {
flushParagraph()
continue
}
if (TABLE_ROW.test(line) && line.trim().startsWith('|')) {
flushParagraph()
const tableLines: string[] = []
while (i < lines.length && lines[i].trim().startsWith('|')) {
tableLines.push(lines[i])
i++
}
i--
const hasHeader = tableLines.length >= 2 && TABLE_SEPARATOR.test(tableLines[1])
const rows = tableLines.filter((l) => !TABLE_SEPARATOR.test(l)).map(splitTableRow)
if (hasHeader) {
const [header, ...body] = rows
blocks.push({ type: 'table', header, rows: body })
} else {
blocks.push({ type: 'table', header: [], rows })
}
continue
}
if (RULE.test(line)) {
flushParagraph()
blocks.push({ type: 'rule' })
continue
}
const heading = HEADING.exec(line)
if (heading) {
flushParagraph()
const level = Math.min(3, heading[1].length) as 1 | 2 | 3
blocks.push({ type: 'heading', level, text: heading[2] })
continue
}
const checkbox = CHECKBOX.exec(line)
if (checkbox) {
flushParagraph()
blocks.push({ type: 'checkbox', checked: checkbox[1] !== ' ', text: checkbox[2].trim() })
continue
}
const bullet = BULLET.exec(line)
if (bullet) {
flushParagraph()
blocks.push({ type: 'bullet', text: bullet[1].trim() })
continue
}
paragraph.push(line.trim())
}
flushParagraph()
return blocks
}
// ============================================================
// HTML 렌더링 (PDF 생성용)
// ============================================================
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
export function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch)
}
/** 이스케이프 후 인라인 강조(**굵게**)만 태그로 바꾼다. */
function renderInline(text: string): string {
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
}
export const CHECKBOX_UNCHECKED = '☐'
export const CHECKBOX_CHECKED = '☑'
export function renderBlocksToHtml(blocks: readonly MarkdownBlock[]): string {
const out: string[] = []
let listOpen = false
const closeList = (): void => {
if (listOpen) {
out.push('</ul>')
listOpen = false
}
}
for (const block of blocks) {
if (block.type === 'bullet' || block.type === 'checkbox') {
if (!listOpen) {
out.push('<ul>')
listOpen = true
}
const marker = block.type === 'checkbox'
? `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} `
: ''
out.push(`<li>${marker}${renderInline(block.text)}</li>`)
continue
}
closeList()
switch (block.type) {
case 'heading':
out.push(`<h${block.level}>${renderInline(block.text)}</h${block.level}>`)
break
case 'paragraph':
out.push(`<p>${block.lines.map(renderInline).join('<br>')}</p>`)
break
case 'rule':
out.push('<hr>')
break
case 'table': {
const head = block.header.length > 0
? `<thead><tr>${block.header.map((c) => `<th>${renderInline(c)}</th>`).join('')}</tr></thead>`
: ''
const body = block.rows
.map((row) => `<tr>${row.map((c) => `<td>${renderInline(c)}</td>`).join('')}</tr>`)
.join('')
out.push(`<table>${head}<tbody>${body}</tbody></table>`)
break
}
}
}
closeList()
return out.join('\n')
}
/** 마크다운→HTML 변환 (PDF 생성용). 모든 텍스트를 이스케이프한다. */
export function markdownToSimpleHtml(md: string): string {
return renderBlocksToHtml(parseMarkdownBlocks(md))
}
export interface MeetingPdfHtmlInput {
title: string
/** 제목 아래 한 줄(일시·소요 등). 호출자가 번역된 라벨로 만든다. */
meta?: string | null
markdown: string | null | undefined
/** 본문이 비었을 때 보여 줄 문구 (호출자가 번역해 넘긴다) */
emptyMessage?: string
/** html lang 속성 */
lang?: string
}
/**
* 회의 PDF 문서 템플릿(단일 정본). Electron 은 이 HTML 을 불러와 인쇄만 한다.
* 제목·메타·본문 모두 이스케이프된다.
*/
export function renderMeetingPdfHtml(input: MeetingPdfHtmlInput): string {
const bodyHtml = input.markdown && input.markdown.trim()
? markdownToSimpleHtml(input.markdown)
: input.emptyMessage
? `<p>${escapeHtml(input.emptyMessage)}</p>`
: ''
const meta = input.meta ? `\n <p class="meta">${escapeHtml(input.meta)}</p>` : ''
return `<!DOCTYPE html>
<html lang="${escapeHtml(input.lang ?? 'ko')}">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Malgun Gothic', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
h2 { font-size: 16px; color: #444; margin-top: 24px; }
h3 { font-size: 14px; color: #444; margin-top: 16px; }
p { line-height: 1.6; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
th { background: #f5f5f5; font-weight: 600; }
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
</style>
</head>
<body>
<h1>${escapeHtml(input.title)}</h1>${meta}
${bodyHtml}
</body>
</html>`
}