Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ hwp2md/
├── internal/
│ ├── cli/ # CLI 명령 구현
│ ├── config/ # 설정 관리
│ ├── formatter/ # 출력 서식 처리 (공문서 항목 기호 변환 등)
│ ├── ir/ # 중간 표현 (Intermediate Representation)
│ ├── llm/ # LLM 프로바이더
│ │ ├── anthropic/ # Anthropic Claude
Expand Down
56 changes: 51 additions & 5 deletions internal/cli/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
101 changes: 101 additions & 0 deletions internal/formatter/official.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading