diff --git a/CLAUDE.md b/CLAUDE.md index b0648d9..b39b34d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown - Providers: `anthropic/`, `openai/`, `gemini/`, `upstage/`, `ollama/` - Model name auto-detection: `claude-*` → Anthropic, `gpt-*` → OpenAI, etc. +### Formatter (`internal/formatter/`) +- **공문서 서식 변환** (`official.go`): 행정업무운영규정시행규칙 기준 항목 기호를 Markdown 헤딩/리스트로 치환 +- 7단계 계층: `1.`/`□` → `##`, `가.`/`○` → `-`, `1)` → ` -`, `가)` → ` -`, `(1)` → ` -`, `(가)` → ` -`, `①` → ` -` + ### CLI (`internal/cli/`) - Entry point: `cmd/hwp2md/main.go` - Commands: `convert` (default), `extract`, `config`, `providers` @@ -51,6 +55,7 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown | File | Purpose | |------|---------| | `internal/cli/convert.go` | Main conversion pipeline, parser/LLM orchestration | +| `internal/formatter/official.go` | 공문서 항목 기호 감지 및 Markdown 치환 | | `internal/parser/hwpx/parser.go` | HWPX XML parsing, table/cell span handling | | `internal/parser/hwp5/parser.go` | HWP5 OLE2 parsing, main entry point | | `internal/parser/hwp5/section.go` | HWP5 section parsing, table/paragraph extraction | diff --git a/README.md b/README.md index bc345e1..1a80aa8 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ hwp2md/ ├── internal/ │ ├── cli/ # CLI 명령 구현 │ ├── config/ # 설정 관리 +│ ├── formatter/ # 출력 서식 처리 (공문서 항목 기호 변환 등) │ ├── ir/ # 중간 표현 (Intermediate Representation) │ ├── llm/ # LLM 프로바이더 │ │ ├── anthropic/ # Anthropic Claude diff --git a/internal/cli/convert.go b/internal/cli/convert.go index c82b4dc..02cdac0 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/roboco-io/hwp2md/internal/config" + "github.com/roboco-io/hwp2md/internal/formatter" "github.com/roboco-io/hwp2md/internal/ir" "github.com/roboco-io/hwp2md/internal/llm" "github.com/roboco-io/hwp2md/internal/llm/anthropic" @@ -347,44 +348,89 @@ func convertToBasicMarkdown(doc *ir.Document) string { } // Content + prevWasListItem := false for _, block := range doc.Content { switch block.Type { case ir.BlockTypeParagraph: if block.Paragraph != nil { - writeMarkdownParagraph(&sb, block.Paragraph) + prevWasListItem = writeMarkdownParagraph(&sb, block.Paragraph, prevWasListItem) } case ir.BlockTypeTable: + if prevWasListItem { + sb.WriteString("\n") + prevWasListItem = false + } if block.Table != nil { writeMarkdownTable(&sb, block.Table) } case ir.BlockTypeImage: + if prevWasListItem { + sb.WriteString("\n") + prevWasListItem = false + } if block.Image != nil { writeMarkdownImage(&sb, block.Image) } case ir.BlockTypeList: + if prevWasListItem { + sb.WriteString("\n") + prevWasListItem = false + } if block.List != nil { writeMarkdownList(&sb, block.List) } } } + // 마지막 블록이 리스트 항목이었으면 줄바꿈 추가 + if prevWasListItem { + sb.WriteString("\n") + } return sb.String() } -func writeMarkdownParagraph(sb *strings.Builder, p *ir.Paragraph) { +// writeMarkdownParagraph 는 단락을 Markdown으로 변환한다. +// prevWasListItem이 true이면 이전 블록이 공문서 리스트 항목이었음을 의미한다. +// 반환값은 현재 블록이 공문서 리스트 항목인지 여부이다. +func writeMarkdownParagraph(sb *strings.Builder, p *ir.Paragraph, prevWasListItem bool) bool { text := strings.TrimSpace(p.Text) if text == "" { - return + return false } - // Handle headings + // Handle headings from paragraph style if p.Style.HeadingLevel > 0 && p.Style.HeadingLevel <= 6 { + if prevWasListItem { + sb.WriteString("\n") + } prefix := strings.Repeat("#", p.Style.HeadingLevel) sb.WriteString(fmt.Sprintf("%s %s\n\n", prefix, text)) - return + return false } + // 공문서 항목 기호 감지 및 변환 + marker := formatter.DetectOfficialMarker(text) + if marker.Level != formatter.LevelNone { + formatted := formatter.FormatOfficialMarker(marker) + if marker.Level == formatter.Level1 { + // 최상위 항목 → ## 제목 + if prevWasListItem { + sb.WriteString("\n") + } + sb.WriteString(formatted + "\n\n") + return false + } + // 하위 항목 → 들여쓰기 + "- " (연속 리스트 항목 사이 빈 줄 없음) + sb.WriteString(formatted + "\n") + return true + } + + // 일반 단락 + if prevWasListItem { + sb.WriteString("\n") + } sb.WriteString(text + "\n\n") + return false } func writeMarkdownTable(sb *strings.Builder, t *ir.TableBlock) { diff --git a/internal/formatter/official.go b/internal/formatter/official.go new file mode 100644 index 0000000..ddb6af4 --- /dev/null +++ b/internal/formatter/official.go @@ -0,0 +1,101 @@ +// Package formatter 는 문서 변환 시 출력 서식 처리를 담당한다. +package formatter + +import ( + "regexp" + "strings" +) + +// OfficialLevel 은 공문서 항목 기호의 계층 수준을 나타낸다. +// 행정업무의운영및혁신에관한규정시행규칙 별표 기준. +type OfficialLevel int + +const ( + LevelNone OfficialLevel = 0 + Level1 OfficialLevel = 1 // 1. 2. 3. 또는 □ + Level2 OfficialLevel = 2 // 가. 나. 다. 또는 ○ + Level3 OfficialLevel = 3 // 1) 2) 3) + Level4 OfficialLevel = 4 // 가) 나) 다) + Level5 OfficialLevel = 5 // (1) (2) (3) + Level6 OfficialLevel = 6 // (가) (나) (다) + Level7 OfficialLevel = 7 // ① ② ③ +) + +// OfficialMarkerResult 는 항목 기호 감지 결과를 담는다. +type OfficialMarkerResult struct { + Level OfficialLevel + Content string // 기호를 제외한 본문 +} + +// 한글 가나다 문자 목록 (행정업무규정 기준) +const koreanSyllables = "가나다라마바사아자차카타파하" + +// 원문자 (① ~ ⑳) +const circledNumbers = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" + +// 패턴 정의: 순서 중요 — 더 구체적인 패턴(괄호 포함)을 먼저 검사 +var officialPatterns = []struct { + re *regexp.Regexp + level OfficialLevel +}{ + // Level 5: (1) (2) (3) — 괄호 + 아라비아 숫자 + {regexp.MustCompile(`^\((\d+)\)\s+(.+)$`), Level5}, + // Level 6: (가) (나) (다) — 괄호 + 한글 + {regexp.MustCompile(`^\([` + koreanSyllables + `]\)\s+(.+)$`), Level6}, + + // Level 7: ①②③ — 원문자 + {regexp.MustCompile(`^[` + circledNumbers + `]\s*(.+)$`), Level7}, + + // Level 1: □ 기호 + {regexp.MustCompile(`^□\s*(.+)$`), Level1}, + // Level 2: ○ 기호 + {regexp.MustCompile(`^○\s*(.+)$`), Level2}, + + // Level 1: 1. 2. 3. — 아라비아 숫자 + 마침표 + {regexp.MustCompile(`^(\d+)\.\s+(.+)$`), Level1}, + // Level 2: 가. 나. 다. — 한글 + 마침표 + {regexp.MustCompile(`^[` + koreanSyllables + `]\.\s+(.+)$`), Level2}, + + // Level 3: 1) 2) 3) — 아라비아 숫자 + 닫는 괄호 + {regexp.MustCompile(`^(\d+)\)\s+(.+)$`), Level3}, + // Level 4: 가) 나) 다) — 한글 + 닫는 괄호 + {regexp.MustCompile(`^[` + koreanSyllables + `]\)\s+(.+)$`), Level4}, +} + +// DetectOfficialMarker 는 텍스트가 공문서 항목 기호로 시작하는지 검사한다. +func DetectOfficialMarker(text string) OfficialMarkerResult { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return OfficialMarkerResult{Level: LevelNone} + } + + for _, p := range officialPatterns { + matches := p.re.FindStringSubmatch(trimmed) + if matches != nil { + content := strings.TrimSpace(matches[len(matches)-1]) + return OfficialMarkerResult{ + Level: p.level, + Content: content, + } + } + } + + return OfficialMarkerResult{Level: LevelNone} +} + +// FormatOfficialMarker 는 감지된 항목 기호를 Markdown 형식으로 변환한다. +// - Level 1 → "## 내용" +// - Level 2 이상 → 깊이에 따른 탭(2칸 스페이스) + "- 내용" +func FormatOfficialMarker(result OfficialMarkerResult) string { + if result.Level == LevelNone { + return "" + } + + if result.Level == Level1 { + return "## " + result.Content + } + + // Level 2 = 들여쓰기 없음, Level 3 = 2칸, Level 4 = 4칸, ... + indent := strings.Repeat(" ", int(result.Level)-2) + return indent + "- " + result.Content +} diff --git a/internal/formatter/official_test.go b/internal/formatter/official_test.go new file mode 100644 index 0000000..8b70750 --- /dev/null +++ b/internal/formatter/official_test.go @@ -0,0 +1,184 @@ +package formatter + +import ( + "testing" +) + +func TestDetectOfficialMarker(t *testing.T) { + tests := []struct { + name string + input string + level OfficialLevel + content string + }{ + // 감지 안 됨 + {"빈 문자열", "", LevelNone, ""}, + {"일반 텍스트", "일반 문장입니다.", LevelNone, ""}, + {"숫자로 시작하지만 마커 아님", "100명이 참여했다.", LevelNone, ""}, + + // Level 1: □ + {"□ 기호", "□ 추진 배경", Level1, "추진 배경"}, + {"□ 기호 (앞뒤 공백)", " □ 추진 배경 ", Level1, "추진 배경"}, + + // Level 1: 아라비아 숫자 + 마침표 + {"숫자 마침표 1", "1. 원서 접수처 및 마감 일자", Level1, "원서 접수처 및 마감 일자"}, + {"숫자 마침표 2", "2. 채용 분야 및 인원", Level1, "채용 분야 및 인원"}, + {"숫자 마침표 10", "10. 기타 사항", Level1, "기타 사항"}, + + // Level 2: ○ + {"○ 기호", "○ 부족한 인력 보충", Level2, "부족한 인력 보충"}, + + // Level 2: 한글 + 마침표 + {"가 마침표", "가. 구청", Level2, "구청"}, + {"나 마침표", "나. 우편 접수", Level2, "우편 접수"}, + {"다 마침표", "다. 온라인 접수", Level2, "온라인 접수"}, + {"하 마침표", "하. 마지막 항목", Level2, "마지막 항목"}, + + // Level 3: 숫자 + 닫는 괄호 + {"숫자 괄호 1", "1) 환경자원과", Level3, "환경자원과"}, + {"숫자 괄호 2", "2) 환경미화과", Level3, "환경미화과"}, + + // Level 4: 한글 + 닫는 괄호 + {"가 괄호", "가) 정규직", Level4, "정규직"}, + {"나 괄호", "나) 계약직", Level4, "계약직"}, + + // Level 5: (숫자) + {"괄호 숫자 1", "(1) 서류 전형", Level5, "서류 전형"}, + {"괄호 숫자 2", "(2) 면접 전형", Level5, "면접 전형"}, + + // Level 6: (한글) + {"괄호 가", "(가) 행정 분야", Level6, "행정 분야"}, + {"괄호 나", "(나) 기술 분야", Level6, "기술 분야"}, + + // Level 7: 원문자 + {"원문자 1", "① 이력서", Level7, "이력서"}, + {"원문자 2", "② 자기소개서", Level7, "자기소개서"}, + {"원문자 10", "⑩ 기타 서류", Level7, "기타 서류"}, + + // 엣지 케이스: 마커 뒤에 공백 없이 바로 텍스트 + {"□ 공백 없음", "□추진 배경", Level1, "추진 배경"}, + {"○ 공백 없음", "○부족한 인력", Level2, "부족한 인력"}, + {"① 공백 없음", "①이력서", Level7, "이력서"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := DetectOfficialMarker(tc.input) + if result.Level != tc.level { + t.Errorf("Level: got %d, want %d", result.Level, tc.level) + } + if tc.level != LevelNone && result.Content != tc.content { + t.Errorf("Content: got %q, want %q", result.Content, tc.content) + } + }) + } +} + +func TestFormatOfficialMarker(t *testing.T) { + tests := []struct { + name string + result OfficialMarkerResult + expected string + }{ + { + "LevelNone", + OfficialMarkerResult{Level: LevelNone}, + "", + }, + { + "Level1 → ## heading", + OfficialMarkerResult{Level: Level1, Content: "추진 배경"}, + "## 추진 배경", + }, + { + "Level2 → - item", + OfficialMarkerResult{Level: Level2, Content: "부족한 인력 보충"}, + "- 부족한 인력 보충", + }, + { + "Level3 → 2칸 들여쓰기 + - item", + OfficialMarkerResult{Level: Level3, Content: "환경자원과"}, + " - 환경자원과", + }, + { + "Level4 → 4칸 들여쓰기 + - item", + OfficialMarkerResult{Level: Level4, Content: "정규직"}, + " - 정규직", + }, + { + "Level5 → 6칸 들여쓰기 + - item", + OfficialMarkerResult{Level: Level5, Content: "서류 전형"}, + " - 서류 전형", + }, + { + "Level6 → 8칸 들여쓰기 + - item", + OfficialMarkerResult{Level: Level6, Content: "행정 분야"}, + " - 행정 분야", + }, + { + "Level7 → 10칸 들여쓰기 + - item", + OfficialMarkerResult{Level: Level7, Content: "이력서"}, + " - 이력서", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := FormatOfficialMarker(tc.result) + if got != tc.expected { + t.Errorf("got %q, want %q", got, tc.expected) + } + }) + } +} + +func TestDetectAndFormat_IssueExample1(t *testing.T) { + // 이슈 #22 예시 1: □ + ○ 조합 + inputs := []struct { + text string + expected string + }{ + {"□ 추진 배경", "## 추진 배경"}, + {"○ 부족한 인력 보충으로 업무 수행을 원활히 하고자 함", "- 부족한 인력 보충으로 업무 수행을 원활히 하고자 함"}, + {"○ 시민들에게 다양한 일자리를 제공하기 위함", "- 시민들에게 다양한 일자리를 제공하기 위함"}, + } + + for _, tc := range inputs { + result := DetectOfficialMarker(tc.text) + if result.Level == LevelNone { + t.Errorf("마커 감지 실패: %q", tc.text) + continue + } + got := FormatOfficialMarker(result) + if got != tc.expected { + t.Errorf("입력 %q → got %q, want %q", tc.text, got, tc.expected) + } + } +} + +func TestDetectAndFormat_IssueExample2(t *testing.T) { + // 이슈 #22 예시 2: 숫자 + 한글 가나다 + 숫자 괄호 조합 + inputs := []struct { + text string + expected string + }{ + {"1. 원서 접수처 및 마감 일자", "## 원서 접수처 및 마감 일자"}, + {"가. 구청", "- 구청"}, + {"1) 환경자원과", " - 환경자원과"}, + {"2) 환경미화과", " - 환경미화과"}, + {"나. 우편 접수", "- 우편 접수"}, + {"다. 온라인 접수", "- 온라인 접수"}, + } + + for _, tc := range inputs { + result := DetectOfficialMarker(tc.text) + if result.Level == LevelNone { + t.Errorf("마커 감지 실패: %q", tc.text) + continue + } + got := FormatOfficialMarker(result) + if got != tc.expected { + t.Errorf("입력 %q → got %q, want %q", tc.text, got, tc.expected) + } + } +}