refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
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>`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue