📦 Initialize Geulbeot structure and merge Prompts & test projects

This commit is contained in:
2026-03-05 11:32:29 +09:00
commit 555a954458
687 changed files with 205247 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# 글벗 API Keys
# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요
# cp .env.sample .env
CLAUDE_API_KEY=여기에_키값_입력
GEMINI_API_KEY=여기에_키값_입력
GPT_API_KEY=여기에_키값_입력

32
03. Code/geulbeot_8th/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Temp files
*.tmp
*.temp
# API Keys - Gitea에 올리지 않기!
api_keys.json

View File

@@ -0,0 +1 @@
web: gunicorn app:app

View File

@@ -0,0 +1,446 @@
# 글벗 (Geulbeot) v8.0
**문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화**
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
v8에서는 **문서 유형 분석·등록 시스템**을 구축했습니다.
HWPX 파일을 업로드하면 12종의 추출 도구가 XML을 코드 기반으로 파싱하고,
시맨틱 매퍼가 요소 의미를 판별한 뒤, 스타일 생성기가 CSS를 산출하여
사용자 정의 문서 유형으로 등록합니다. 등록된 유형은 기획서·보고서와 동일하게 문서 생성에 사용됩니다.
---
## 🏗 아키텍처 (Architecture)
### 핵심 흐름
```
자료 입력 (파일/폴더)
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성
RAG 파이프라인 (9단계) ─── 공통 처리
문서 유형 선택
├─ 기획서 (기본)
├─ 보고서 (기본)
├─ 발표자료 (기본)
└─ 사용자 등록 (v8 — HWPX 분석 → 자동 등록)
글벗 표준 HTML 생성 ◀── 템플릿 스타일 + 시맨틱 맵 참조
웹 편집기 (수기 편집 / AI 편집)
출력 (HTML / PDF / HWP)
```
### 1. Backend (Python Flask)
- **Language**: Python 3.13
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
- **AI**:
- Claude API (Anthropic) — 기획서 생성, AI 편집, 문서 유형 맥락 분석
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
- Gemini API — 보고서 콘텐츠·HTML 생성
- **Features**:
- 자료 입력 → 9단계 RAG 파이프라인
- 문서 유형별 생성: 기획서 (Claude), 보고서 (Gemini), 사용자 정의 유형 (v8 신규)
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
- 문서 유형 분석·등록 (v8 신규): HWPX 업로드 → 12종 도구 추출 → 시맨틱 매핑 → 스타일 생성 → 유형 CRUD
- HWPX 템플릿 관리: 추출·저장·교체·삭제
- HWP 변환: 하이브리드 방식
- PDF 변환: WeasyPrint 기반
### 2. Frontend (순수 JavaScript)
- **Features**:
- 웹 WYSIWYG 편집기 — 생성된 문서 직접 수정
- 작성 방식 선택 탭: 형식만 변경 / 내용 재구성 / 신규 작성
- 문서 유형 선택 UI: 기본 3종 + 사용자 등록 유형 동적 표시
- 템플릿 관리 UI: 사이드바 목록·선택·삭제, 요소별 체크박스
- HTML / PDF / HWP 다운로드
### 3. 변환 엔진 (Converters)
- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립
- **분량 자동 판단**: 5,000자 기준
- **HWP 변환 (하이브리드)**: HTML 분석 → pyhwpx 변환 → HWPX 스타일 주입 → 표 열 너비 수정
### 4. HWPX 추출 도구 12종 (v8 신규)
HWPX XML에서 특정 항목을 **코드 기반**으로 추출하는 모듈 패키지 (`handlers/tools/`):
| 도구 | 대상 | 추출 내용 |
|------|------|----------|
| page_setup | §7 용지/여백 | pagePr, margin, 용지 크기 |
| font | §3 글꼴 | fontface → 폰트명·유형 매핑 |
| char_style | §4 글자 모양 | charPr 28개 속성 전체 |
| para_style | §5 문단 모양 | paraPr 23개 속성 전체 |
| border_fill | §2 테두리/배경 | borderFill, 색상·선 종류 |
| table | §6 표 | tbl, tc, 병합·너비·셀 구조 |
| header_footer | §8 머리말/꼬리말 | headerFooter 영역 |
| section | §9 구역 정의 | secPr, 다단, 페이지 속성 |
| style_def | 스타일 정의 | styles 목록 (charPr + paraPr 조합) |
| numbering | 번호매기기 | 글머리표·번호 체계 |
| image | 이미지 | 그리기 객체, 크기·위치 |
| content_order | 본문 순서 | section*.xml 문단·표·이미지 순서 |
- 추출 실패 시 `None` 반환 (디폴트값 절대 생성 안 함)
- 모든 단위 변환은 `hwpx_utils` 사용 (hwpunit→mm, charsize→pt)
- `hwpx_domain_guide.md` 기준 준수
### 5. 문서 유형 분석·등록 (v8 신규)
HWPX 업로드 → 자동 분석 → 사용자 정의 문서 유형 등록:
1. **DocTemplateAnalyzer**: HWPX 압축 해제 → 12종 도구로 코드 기반 추출
2. **SemanticMapper**: 추출 결과에서 요소 의미 판별 (헤더표/푸터표/제목블록/데이터표/섹션)
3. **StyleGenerator**: 추출값 → CSS 생성 (charPr→클래스, paraPr→클래스, 폰트 매핑, 줄간격)
4. **ContentAnalyzer**: template_info + semantic_map → content_prompt.json (placeholder 의미·유형·작성 패턴)
5. **DocTypeAnalyzer**: AI로 맥락(목적/문서유형)과 구조 가이드(섹션별 작성법) 분석 — 레이아웃은 코드 추출
6. **TemplateManager**: 템플릿 CRUD (생성·조회·삭제·교체), template.html 조립
7. **CustomDocType**: 등록된 유형으로 실제 문서 생성 — template.html에 사용자 콘텐츠 채움
### 6. 주요 시나리오 (Core Scenarios)
1. **기획서 생성**: RAG 분석 후 Claude API가 구조 추출 → 배치 → 글벗 표준 HTML 생성
2. **보고서 생성**: RAG 파이프라인 → Gemini API가 다페이지 HTML 보고서 생성
3. **사용자 정의 문서 생성 (v8 신규)**: 등록된 유형의 template.html + content_prompt.json을 기반으로, 사용자 입력 내용을 정리·재구성하여 문서 생성
4. **문서 유형 등록 (v8 신규)**: HWPX 업로드 → 12종 도구 추출 → 시맨틱 매핑 → CSS 생성 → config.json + template.html + semantic_map.json + style.json 자동 저장
5. **AI 편집**: 웹 편집기에서 전체·부분 수정
6. **HWP 내보내기**: 하이브리드 변환
### 프로세스 플로우
#### RAG 파이프라인 (공통)
```mermaid
flowchart TD
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A[/"📂 자료 입력 (파일/폴더)"/]:::process
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
D{"분량 판단\n5,000자 기준"}:::decision
E["step3: 도메인 분석"]:::process
F["step4: 의미 단위 청킹"]:::process
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
H["step6: 코퍼스 생성"]:::process
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
A --> B --> C --> D
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
D -->|"< 5,000자"| I
I --> J
```
#### 전체 워크플로우 (v8 시점)
```mermaid
flowchart TD
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
classDef newModule fill:#e0f2f1,stroke:#00695c,stroke-width:2px,color:#004d40
classDef uiNew fill:#e8eaf6,stroke:#3949ab,stroke-width:2px,color:#1a237e
A(["📂 자료 입력"]):::startEnd
W{"작성 방식 선택"}:::uiNew
W1["📄 형식만 변경"]:::uiNew
W2["🔄 내용 재구성"]:::uiNew
W3["✨ 신규 작성"]:::uiNew
R["RAG 파이프라인\n9단계 공통 처리"]:::startEnd
B{"문서 유형 선택"}:::decision
C["기획서 생성\n⚡ Claude API"]:::aiClaude
D["보고서 생성\n⚡ Gemini API"]:::aiGemini
E["발표자료\n예정"]:::planned
U["사용자 정의 유형\ntemplate.html 기반\n(v8 신규)"]:::newModule
T["📋 템플릿 + 시맨틱 맵\nstyle.json\nsemantic_map.json\ncontent_prompt.json"]:::newModule
G["글벗 표준 HTML"]:::startEnd
H{"편집 방식"}:::decision
I["웹 편집기\n수기 편집"]:::editStyle
J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude
K{"출력 형식"}:::decision
L["HTML / PDF"]:::exportStyle
M["HWP 변환\n하이브리드"]:::exportStyle
N["PPT\n예정"]:::planned
O(["✅ 최종 산출물"]):::startEnd
A --> W
W --> W1 & W2 & W3
W1 & W2 & W3 --> R
R --> B
B -->|"기획서"| C --> G
B -->|"보고서"| D --> G
B -->|"발표자료"| E -.-> G
B -->|"사용자 유형"| U --> G
T -.->|"스타일·구조 참조"| U
G --> H
H -->|"수기"| I --> K
H -->|"AI"| J --> K
K -->|"웹/인쇄"| L --> O
K -->|"HWP"| M --> O
K -->|"PPT"| N -.-> O
```
#### 문서 유형 등록 (v8 신규)
```mermaid
flowchart TD
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
classDef aiNode fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
classDef dataStore fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A(["📄 HWPX 업로드"]):::startEnd
B["DocTemplateAnalyzer\n12종 tools 코드 추출"]:::newModule
C["SemanticMapper\n요소 의미 판별\n헤더표/푸터표/제목블록/데이터표"]:::newModule
D["StyleGenerator\n추출값 → CSS 생성\ncharPr·paraPr·폰트 매핑"]:::newModule
E["ContentAnalyzer\nplaceholder 의미·유형\ncontent_prompt.json"]:::newModule
F["DocTypeAnalyzer\n⚡ AI 맥락·구조 분석\nconfig.json"]:::aiNode
G["TemplateManager\ntemplate.html 조립"]:::newModule
H[("📋 templates/user/\ntemplates/{tpl_id}/\ndoc_types/{type_id}/")]:::dataStore
A --> B --> C --> D --> E
B --> F
C & D & E & F --> G --> H
```
---
## 🔄 v7 → v8 변경사항
| 영역 | v7 | v8 |
|------|------|------|
| 문서 유형 등록 | 없음 | **HWPX → 자동 분석 → 유형 CRUD** (doc_type_analyzer + custom_doc_type) |
| HWPX 추출 | template/processor.py 단일 | **handlers/tools/ 12종 모듈** (폰트·문단·표·테두리·이미지 등) |
| 시맨틱 매핑 | 없음 | **semantic_mapper** — 요소 의미 판별 (헤더/푸터/제목/데이터표) |
| 스타일 생성 | CSS 자동 생성 (기초) | **style_generator v2.1** — charPr 28개·paraPr 23개 전체 CSS 클래스 |
| 콘텐츠 분석 | 없음 | **content_analyzer** — placeholder 의미·유형·작성 패턴 추출 |
| 템플릿 관리 | 분석·저장·CRUD | **template_manager v5.2** — content_order 기반 본문 조립, 독립 저장 구조 |
| 도메인 지식 | 없음 | **domain/hwpx/** — hwpx_utils + hwpx_domain_guide.md |
| 기본 문서유형 | 하드코딩 | **config.json 3종** (briefing·report·presentation) |
| 사용자 유형 | 없음 | **templates/user/** 디렉토리 (doc_types + templates 분리) |
| 신규 API | — | `/api/doc-types` CRUD, `/api/templates` CRUD, `/api/doc-types/analyze-stream` |
| app.py | 354줄 | 682줄 (+328) |
| Python 전체 | 11,500줄 | 18,917줄 (+7,417) |
---
## 🗺 상태 및 로드맵 (Status & Roadmap)
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현)
- **Phase 2**: 문서 생성 — 기획서·보고서·사용자 정의 유형 AI 생성 (🔧 기본 구현)
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
- **Phase 5**: 문서 유형 분석·등록 — HWPX → 12종 도구 추출 → 시맨틱 매핑 → 유형 CRUD (🔧 기본 구현 · 현재 버전)
- **Phase 6**: HWPX 템플릿 관리 — template_manager v5.2, content_order 기반 조립, 독립 저장 (🔧 기본 구현 · 현재 버전)
- **Phase 7**: UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI (🔧 기본 구현)
- **Phase 8**: 백엔드 재구조화 + 배포 — 패키지 정리, API 키 공통화, 로깅, Docker (예정)
---
## 🚀 시작하기 (Getting Started)
### 사전 요구사항
- Python 3.10+
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집, 문서 유형 분석
- OpenAI API 키 — RAG 파이프라인
- Gemini API 키 — 보고서 콘텐츠·HTML 생성
- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수)
### 환경 설정
```bash
# 저장소 클론
git clone http://[Gitea주소]/kei/geulbeot-v8.git
cd geulbeot-v8
# 가상환경
python -m venv venv
venv\Scripts\activate # Windows
# 패키지 설치
pip install -r requirements.txt
# 환경변수
cp .env.sample .env
# .env 파일을 열어 실제 API 키 입력
```
### .env 작성
```env
CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집, 유형 분석
GPT_API_KEY=sk-proj-your-key-here # RAG 파이프라인
GEMINI_API_KEY=AIzaSy-your-key-here # 보고서 콘텐츠 생성
```
### 실행
```bash
python app.py
# → http://localhost:5000 접속
```
---
## 📂 프로젝트 구조
```
geulbeot_8th/
├── app.py # Flask 웹 서버 — API 라우팅 (682줄)
├── api_config.py # .env 환경변수 로더
├── domain/ # ★ v8 신규 — 도메인 지식
│ └── hwpx/
│ ├── hwpx_domain_guide.md # HWPX 명세서 (§1~§11)
│ └── hwpx_utils.py # 단위 변환 (hwpunit→mm, charsize→pt)
├── handlers/ # 비즈니스 로직
│ ├── common.py # Claude API 호출, JSON/HTML 추출
│ ├── briefing/ # 기획서 처리
│ ├── report/ # 보고서 처리
│ ├── template/ # 템플릿 기본 관리
│ │
│ ├── doc_type_analyzer.py # ★ v8 — 문서 유형 AI 분석 (맥락·구조)
│ ├── doc_template_analyzer.py # ★ v8 — HWPX → 12종 도구 추출 오케스트레이터
│ ├── semantic_mapper.py # ★ v8 — 요소 의미 판별 (헤더/푸터/제목/데이터표)
│ ├── style_generator.py # ★ v8 — 추출값 → CSS 클래스 생성
│ ├── content_analyzer.py # ★ v8 — placeholder 의미·유형·작성 패턴
│ ├── template_manager.py # ★ v8 — 템플릿 CRUD + template.html 조립
│ ├── custom_doc_type.py # ★ v8 — 사용자 정의 유형 문서 생성
│ │
│ └── tools/ # ★ v8 — HWPX 추출 도구 12종
│ ├── page_setup.py # §7 용지/여백
│ ├── font.py # §3 글꼴
│ ├── char_style.py # §4 글자 모양 (charPr 28개)
│ ├── para_style.py # §5 문단 모양 (paraPr 23개)
│ ├── border_fill.py # §2 테두리/배경
│ ├── table.py # §6 표 (병합·너비·셀)
│ ├── header_footer.py # §8 머리말/꼬리말
│ ├── section.py # §9 구역 정의
│ ├── style_def.py # 스타일 정의
│ ├── numbering.py # 번호매기기/글머리표
│ ├── image.py # 이미지/그리기 객체
│ └── content_order.py # 본문 콘텐츠 순서
├── converters/ # 변환 엔진
│ ├── pipeline/ # 9단계 RAG 파이프라인
│ ├── style_analyzer.py # HTML 요소 역할 분류
│ ├── hwpx_generator.py # HWPX 파일 직접 생성
│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑
│ ├── hwpx_style_injector.py # HWPX 커스텀 스타일 주입
│ ├── hwpx_table_injector.py # HWPX 표 열 너비 정밀 수정
│ ├── html_to_hwp.py # 보고서 → HWP 변환
│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환
├── templates/ # 문서 유형 + UI
│ ├── default/doc_types/ # 기본 유형 설정
│ │ ├── briefing/config.json # 기획서
│ │ ├── report/config.json # 보고서
│ │ └── presentation/config.json # 발표자료
│ ├── user/ # ★ v8 — 사용자 등록 데이터
│ │ ├── doc_types/{type_id}/ # config.json + content_prompt.json
│ │ └── templates/{tpl_id}/ # meta.json + style.json + semantic_map.json + template.html
│ ├── hwp_guide.md
│ ├── hwp_html_defaults.json
│ └── index.html # 메인 UI
├── static/
│ ├── js/editor.js # 웹 WYSIWYG 편집기
│ └── css/editor.css # 편집기 스타일
├── .env / .env.sample
├── .gitignore
├── requirements.txt
├── Procfile
└── README.md
```
---
## 🎨 글벗 표준 HTML 양식
| 항목 | 사양 |
|------|------|
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
| 폰트 | Noto Sans KR (Google Fonts) |
| 색상 | Navy 계열 (#1a365d 기본) |
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
---
## ⚠️ 알려진 제한사항
- 로컬 경로 하드코딩: `D:\for python\...` 잔존 (router.py, app.py)
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
- 발표자료: config.json만 존재, 실제 생성 미구현
- 사용자 유형 생성: template.html 기반 채움만 (AI 창작 아닌 정리·재구성)
- 레거시 잔존: prompts/ 디렉토리, templates/hwp_guide.html → .md 전환 중
---
## 📊 코드 규모
| 영역 | 줄 수 |
|------|-------|
| Python 전체 | 18,917 (+7,417) |
| 프론트엔드 (JS + CSS + HTML) | 5,269 (+365) |
| **합계** | **~24,200** |
---
## 📝 버전 이력
| 버전 | 핵심 변경 |
|------|----------|
| v1 | Flask + Claude API 기획서 생성기 |
| v2 | 웹 편집기 추가 |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
| v6 | HWPX 템플릿 분석·저장·관리 |
| v7 | UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI |
| **v8** | **문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화** |
---
## 📝 라이선스
Private — GPD 내부 사용

View File

@@ -0,0 +1,30 @@
"""API 키 관리 - .env 파일에서 읽기"""
import os
from pathlib import Path
def load_api_keys():
"""프로젝트 폴더의 .env에서 API 키 로딩"""
# python-dotenv 있으면 사용
try:
from dotenv import load_dotenv
env_path = Path(__file__).resolve().parent / '.env'
load_dotenv(env_path)
except ImportError:
# python-dotenv 없으면 수동 파싱
env_path = Path(__file__).resolve().parent / '.env'
if env_path.exists():
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
os.environ.setdefault(key.strip(), value.strip())
return {
'CLAUDE_API_KEY': os.getenv('CLAUDE_API_KEY', ''),
'GPT_API_KEY': os.getenv('GPT_API_KEY', ''),
'GEMINI_API_KEY': os.getenv('GEMINI_API_KEY', ''),
'PERPLEXITY_API_KEY': os.getenv('PERPLEXITY_API_KEY', ''),
}
API_KEYS = load_api_keys()

View File

@@ -0,0 +1,683 @@
# -*- coding: utf-8 -*-
"""
글벗 Light v2.0
Flask 라우팅 + 공통 기능
"""
import os
import io
import tempfile
import json
import shutil
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response, session, send_file
import queue
import threading
from handlers.template_manager import TemplateManager
from pathlib import Path
# 문서 유형별 프로세서
from handlers.template import TemplateProcessor
from handlers.briefing import BriefingProcessor
from handlers.report import ReportProcessor
from handlers.custom_doc_type import CustomDocTypeProcessor
from handlers.doc_type_analyzer import DocTypeAnalyzer
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
# processors 딕셔너리에 추가
template_mgr = TemplateManager()
processors = {
'briefing': BriefingProcessor(),
'report': ReportProcessor(),
'template': TemplateProcessor(),
'custom': CustomDocTypeProcessor()
}
DOC_TYPES_DEFAULT = Path('templates/default/doc_types')
DOC_TYPES_USER = Path('templates/user/doc_types')
# ============== 메인 페이지 ==============
@app.route('/')
def index():
"""메인 페이지"""
return render_template('index.html')
@app.route('/api/doc-types', methods=['GET'])
def get_doc_types():
"""문서 유형 목록 조회"""
try:
doc_types = []
# default 폴더 스캔
if DOC_TYPES_DEFAULT.exists():
for folder in DOC_TYPES_DEFAULT.iterdir():
if folder.is_dir():
config_file = folder / 'config.json'
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
doc_types.append(json.load(f))
# user 폴더 스캔
if DOC_TYPES_USER.exists():
for folder in DOC_TYPES_USER.iterdir():
if folder.is_dir():
config_file = folder / 'config.json'
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
doc_types.append(json.load(f))
# order → isDefault 순 정렬
doc_types.sort(key=lambda x: (x.get('order', 999), not x.get('isDefault', False)))
return jsonify(doc_types)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/api/doc-types', methods=['POST'])
def add_doc_type():
"""문서 유형 추가 (분석 결과 저장)"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'JSON 데이터가 필요합니다'}), 400
# user 폴더 생성
DOC_TYPES_USER.mkdir(parents=True, exist_ok=True)
type_id = data.get('id')
if not type_id:
import time
type_id = f"user_{int(time.time())}"
data['id'] = type_id
folder_path = DOC_TYPES_USER / type_id
folder_path.mkdir(parents=True, exist_ok=True)
# config.json 저장
with open(folder_path / 'config.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return jsonify(data)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/api/doc-types/<type_id>', methods=['DELETE'])
def delete_doc_type(type_id):
"""문서 유형 삭제"""
try:
folder_path = DOC_TYPES_USER / type_id
if not folder_path.exists():
return jsonify({'error': '문서 유형을 찾을 수 없습니다'}), 404
shutil.rmtree(folder_path)
return jsonify({'success': True, 'deleted': type_id})
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 생성 API ==============
@app.route('/generate', methods=['POST'])
def generate():
"""문서 생성 API"""
try:
content = ""
if 'file' in request.files and request.files['file'].filename:
file = request.files['file']
content = file.read().decode('utf-8')
elif 'content' in request.form:
content = request.form.get('content', '')
doc_type = request.form.get('doc_type', 'briefing')
if doc_type.startswith('user_'):
options = {
'instruction': request.form.get('instruction', '')
}
result = processors['custom'].generate(content, doc_type, options)
else:
options = {
'page_option': request.form.get('page_option', '1'),
'department': request.form.get('department', ''),
'instruction': request.form.get('instruction', '')
}
processor = processors.get(doc_type, processors['briefing'])
result = processor.generate(content, options)
if 'error' in result:
return jsonify(result), 400 if 'trace' not in result else 500
return jsonify(result)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/generate-report', methods=['POST'])
def generate_report():
"""보고서 생성 API"""
try:
data = request.get_json() or {}
content = data.get('content', '')
options = {
'folder_path': data.get('folder_path', ''),
'cover': data.get('cover', False),
'toc': data.get('toc', False),
'divider': data.get('divider', False),
'instruction': data.get('instruction', ''),
'template_id': data.get('template_id')
}
result = processors['report'].generate(content, options)
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 수정 API ==============
@app.route('/refine', methods=['POST'])
def refine():
"""피드백 반영 API"""
try:
feedback = request.json.get('feedback', '')
current_html = request.json.get('current_html', '') or session.get('current_html', '')
original_html = session.get('original_html', '')
doc_type = request.json.get('doc_type', 'briefing')
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine(feedback, current_html, original_html)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/refine-selection', methods=['POST'])
def refine_selection():
"""선택 부분 수정 API"""
try:
data = request.json
current_html = data.get('current_html', '')
selected_text = data.get('selected_text', '')
user_request = data.get('request', '')
doc_type = data.get('doc_type', 'briefing')
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine_selection(current_html, selected_text, user_request)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============== 다운로드 API ==============
@app.route('/download/html', methods=['POST'])
def download_html():
"""HTML 파일 다운로드"""
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.html'
return Response(
html_content,
mimetype='text/html',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
@app.route('/download/pdf', methods=['POST'])
def download_pdf():
"""PDF 파일 다운로드"""
try:
from weasyprint import HTML
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
pdf_buffer = io.BytesIO()
HTML(string=html_content).write_pdf(pdf_buffer)
pdf_buffer.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.pdf'
return Response(
pdf_buffer.getvalue(),
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
except ImportError:
return jsonify({'error': 'PDF 변환 미지원'}), 501
except Exception as e:
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
# ============== 기타 API ==============
@app.route('/assets/<path:filename>')
def serve_assets(filename):
"""로컬 assets 폴더 서빙"""
assets_dir = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
return send_file(os.path.join(assets_dir, filename))
@app.route('/hwp-script')
def hwp_script():
"""HWP 변환 스크립트 안내"""
return render_template('hwp_guide.html')
@app.route('/health')
def health():
"""헬스 체크"""
return jsonify({'status': 'healthy', 'version': '2.0.0'})
@app.route('/export-hwp', methods=['POST'])
def export_hwp():
"""HWP 변환 (스타일 그루핑 지원)"""
try:
data = request.get_json()
html_content = data.get('html', '')
doc_type = data.get('doc_type', 'briefing')
use_style_grouping = data.get('style_grouping', False) # 새 옵션
if not html_content:
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
temp_dir = tempfile.gettempdir()
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html_content)
# 변환기 선택
if doc_type == 'briefing':
from converters.html_to_hwp_briefing import HtmlToHwpConverter
else:
from converters.html_to_hwp import HtmlToHwpConverter
converter = HtmlToHwpConverter(visible=False)
# 스타일 그루핑 사용 여부
if use_style_grouping:
final_path = converter.convert_with_styles(html_path, hwp_path)
# HWPX 파일 전송
return send_file(
final_path,
as_attachment=True,
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwpx',
mimetype='application/vnd.hancom.hwpx'
)
else:
converter.convert(html_path, hwp_path)
return send_file(
hwp_path,
as_attachment=True,
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp',
mimetype='application/x-hwp'
)
except ImportError as e:
return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
# 기존 add_doc_type 대체 또는 수정
@app.route('/api/doc-types/analyze', methods=['POST'])
def analyze_doc_type():
"""문서 유형 분석 API"""
if 'file' not in request.files:
return jsonify({"error": "파일이 필요합니다"}), 400
file = request.files['file']
doc_name = request.form.get('name', '새 문서 유형')
# 임시 저장
import tempfile
temp_path = os.path.join(tempfile.gettempdir(), file.filename)
file.save(temp_path)
try:
analyzer = DocTypeAnalyzer()
result = analyzer.analyze(temp_path, doc_name)
return jsonify({
"success": True,
"config": result["config"],
"summary": {
"pageCount": result["structure"]["pageCount"],
"sections": len(result["toc"]),
"style": result["style"]
}
})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
os.remove(temp_path)
@app.route('/analyze-styles', methods=['POST'])
def analyze_styles():
"""HTML 스타일 분석 미리보기"""
try:
data = request.get_json()
html_content = data.get('html', '')
if not html_content:
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
from converters.style_analyzer import StyleAnalyzer
from converters.hwp_style_mapping import ROLE_TO_STYLE_NAME
analyzer = StyleAnalyzer()
elements = analyzer.analyze(html_content)
# 요약 정보
summary = analyzer.get_role_summary()
# 상세 정보 (처음 50개만)
details = []
for elem in elements[:50]:
details.append({
'role': elem.role,
'hwp_style': ROLE_TO_STYLE_NAME.get(elem.role, '바탕글'),
'text': elem.text[:50] + ('...' if len(elem.text) > 50 else ''),
'section': elem.section
})
return jsonify({
'total_elements': len(elements),
'summary': summary,
'details': details
})
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/templates', methods=['GET'])
def get_templates():
"""저장된 템플릿 목록 조회"""
try:
templates = template_mgr.list_templates()
return jsonify(templates)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates', methods=['GET'])
def get_templates_api():
"""템플릿 목록 조회 (API 경로)"""
try:
templates = template_mgr.list_templates()
return jsonify(templates)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/analyze-template', methods=['POST'])
def analyze_template():
"""템플릿 추출 및 저장 (doc_template_analyzer → template_manager)"""
try:
if 'file' not in request.files:
return jsonify({'error': '파일이 없습니다'}), 400
file = request.files['file']
name = request.form.get('name', '').strip()
if not name:
return jsonify({'error': '템플릿 이름을 입력해주세요'}), 400
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
# 임시 저장 → HWPX 파싱 → 템플릿 추출
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
try:
# v3 파서 재사용 (HWPX → parsed dict)
from handlers.doc_type_analyzer import DocTypeAnalyzer
parser = DocTypeAnalyzer()
parsed = parser._parse_hwpx(temp_path)
# template_manager로 추출+저장
result = template_mgr.extract_and_save(
parsed, name,
source_file=file.filename
)
return jsonify(result)
finally:
try:
os.remove(temp_path)
except:
pass
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 문서 유형 분석 SSE API ==============
@app.route('/api/doc-types/analyze-stream', methods=['POST'])
def analyze_doc_type_stream():
"""
문서 유형 분석 (SSE 스트리밍)
실시간으로 각 단계의 진행 상황을 전달
"""
import tempfile
# 파일 및 데이터 검증
if 'file' not in request.files:
return jsonify({'error': '파일이 없습니다'}), 400
file = request.files['file']
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
return jsonify({'error': '문서 유형 이름을 입력해주세요'}), 400
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
# 임시 파일 저장
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
# 메시지 큐 생성
message_queue = queue.Queue()
analysis_result = {"data": None, "error": None}
def progress_callback(step_id, status, message):
"""진행 상황 콜백 - 메시지 큐에 추가"""
message_queue.put({
"type": "progress",
"step": step_id,
"status": status,
"message": message
})
def run_analysis():
"""분석 실행 (별도 스레드)"""
try:
analyzer = DocTypeAnalyzer(progress_callback=progress_callback)
result = analyzer.analyze(temp_path, name, description)
# 저장
save_path = analyzer.save_doc_type(result["config"], result.get("template", "") )
analysis_result["data"] = {
"success": True,
"config": result["config"],
"layout": result.get("layout", {}),
"context": result.get("context", {}),
"structure": result.get("structure", {}),
"template_generated": bool(result.get("template_id") or result.get("template")),
"template_id": result.get("template_id"), # ★ 추가
"saved_path": save_path
}
except Exception as e:
import traceback
analysis_result["error"] = {
"message": str(e),
"trace": traceback.format_exc()
}
finally:
# 완료 신호
message_queue.put({"type": "complete"})
# 임시 파일 삭제
try:
os.remove(temp_path)
except:
pass
def generate_events():
"""SSE 이벤트 생성기"""
# 분석 시작
analysis_thread = threading.Thread(target=run_analysis)
analysis_thread.start()
# 이벤트 스트리밍
while True:
try:
msg = message_queue.get(timeout=60) # 60초 타임아웃
if msg["type"] == "complete":
# 분석 완료
if analysis_result["error"]:
yield f"data: {json.dumps({'type': 'error', 'error': analysis_result['error']}, ensure_ascii=False)}\n\n"
else:
yield f"data: {json.dumps({'type': 'result', 'data': analysis_result['data']}, ensure_ascii=False)}\n\n"
break
else:
# 진행 상황
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
except queue.Empty:
# 타임아웃
yield f"data: {json.dumps({'type': 'error', 'error': {'message': '분석 시간 초과'}}, ensure_ascii=False)}\n\n"
break
return Response(
generate_events(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
@app.route('/delete-template/<template_id>', methods=['DELETE'])
def delete_template(template_id):
"""템플릿 삭제 (레거시 호환)"""
try:
result = template_mgr.delete_template(template_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates/<tpl_id>', methods=['GET'])
def get_template(tpl_id):
"""특정 템플릿 조회"""
try:
result = template_mgr.load_template(tpl_id)
if 'error' in result:
return jsonify(result), 404
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates/<tpl_id>', methods=['DELETE'])
def delete_template_new(tpl_id):
"""템플릿 삭제"""
try:
result = template_mgr.delete_template(tpl_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/doc-types/<type_id>/template', methods=['PUT'])
def change_doc_type_template(type_id):
"""문서 유형의 템플릿 교체"""
try:
data = request.get_json()
new_tpl_id = data.get('template_id')
if not new_tpl_id:
return jsonify({'error': 'template_id가 필요합니다'}), 400
result = template_mgr.change_template(type_id, new_tpl_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/doc-types/<type_id>/template', methods=['GET'])
def get_doc_type_template(type_id):
"""문서 유형에 연결된 템플릿 조회"""
try:
result = template_mgr.get_template_for_doctype(type_id)
if 'error' in result:
return jsonify(result), 404
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(host='0.0.0.0', port=port, debug=debug)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
# -*- coding: utf-8 -*-
"""
HTML → HWP 변환기 (기획서 전용)
✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함)
✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원
✅ process-container (단계별 프로세스) 지원
✅ badge 스타일 텍스트 변환
✅ Navy 색상 테마
pip install pyhwpx beautifulsoup4
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup
import os
class Config:
"""페이지 설정"""
PAGE_WIDTH = 210
PAGE_HEIGHT = 297
MARGIN_LEFT = 20
MARGIN_RIGHT = 20
MARGIN_TOP = 20
MARGIN_BOTTOM = 15
HEADER_LEN = 10
FOOTER_LEN = 10
CONTENT_WIDTH = 170
class HtmlToHwpConverter:
"""HTML → HWP 변환기 (기획서 전용)"""
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.cfg = Config()
self.colors = {}
self.is_first_h1 = True
# ─────────────────────────────────────────────────────────
# 초기화 및 유틸리티
# ─────────────────────────────────────────────────────────
def _init_colors(self):
"""색상 팔레트 초기화 (Navy 계열)"""
self.colors = {
'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d
'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282
'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce
'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748
'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568
'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0
'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc
'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0
'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f
'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13
'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b
'white': self.hwp.RGBColor(255, 255, 255),
'black': self.hwp.RGBColor(0, 0, 0),
}
def _mm(self, mm):
"""밀리미터를 HWP 단위로 변환"""
return self.hwp.MiliToHwpUnit(mm)
def _pt(self, pt):
"""포인트를 HWP 단위로 변환"""
return self.hwp.PointToHwpUnit(pt)
def _rgb(self, hex_color):
"""HEX 색상을 RGB로 변환"""
c = hex_color.lstrip('#')
return self.hwp.RGBColor(int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) if len(c) >= 6 else self.hwp.RGBColor(0, 0, 0)
def _font(self, size=10, color='black', bold=False):
"""폰트 설정 (색상 이름 사용)"""
self.hwp.set_font(
FaceName='맑은 고딕',
Height=size,
Bold=bold,
TextColor=self.colors.get(color, self.colors['black'])
)
def _set_font(self, size=11, bold=False, hex_color='#000000'):
"""폰트 설정 (HEX 색상 사용)"""
self.hwp.set_font(
FaceName='맑은 고딕',
Height=size,
Bold=bold,
TextColor=self._rgb(hex_color)
)
def _align(self, align):
"""정렬 설정"""
actions = {
'left': 'ParagraphShapeAlignLeft',
'center': 'ParagraphShapeAlignCenter',
'right': 'ParagraphShapeAlignRight',
'justify': 'ParagraphShapeAlignJustify',
}
if align in actions:
self.hwp.HAction.Run(actions[align])
def _para(self, text='', size=10, color='black', bold=False, align='left'):
"""문단 삽입"""
self._align(align)
self._font(size, color, bold)
if text:
self.hwp.insert_text(text)
self.hwp.BreakPara()
def _exit_table(self):
"""표 편집 모드 종료"""
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
self.hwp.BreakPara()
def _setup_page(self):
"""페이지 설정"""
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
s = self.hwp.HParameterSet.HSecDef
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
self.hwp.HAction.Execute("PageSetup", s.HSet)
print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm")
except Exception as e:
print(f"[경고] 페이지 설정 실패: {e}")
# ─────────────────────────────────────────────────────────
# 머리말 / 꼬리말 (보고서 방식)
# ─────────────────────────────────────────────────────────
def _create_header(self, right_text=""):
"""머리말 생성 (우측 정렬)"""
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
try:
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#4a5568')
if right_text:
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말: {e}")
def _create_footer(self, left_text=""):
"""꼬리말 생성 (좌측 텍스트 + 우측 페이지 번호)"""
print(f" → 꼬리말: {left_text}")
# 1. 꼬리말 열기
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
# 2. 좌측 정렬 + 제목 8pt
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
self._set_font(8, False, '#4a5568')
self.hwp.insert_text(left_text)
# 3. 꼬리말 닫기
self.hwp.HAction.Run("CloseEx")
# 4. 쪽번호 (우측 하단)
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
def _new_section_with_header(self, header_text):
"""새 구역 생성 후 머리말 설정"""
print(f" → 새 구역 머리말: {header_text}")
try:
self.hwp.HAction.Run("BreakSection")
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#4a5568')
self.hwp.insert_text(header_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 구역 머리말: {e}")
# ─────────────────────────────────────────────────────────
# 셀 배경색 설정
# ─────────────────────────────────────────────────────────
def _set_cell_bg(self, color_name):
"""셀 배경색 설정 (색상 이름)"""
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
pset = self.hwp.HParameterSet.HCellBorderFill
pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0)
pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white'])
pset.FillAttr.WindowsBrush = 1
self.hwp.HAction.Execute("CellBorderFill", pset.HSet)
# ─────────────────────────────────────────────────────────
# HTML 요소 변환 (기획서 전용)
# ─────────────────────────────────────────────────────────
def _convert_lead_box(self, elem):
"""lead-box 변환 (핵심 기조 박스)"""
content = elem.find("div")
if not content:
return
text = content.get_text(strip=True)
text = ' '.join(text.split())
print(f" → lead-box")
self.hwp.create_table(1, 1, treat_as_char=True)
self._set_cell_bg('bg-light')
self._font(11.5, 'dark-gray', False)
self.hwp.insert_text(text)
self._exit_table()
def _convert_strategy_grid(self, elem):
"""strategy-grid 변환 (2x2 전략 박스)"""
items = elem.find_all(class_="strategy-item")
if not items:
return
print(f" → strategy-grid: {len(items)} items")
self.hwp.create_table(2, 2, treat_as_char=True)
for i, item in enumerate(items[:4]):
if i > 0:
self.hwp.HAction.Run("MoveRight")
self._set_cell_bg('bg-light')
title = item.find(class_="strategy-title")
if title:
self._font(10, 'primary-navy', True)
self.hwp.insert_text(title.get_text(strip=True))
self.hwp.BreakPara()
p = item.find("p")
if p:
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(p.get_text(strip=True))
self._exit_table()
def _convert_process_container(self, elem):
"""process-container 변환 (단계별 프로세스)"""
steps = elem.find_all(class_="process-step")
if not steps:
return
print(f" → process-container: {len(steps)} steps")
rows = len(steps)
self.hwp.create_table(rows, 2, treat_as_char=True)
for i, step in enumerate(steps):
if i > 0:
self.hwp.HAction.Run("MoveRight")
# 번호 셀
num = step.find(class_="step-num")
self._set_cell_bg('primary-navy')
self._font(10, 'white', True)
self._align('center')
if num:
self.hwp.insert_text(num.get_text(strip=True))
self.hwp.HAction.Run("MoveRight")
# 내용 셀
content = step.find(class_="step-content")
self._set_cell_bg('bg-light')
self._font(10.5, 'dark-gray', False)
self._align('left')
if content:
self.hwp.insert_text(content.get_text(strip=True))
self._exit_table()
def _convert_data_table(self, table):
"""data-table 변환 (badge 포함)"""
data = []
thead = table.find("thead")
if thead:
ths = thead.find_all("th")
data.append([th.get_text(strip=True) for th in ths])
tbody = table.find("tbody")
if tbody:
for tr in tbody.find_all("tr"):
row = []
for td in tr.find_all("td"):
badge = td.find(class_="badge")
if badge:
badge_class = ' '.join(badge.get('class', []))
badge_text = badge.get_text(strip=True)
if 'badge-safe' in badge_class:
row.append(f"[✓ {badge_text}]")
elif 'badge-caution' in badge_class:
row.append(f"[△ {badge_text}]")
elif 'badge-risk' in badge_class:
row.append(f"[✗ {badge_text}]")
else:
row.append(f"[{badge_text}]")
else:
row.append(td.get_text(strip=True))
data.append(row)
if not data:
return
rows = len(data)
cols = len(data[0]) if data else 0
print(f" → data-table: {rows}×{cols}")
self.hwp.create_table(rows, cols, treat_as_char=True)
for row_idx, row in enumerate(data):
for col_idx, cell_text in enumerate(row):
is_header = (row_idx == 0)
is_first_col = (col_idx == 0 and not is_header)
is_safe = '[✓' in str(cell_text)
is_caution = '[△' in str(cell_text)
is_risk = '[✗' in str(cell_text)
if is_header:
self._set_cell_bg('primary-navy')
self._font(9, 'white', True)
elif is_first_col:
self._set_cell_bg('bg-light')
self._font(9.5, 'primary-navy', True)
elif is_safe:
self._font(9.5, 'badge-safe', True)
elif is_caution:
self._font(9.5, 'badge-caution', True)
elif is_risk:
self._font(9.5, 'badge-risk', True)
else:
self._font(9.5, 'dark-gray', False)
self._align('center')
self.hwp.insert_text(str(cell_text))
if not (row_idx == rows - 1 and col_idx == cols - 1):
self.hwp.HAction.Run("MoveRight")
self._exit_table()
def _convert_qa_grid(self, elem):
"""qa-grid 변환 (Q&A 2단 박스)"""
items = elem.find_all(class_="qa-item")
if not items:
return
print(f" → qa-grid: {len(items)} items")
self.hwp.create_table(1, 2, treat_as_char=True)
for i, item in enumerate(items[:2]):
if i > 0:
self.hwp.HAction.Run("MoveRight")
self._set_cell_bg('bg-light')
text = item.get_text(strip=True)
strong = item.find("strong")
if strong:
q_text = strong.get_text(strip=True)
a_text = text.replace(q_text, '').strip()
self._font(9.5, 'primary-navy', True)
self.hwp.insert_text(q_text)
self.hwp.BreakPara()
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(a_text)
else:
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(text)
self._exit_table()
def _convert_bottom_box(self, elem):
"""bottom-box 변환 (핵심 결론 박스)"""
left = elem.find(class_="bottom-left")
right = elem.find(class_="bottom-right")
if not left or not right:
return
left_text = ' '.join(left.get_text().split())
right_text = right.get_text(strip=True)
print(f" → bottom-box")
self.hwp.create_table(1, 2, treat_as_char=True)
# 좌측 (Navy 배경)
self._set_cell_bg('primary-navy')
self._font(10.5, 'white', True)
self._align('center')
self.hwp.insert_text(left_text)
self.hwp.HAction.Run("MoveRight")
# 우측 (연한 배경)
self._set_cell_bg('bg-light')
self._font(10.5, 'primary-navy', True)
self._align('center')
self.hwp.insert_text(right_text)
self._exit_table()
def _convert_section(self, section):
"""section 변환"""
title = section.find(class_="section-title")
if title:
self._para("" + title.get_text(strip=True), 12, 'primary-navy', True)
strategy_grid = section.find(class_="strategy-grid")
if strategy_grid:
self._convert_strategy_grid(strategy_grid)
process = section.find(class_="process-container")
if process:
self._convert_process_container(process)
table = section.find("table", class_="data-table")
if table:
self._convert_data_table(table)
ul = section.find("ul")
if ul:
for li in ul.find_all("li", recursive=False):
keyword = li.find(class_="keyword")
if keyword:
kw_text = keyword.get_text(strip=True)
full = li.get_text(strip=True)
rest = full.replace(kw_text, '', 1).strip()
self._font(10.5, 'primary-navy', True)
self.hwp.insert_text("" + kw_text + " ")
self._font(10.5, 'dark-gray', False)
self.hwp.insert_text(rest)
self.hwp.BreakPara()
else:
self._para("" + li.get_text(strip=True), 10.5, 'dark-gray')
qa_grid = section.find(class_="qa-grid")
if qa_grid:
self._convert_qa_grid(qa_grid)
self._para()
def _convert_sheet(self, sheet, is_first_page=False, footer_title=""):
"""한 페이지(sheet) 변환"""
# 첫 페이지에서만 머리말/꼬리말 설정
if is_first_page:
# 머리말: page-header에서 텍스트 추출
header = sheet.find(class_="page-header")
if header:
left = header.find(class_="header-left")
right = header.find(class_="header-right")
# 우측 텍스트 사용 (부서명 등)
header_text = right.get_text(strip=True) if right else ""
if header_text:
self._create_header(header_text)
# 꼬리말: 제목 + 페이지번호
self._create_footer(footer_title)
# 대제목
title = sheet.find(class_="header-title")
if title:
title_text = title.get_text(strip=True)
if '[첨부]' in title_text:
self._para(title_text, 15, 'primary-navy', True, 'left')
self._font(10, 'secondary-navy', False)
self._align('left')
self.hwp.insert_text("" * 60)
self.hwp.BreakPara()
else:
self._para(title_text, 23, 'primary-navy', True, 'center')
self._font(10, 'secondary-navy', False)
self._align('center')
self.hwp.insert_text("" * 45)
self.hwp.BreakPara()
self._para()
# 리드 박스
lead_box = sheet.find(class_="lead-box")
if lead_box:
self._convert_lead_box(lead_box)
self._para()
# 섹션들
for section in sheet.find_all(class_="section"):
self._convert_section(section)
# 하단 박스
bottom_box = sheet.find(class_="bottom-box")
if bottom_box:
self._para()
self._convert_bottom_box(bottom_box)
# ─────────────────────────────────────────────────────────
# 메인 변환 함수
# ─────────────────────────────────────────────────────────
def convert(self, html_path, output_path):
"""HTML → HWP 변환 실행"""
print("=" * 60)
print("HTML → HWP 변환기 (기획서 전용)")
print(" ✓ 머리말/꼬리말: 보고서 방식")
print(" ✓ Navy 테마, 기획서 요소")
print("=" * 60)
print(f"\n[입력] {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
# 제목 추출 (꼬리말용)
title_tag = soup.find('title')
if title_tag:
full_title = title_tag.get_text(strip=True)
footer_title = full_title.split(':')[0].strip()
else:
footer_title = ""
self.hwp.FileNew()
self._init_colors()
self._setup_page()
# 페이지별 변환
sheets = soup.find_all(class_="sheet")
total = len(sheets)
print(f"[변환] 총 {total} 페이지\n")
for i, sheet in enumerate(sheets, 1):
print(f"[{i}/{total}] 페이지 처리 중...")
self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title)
if i < total:
self.hwp.HAction.Run("BreakPage")
# 저장
self.hwp.SaveAs(output_path)
print(f"\n✅ 저장 완료: {output_path}")
def close(self):
"""HWP 종료"""
try:
self.hwp.Quit()
except:
pass
def main():
"""메인 실행"""
html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html"
output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp"
print("=" * 60)
print("HTML → HWP 변환기 (기획서)")
print("=" * 60)
print()
try:
converter = HtmlToHwpConverter(visible=True)
converter.convert(html_path, output_path)
print("\n" + "=" * 60)
print("✅ 변환 완료!")
print("=" * 60)
input("\nEnter를 누르면 HWP가 닫힙니다...")
converter.close()
except FileNotFoundError:
print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}")
print("경로를 확인해주세요.")
except Exception as e:
print(f"\n[에러] {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,434 @@
# -*- coding: utf-8 -*-
"""
HWP 스타일 매핑 모듈 v2.0
HTML 역할(Role) → HWP 스타일 매핑
✅ v2.0 변경사항:
- pyhwpx API에 맞게 apply_to_hwp() 재작성
- CharShape/ParaShape 직접 설정 방식
- 역할 → 개요 스타일 매핑
"""
from dataclasses import dataclass
from typing import Dict, Optional
from enum import Enum
class HwpStyleType(Enum):
"""HWP 스타일 유형"""
PARAGRAPH = "paragraph"
CHARACTER = "character"
@dataclass
class HwpStyle:
"""HWP 스타일 정의"""
id: int
name: str
type: HwpStyleType
font_size: float
font_bold: bool = False
font_color: str = "000000"
align: str = "justify"
line_spacing: float = 160
space_before: float = 0
space_after: float = 0
indent_left: float = 0
indent_first: float = 0
bg_color: Optional[str] = None
# =============================================================================
# 기본 스타일 템플릿
# =============================================================================
DEFAULT_STYLES: Dict[str, HwpStyle] = {
# 표지
"COVER_TITLE": HwpStyle(
id=100, name="표지제목", type=HwpStyleType.PARAGRAPH,
font_size=32, font_bold=True, align="center",
space_before=20, space_after=10, font_color="1a365d"
),
"COVER_SUBTITLE": HwpStyle(
id=101, name="표지부제", type=HwpStyleType.PARAGRAPH,
font_size=18, font_bold=False, align="center",
font_color="555555"
),
"COVER_INFO": HwpStyle(
id=102, name="표지정보", type=HwpStyleType.PARAGRAPH,
font_size=12, align="center", font_color="666666"
),
# 목차
"TOC_H1": HwpStyle(
id=110, name="목차1수준", type=HwpStyleType.PARAGRAPH,
font_size=12, font_bold=True, indent_left=0
),
"TOC_H2": HwpStyle(
id=111, name="목차2수준", type=HwpStyleType.PARAGRAPH,
font_size=11, indent_left=20
),
"TOC_H3": HwpStyle(
id=112, name="목차3수준", type=HwpStyleType.PARAGRAPH,
font_size=10, indent_left=40, font_color="666666"
),
# 제목 계층 (개요 1~7 매핑)
"H1": HwpStyle(
id=1, name="개요 1", type=HwpStyleType.PARAGRAPH,
font_size=20, font_bold=True, align="left",
space_before=30, space_after=15, font_color="1a365d"
),
"H2": HwpStyle(
id=2, name="개요 2", type=HwpStyleType.PARAGRAPH,
font_size=16, font_bold=True, align="left",
space_before=20, space_after=10, font_color="2c5282"
),
"H3": HwpStyle(
id=3, name="개요 3", type=HwpStyleType.PARAGRAPH,
font_size=14, font_bold=True, align="left",
space_before=15, space_after=8, font_color="2b6cb0"
),
"H4": HwpStyle(
id=4, name="개요 4", type=HwpStyleType.PARAGRAPH,
font_size=12, font_bold=True, align="left",
space_before=10, space_after=5, indent_left=10
),
"H5": HwpStyle(
id=5, name="개요 5", type=HwpStyleType.PARAGRAPH,
font_size=11, font_bold=True, align="left",
space_before=8, space_after=4, indent_left=20
),
"H6": HwpStyle(
id=6, name="개요 6", type=HwpStyleType.PARAGRAPH,
font_size=11, font_bold=False, align="left",
indent_left=30
),
"H7": HwpStyle(
id=7, name="개요 7", type=HwpStyleType.PARAGRAPH,
font_size=10.5, font_bold=False, align="left",
indent_left=40
),
# 본문
"BODY": HwpStyle(
id=20, name="바탕글", type=HwpStyleType.PARAGRAPH,
font_size=11, align="justify",
line_spacing=180, indent_first=10
),
"LIST_ITEM": HwpStyle(
id=8, name="개요 8", type=HwpStyleType.PARAGRAPH,
font_size=11, align="left",
indent_left=15, line_spacing=160
),
"HIGHLIGHT_BOX": HwpStyle(
id=21, name="강조박스", type=HwpStyleType.PARAGRAPH,
font_size=10.5, align="left",
bg_color="f7fafc", indent_left=10, indent_first=0
),
# 표
"TABLE": HwpStyle(
id=30, name="", type=HwpStyleType.PARAGRAPH,
font_size=10, align="center"
),
"TH": HwpStyle(
id=11, name="표제목", type=HwpStyleType.PARAGRAPH,
font_size=10, font_bold=True, align="center",
bg_color="e2e8f0"
),
"TD": HwpStyle(
id=31, name="표내용", type=HwpStyleType.PARAGRAPH,
font_size=10, align="left"
),
"TABLE_CAPTION": HwpStyle(
id=19, name="표캡션", type=HwpStyleType.PARAGRAPH,
font_size=10, font_bold=True, align="center",
space_before=5, space_after=3
),
# 그림
"FIGURE": HwpStyle(
id=32, name="그림", type=HwpStyleType.PARAGRAPH,
font_size=10, align="center"
),
"FIGURE_CAPTION": HwpStyle(
id=18, name="그림캡션", type=HwpStyleType.PARAGRAPH,
font_size=9.5, align="center",
font_color="666666", space_before=5
),
# 기타
"UNKNOWN": HwpStyle(
id=0, name="바탕글", type=HwpStyleType.PARAGRAPH,
font_size=10, align="left"
),
}
# 역할 → 개요 번호 매핑 (StyleShortcut 용)
ROLE_TO_OUTLINE_NUM = {
"H1": 1,
"H2": 2,
"H3": 3,
"H4": 4,
"H5": 5,
"H6": 6,
"H7": 7,
"LIST_ITEM": 8,
"BODY": 0, # 바탕글
"COVER_TITLE": 0,
"COVER_SUBTITLE": 0,
"COVER_INFO": 0,
}
# 역할 → HWP 스타일 이름 매핑
ROLE_TO_STYLE_NAME = {
"H1": "개요 1",
"H2": "개요 2",
"H3": "개요 3",
"H4": "개요 4",
"H5": "개요 5",
"H6": "개요 6",
"H7": "개요 7",
"LIST_ITEM": "개요 8",
"BODY": "바탕글",
"COVER_TITLE": "표지제목",
"COVER_SUBTITLE": "표지부제",
"TH": "표제목",
"TD": "표내용",
"TABLE_CAPTION": "표캡션",
"FIGURE_CAPTION": "그림캡션",
"UNKNOWN": "바탕글",
}
class HwpStyleMapper:
"""HTML 역할 → HWP 스타일 매퍼"""
def __init__(self, custom_styles: Optional[Dict[str, HwpStyle]] = None):
self.styles = DEFAULT_STYLES.copy()
if custom_styles:
self.styles.update(custom_styles)
def get_style(self, role: str) -> HwpStyle:
return self.styles.get(role, self.styles["UNKNOWN"])
def get_style_id(self, role: str) -> int:
return self.get_style(role).id
def get_all_styles(self) -> Dict[str, HwpStyle]:
return self.styles
class HwpStyGenerator:
"""
HTML 스타일 → HWP 스타일 적용기
pyhwpx API를 사용하여:
1. 역할별 스타일 정보 저장
2. 텍스트 삽입 시 CharShape/ParaShape 직접 적용
3. 개요 스타일 번호 매핑 반환
"""
def __init__(self):
self.styles: Dict[str, HwpStyle] = {}
self.hwp = None
def update_from_html(self, html_styles: Dict[str, Dict]):
"""HTML에서 추출한 스타일로 업데이트"""
for role, style_dict in html_styles.items():
if role in DEFAULT_STYLES:
base = DEFAULT_STYLES[role]
# color 처리 - # 제거
color = style_dict.get('color', base.font_color)
if isinstance(color, str):
color = color.lstrip('#')
self.styles[role] = HwpStyle(
id=base.id,
name=base.name,
type=base.type,
font_size=style_dict.get('font_size', base.font_size),
font_bold=style_dict.get('bold', base.font_bold),
font_color=color,
align=style_dict.get('align', base.align),
line_spacing=style_dict.get('line_spacing', base.line_spacing),
space_before=style_dict.get('space_before', base.space_before),
space_after=style_dict.get('space_after', base.space_after),
indent_left=style_dict.get('indent_left', base.indent_left),
indent_first=style_dict.get('indent_first', base.indent_first),
bg_color=style_dict.get('bg_color', base.bg_color),
)
else:
# 기본 스타일 사용
self.styles[role] = DEFAULT_STYLES.get('UNKNOWN')
# 누락된 역할은 기본값으로 채움
for role in DEFAULT_STYLES:
if role not in self.styles:
self.styles[role] = DEFAULT_STYLES[role]
def apply_to_hwp(self, hwp) -> Dict[str, HwpStyle]:
"""역할 → HwpStyle 매핑 반환"""
self.hwp = hwp
# 🚫 스타일 생성 비활성화 (API 문제)
# for role, style in self.styles.items():
# self._create_or_update_style(hwp, role, style)
if not self.styles:
self.styles = DEFAULT_STYLES.copy()
print(f" ✅ 스타일 매핑 완료: {len(self.styles)}")
return self.styles
def _create_or_update_style(self, hwp, role: str, style: HwpStyle):
"""HWP에 스타일 생성 또는 수정"""
try:
# 1. 스타일 편집 모드
hwp.HAction.GetDefault("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
hwp.HParameterSet.HStyle.StyleName = style.name
# 2. 글자 모양
color_hex = style.font_color.lstrip('#')
if len(color_hex) == 6:
r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16)
text_color = hwp.RGBColor(r, g, b)
else:
text_color = hwp.RGBColor(0, 0, 0)
hwp.HParameterSet.HStyle.CharShape.Height = hwp.PointToHwpUnit(style.font_size)
hwp.HParameterSet.HStyle.CharShape.Bold = style.font_bold
hwp.HParameterSet.HStyle.CharShape.TextColor = text_color
# 3. 문단 모양
align_map = {'left': 0, 'center': 1, 'right': 2, 'justify': 3}
hwp.HParameterSet.HStyle.ParaShape.Align = align_map.get(style.align, 3)
hwp.HParameterSet.HStyle.ParaShape.LineSpacing = int(style.line_spacing)
hwp.HParameterSet.HStyle.ParaShape.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
hwp.HParameterSet.HStyle.ParaShape.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
# 4. 실행
hwp.HAction.Execute("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
print(f" ✓ 스타일 '{style.name}' 정의됨")
except Exception as e:
print(f" [경고] 스타일 '{style.name}' 생성 실패: {e}")
def get_style(self, role: str) -> HwpStyle:
"""역할에 해당하는 스타일 반환"""
return self.styles.get(role, DEFAULT_STYLES.get('UNKNOWN'))
def apply_char_shape(self, hwp, role: str):
"""현재 선택 영역에 글자 모양 적용"""
style = self.get_style(role)
try:
# RGB 색상 변환
color_hex = style.font_color.lstrip('#') if style.font_color else '000000'
if len(color_hex) == 6:
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
text_color = hwp.RGBColor(r, g, b)
else:
text_color = hwp.RGBColor(0, 0, 0)
# 글자 모양 설정
hwp.HAction.GetDefault("CharShape", hwp.HParameterSet.HCharShape.HSet)
hwp.HParameterSet.HCharShape.Height = hwp.PointToHwpUnit(style.font_size)
hwp.HParameterSet.HCharShape.Bold = style.font_bold
hwp.HParameterSet.HCharShape.TextColor = text_color
hwp.HAction.Execute("CharShape", hwp.HParameterSet.HCharShape.HSet)
except Exception as e:
print(f" [경고] 글자 모양 적용 실패 ({role}): {e}")
def apply_para_shape(self, hwp, role: str):
"""현재 문단에 문단 모양 적용"""
style = self.get_style(role)
try:
# 정렬
align_actions = {
'left': "ParagraphShapeAlignLeft",
'center': "ParagraphShapeAlignCenter",
'right': "ParagraphShapeAlignRight",
'justify': "ParagraphShapeAlignJustify"
}
if style.align in align_actions:
hwp.HAction.Run(align_actions[style.align])
# 문단 모양 상세 설정
hwp.HAction.GetDefault("ParagraphShape", hwp.HParameterSet.HParaShape.HSet)
p = hwp.HParameterSet.HParaShape
p.LineSpaceType = 0 # 퍼센트
p.LineSpacing = int(style.line_spacing)
p.LeftMargin = hwp.MiliToHwpUnit(style.indent_left)
p.IndentMargin = hwp.MiliToHwpUnit(style.indent_first)
p.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
p.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
hwp.HAction.Execute("ParagraphShape", p.HSet)
except Exception as e:
print(f" [경고] 문단 모양 적용 실패 ({role}): {e}")
def apply_style(self, hwp, role: str):
"""역할에 맞는 전체 스타일 적용 (글자 + 문단)"""
self.apply_char_shape(hwp, role)
self.apply_para_shape(hwp, role)
def export_sty(self, hwp, output_path: str) -> bool:
"""스타일 파일 내보내기 (현재 미지원)"""
print(f" [알림] .sty 내보내기는 현재 미지원")
return False
# =============================================================================
# 번호 제거 유틸리티
# =============================================================================
import re
NUMBERING_PATTERNS = {
'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → ""
'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → ""
'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → ""
'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → ""
'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → ""
'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → ""
'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → ""
'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → ""
}
def strip_numbering(text: str, role: str) -> str:
"""
역할에 따라 텍스트 앞의 번호/기호 제거
HWP 개요 기능이 번호를 자동 생성하므로 중복 방지
"""
if not text:
return text
pattern = NUMBERING_PATTERNS.get(role)
if pattern:
return pattern.sub('', text).strip()
return text.strip()
if __name__ == "__main__":
# 테스트
print("=== 스타일 매핑 테스트 ===")
gen = HwpStyGenerator()
# HTML 스타일 시뮬레이션
html_styles = {
'H1': {'font_size': 20, 'color': '#1a365d', 'bold': True},
'H2': {'font_size': 16, 'color': '#2c5282', 'bold': True},
'BODY': {'font_size': 11, 'align': 'justify'},
}
gen.update_from_html(html_styles)
for role, style in gen.styles.items():
print(f"{role:15} → size={style.font_size}pt, bold={style.font_bold}, color=#{style.font_color}")

View File

@@ -0,0 +1,431 @@
"""
HWPX 파일 생성기
StyleAnalyzer 결과를 받아 스타일이 적용된 HWPX 파일 생성
"""
import os
import zipfile
import xml.etree.ElementTree as ET
from typing import List, Dict, Optional
from dataclasses import dataclass
from pathlib import Path
from style_analyzer import StyleAnalyzer, StyledElement
from hwp_style_mapping import HwpStyleMapper, HwpStyle, ROLE_TO_STYLE_NAME
@dataclass
class HwpxConfig:
"""HWPX 생성 설정"""
paper_width: int = 59528 # A4 너비 (hwpunit, 1/7200 inch)
paper_height: int = 84188 # A4 높이
margin_left: int = 8504
margin_right: int = 8504
margin_top: int = 5668
margin_bottom: int = 4252
default_font: str = "함초롬바탕"
default_font_size: int = 1000 # 10pt (hwpunit)
class HwpxGenerator:
"""HWPX 파일 생성기"""
def __init__(self, config: Optional[HwpxConfig] = None):
self.config = config or HwpxConfig()
self.mapper = HwpStyleMapper()
self.used_styles: set = set()
def generate(self, elements: List[StyledElement], output_path: str) -> str:
"""
StyledElement 리스트로부터 HWPX 파일 생성
Args:
elements: StyleAnalyzer로 분류된 요소 리스트
output_path: 출력 파일 경로 (.hwpx)
Returns:
생성된 파일 경로
"""
# 사용된 스타일 수집
self.used_styles = {e.role for e in elements}
# 임시 디렉토리 생성
temp_dir = Path(output_path).with_suffix('.temp')
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# HWPX 구조 생성
self._create_mimetype(temp_dir)
self._create_meta_inf(temp_dir)
self._create_version(temp_dir)
self._create_header(temp_dir)
self._create_content(temp_dir, elements)
self._create_settings(temp_dir)
# ZIP으로 압축
self._create_hwpx(temp_dir, output_path)
return output_path
finally:
# 임시 파일 정리
import shutil
if temp_dir.exists():
shutil.rmtree(temp_dir)
def _create_mimetype(self, temp_dir: Path):
"""mimetype 파일 생성"""
mimetype_path = temp_dir / "mimetype"
mimetype_path.write_text("application/hwp+zip")
def _create_meta_inf(self, temp_dir: Path):
"""META-INF/manifest.xml 생성"""
meta_dir = temp_dir / "META-INF"
meta_dir.mkdir(exist_ok=True)
manifest = """<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
<manifest:file-entry manifest:full-path="version.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="application/xml"/>
</manifest:manifest>"""
(meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8')
def _create_version(self, temp_dir: Path):
"""version.xml 생성"""
version = """<?xml version="1.0" encoding="UTF-8"?>
<hh:HWPMLVersion xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" version="1.1"/>"""
(temp_dir / "version.xml").write_text(version, encoding='utf-8')
def _create_header(self, temp_dir: Path):
"""Contents/header.xml 생성 (스타일 정의 포함)"""
contents_dir = temp_dir / "Contents"
contents_dir.mkdir(exist_ok=True)
# 스타일별 속성 생성
char_props_xml = self._generate_char_properties()
para_props_xml = self._generate_para_properties()
styles_xml = self._generate_styles_xml()
header = f"""<?xml version="1.0" encoding="UTF-8"?>
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"
xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
version="1.5" secCnt="1">
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
<hh:refList>
<hh:fontfaces itemCnt="7">
<hh:fontface lang="HANGUL" fontCnt="2">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="LATIN" fontCnt="2">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="HANJA" fontCnt="2">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="JAPANESE" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="OTHER" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="SYMBOL" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="USER" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
</hh:fontfaces>
<hh:borderFills itemCnt="2">
<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE">
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
</hh:borderFill>
<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE">
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
<hc:fillBrush><hc:winBrush faceColor="none" hatchColor="#000000" alpha="0"/></hc:fillBrush>
</hh:borderFill>
</hh:borderFills>
{char_props_xml}
{para_props_xml}
{styles_xml}
</hh:refList>
<hh:compatibleDocument targetProgram="HWP201X"/>
<hh:docOption>
<hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/>
</hh:docOption>
</hh:head>"""
(contents_dir / "header.xml").write_text(header, encoding='utf-8')
def _generate_char_properties(self) -> str:
"""글자 속성 XML 생성"""
lines = [f' <hh:charProperties itemCnt="{len(self.used_styles) + 1}">']
# 기본 글자 속성 (id=0)
lines.append(''' <hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
<hh:strikeout shape="NONE" color="#000000"/>
<hh:outline type="NONE"/>
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
</hh:charPr>''')
# 역할별 글자 속성
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
height = int(style.font_size * 100) # pt → hwpunit
color = style.font_color.lstrip('#')
font_id = "1" if style.font_bold else "0" # 굵게면 함초롬돋움
lines.append(f''' <hh:charPr id="{idx}" height="{height}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
<hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/>
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
<hh:strikeout shape="NONE" color="#000000"/>
<hh:outline type="NONE"/>
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
</hh:charPr>''')
lines.append(' </hh:charProperties>')
return '\n'.join(lines)
def _generate_para_properties(self) -> str:
"""문단 속성 XML 생성"""
lines = [f' <hh:paraProperties itemCnt="{len(self.used_styles) + 1}">']
# 기본 문단 속성 (id=0)
lines.append(''' <hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>
<hh:heading type="NONE" idRef="0" level="0"/>
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
</hp:case>
<hp:default>
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
</hp:default>
</hp:switch>
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
</hh:paraPr>''')
# 역할별 문단 속성
align_map = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", "justify": "JUSTIFY"}
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
align_val = align_map.get(style.align, "JUSTIFY")
line_spacing = int(style.line_spacing)
left_margin = int(style.indent_left * 100)
indent = int(style.indent_first * 100)
space_before = int(style.space_before * 100)
space_after = int(style.space_after * 100)
lines.append(f''' <hh:paraPr id="{idx}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
<hh:align horizontal="{align_val}" vertical="BASELINE"/>
<hh:heading type="NONE" idRef="0" level="0"/>
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
</hp:case>
<hp:default>
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
</hp:default>
</hp:switch>
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
</hh:paraPr>''')
lines.append(' </hh:paraProperties>')
return '\n'.join(lines)
def _generate_styles_xml(self) -> str:
"""스타일 정의 XML 생성 (charPrIDRef, paraPrIDRef 참조)"""
lines = [f' <hh:styles itemCnt="{len(self.used_styles) + 1}">']
# 기본 스타일 (id=0, 바탕글)
lines.append(' <hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>')
# 역할별 스타일 (charPrIDRef, paraPrIDRef 참조)
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
style_name = style.name.replace('<', '&lt;').replace('>', '&gt;')
lines.append(f' <hh:style id="{idx}" type="PARA" name="{style_name}" engName="" paraPrIDRef="{idx}" charPrIDRef="{idx}" nextStyleIDRef="{idx}" langID="1042" lockForm="0"/>')
lines.append(' </hh:styles>')
return '\n'.join(lines)
def _create_content(self, temp_dir: Path, elements: List[StyledElement]):
"""Contents/section0.xml 생성 (본문 + 스타일 참조)"""
contents_dir = temp_dir / "Contents"
# 문단 XML 생성
paragraphs = []
current_table = None
# 역할 → 스타일 인덱스 매핑 생성
role_to_idx = {role: idx for idx, role in enumerate(sorted(self.used_styles), start=1)}
for elem in elements:
style = self.mapper.get_style(elem.role)
style_idx = role_to_idx.get(elem.role, 0)
# 테이블 요소는 특수 처리
if elem.role in ["TH", "TD", "TABLE_CAPTION", "TABLE", "FIGURE"]:
continue # 테이블/그림은 별도 처리 필요
# 일반 문단
para_xml = self._create_paragraph(elem.text, style, style_idx)
paragraphs.append(para_xml)
section = f"""<?xml version="1.0" encoding="UTF-8"?>
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
{"".join(paragraphs)}
</hs:sec>"""
(contents_dir / "section0.xml").write_text(section, encoding='utf-8')
def _create_paragraph(self, text: str, style: HwpStyle, style_idx: int) -> str:
"""단일 문단 XML 생성"""
text = self._escape_xml(text)
return f'''
<hp:p xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
paraPrIDRef="{style_idx}" styleIDRef="{style_idx}" pageBreak="0" columnBreak="0" merged="0">
<hp:run charPrIDRef="{style_idx}">
<hp:t>{text}</hp:t>
</hp:run>
</hp:p>'''
def _escape_xml(self, text: str) -> str:
"""XML 특수문자 이스케이프"""
return (text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;"))
def _create_settings(self, temp_dir: Path):
"""settings.xml 생성"""
settings = """<?xml version="1.0" encoding="UTF-8"?>
<hs:settings xmlns:hs="http://www.hancom.co.kr/hwpml/2011/settings">
<hs:viewSetting>
<hs:viewType val="printView"/>
<hs:zoom val="100"/>
</hs:viewSetting>
</hs:settings>"""
(temp_dir / "settings.xml").write_text(settings, encoding='utf-8')
def _create_hwpx(self, temp_dir: Path, output_path: str):
"""HWPX 파일 생성 (ZIP 압축)"""
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축하지 않고 첫 번째로
mimetype_path = temp_dir / "mimetype"
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zf.write(file_path, arcname)
def convert_html_to_hwpx(html: str, output_path: str) -> str:
"""
HTML → HWPX 변환 메인 함수
Args:
html: HTML 문자열
output_path: 출력 파일 경로
Returns:
생성된 파일 경로
"""
# 1. HTML 분석 → 역할 분류
analyzer = StyleAnalyzer()
elements = analyzer.analyze(html)
print(f"📊 분석 완료: {len(elements)}개 요소")
for role, count in analyzer.get_role_summary().items():
print(f" {role}: {count}")
# 2. HWPX 생성
generator = HwpxGenerator()
result_path = generator.generate(elements, output_path)
print(f"✅ 생성 완료: {result_path}")
return result_path
if __name__ == "__main__":
# 테스트
test_html = """
<html>
<body>
<div class="box-cover">
<h1>건설·토목 측량 DX 실무지침</h1>
<h2>드론/UAV·GIS·지형/지반 모델 기반</h2>
<p>2024년 1월</p>
</div>
<h1>1. 개요</h1>
<p>본 보고서는 건설 및 토목 분야의 측량 디지털 전환에 대한 실무 지침을 제공합니다.</p>
<h2>1.1 배경</h2>
<p>최근 드론과 GIS 기술의 발전으로 측량 업무가 크게 변화하고 있습니다.</p>
<h3>1.1.1 기술 동향</h3>
<p>1) <strong>드론 측량의 발전</strong></p>
<p>드론을 활용한 측량은 기존 방식 대비 효율성이 크게 향상되었습니다.</p>
<p>(1) <strong>RTK 드론</strong></p>
<p>실시간 보정 기능을 갖춘 RTK 드론이 보급되고 있습니다.</p>
<ul>
<li>고정밀 GPS 수신기 내장</li>
<li>센티미터 단위 정확도</li>
</ul>
</body>
</html>
"""
output = "/home/claude/test_output.hwpx"
convert_html_to_hwpx(test_html, output)

View File

@@ -0,0 +1,750 @@
"""
HWPX 스타일 주입기
pyhwpx로 생성된 HWPX 파일에 커스텀 스타일을 후처리로 주입
워크플로우:
1. HWPX 압축 해제
2. header.xml에 커스텀 스타일 정의 추가
3. section*.xml에서 역할별 styleIDRef 매핑
4. 다시 압축
"""
import os
import re
import zipfile
import shutil
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class StyleDefinition:
"""스타일 정의"""
id: int
name: str
font_size: int # hwpunit (pt * 100)
font_bold: bool
font_color: str # #RRGGBB
align: str # LEFT, CENTER, RIGHT, JUSTIFY
line_spacing: int # percent (160 = 160%)
indent_left: int # hwpunit
indent_first: int # hwpunit
space_before: int # hwpunit
space_after: int # hwpunit
outline_level: int = -1 # 🆕 개요 수준 (-1=없음, 0=1수준, 1=2수준, ...)
# 역할 → 스타일 정의 매핑
ROLE_STYLES: Dict[str, StyleDefinition] = {
# 🆕 개요 문단 (자동 번호 매기기!)
'H1': StyleDefinition(
id=101, name='제1장 제목', font_size=2200, font_bold=True,
font_color='#006400', align='CENTER', line_spacing=200,
indent_left=0, indent_first=0, space_before=400, space_after=200,
outline_level=0 # 🆕 제^1장
),
'H2': StyleDefinition(
id=102, name='1.1 제목', font_size=1500, font_bold=True,
font_color='#03581d', align='LEFT', line_spacing=200,
indent_left=0, indent_first=0, space_before=300, space_after=100,
outline_level=1 # 🆕 ^1.^2
),
'H3': StyleDefinition(
id=103, name='1.1.1 제목', font_size=1400, font_bold=True,
font_color='#228B22', align='LEFT', line_spacing=200,
indent_left=500, indent_first=0, space_before=200, space_after=100,
outline_level=2 # 🆕 ^1.^2.^3
),
'H4': StyleDefinition(
id=104, name='가. 제목', font_size=1300, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=1000, indent_first=0, space_before=150, space_after=50,
outline_level=3 # 🆕 ^4.
),
'H5': StyleDefinition(
id=105, name='1) 제목', font_size=1200, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=1500, indent_first=0, space_before=100, space_after=50,
outline_level=4 # 🆕 ^5)
),
'H6': StyleDefinition(
id=106, name='가) 제목', font_size=1150, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=2000, indent_first=0, space_before=100, space_after=50,
outline_level=5 # 🆕 ^6)
),
'H7': StyleDefinition(
id=115, name='① 제목', font_size=1100, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=2300, indent_first=0, space_before=100, space_after=50,
outline_level=6 # 🆕 ^7 (원문자)
),
# 본문 스타일 (개요 아님)
'BODY': StyleDefinition(
id=107, name='○본문', font_size=1100, font_bold=False,
font_color='#000000', align='JUSTIFY', line_spacing=200,
indent_left=1500, indent_first=0, space_before=0, space_after=0
),
'LIST_ITEM': StyleDefinition(
id=108, name='●본문', font_size=1050, font_bold=False,
font_color='#000000', align='JUSTIFY', line_spacing=200,
indent_left=2500, indent_first=0, space_before=0, space_after=0
),
'TABLE_CAPTION': StyleDefinition(
id=109, name='<표 제목>', font_size=1100, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=130,
indent_left=0, indent_first=0, space_before=200, space_after=100
),
'FIGURE_CAPTION': StyleDefinition(
id=110, name='<그림 제목>', font_size=1100, font_bold=True,
font_color='#000000', align='CENTER', line_spacing=130,
indent_left=0, indent_first=0, space_before=100, space_after=200
),
'COVER_TITLE': StyleDefinition(
id=111, name='표지제목', font_size=2800, font_bold=True,
font_color='#1a365d', align='CENTER', line_spacing=150,
indent_left=0, indent_first=0, space_before=0, space_after=200
),
'COVER_SUBTITLE': StyleDefinition(
id=112, name='표지부제', font_size=1800, font_bold=False,
font_color='#2d3748', align='CENTER', line_spacing=150,
indent_left=0, indent_first=0, space_before=0, space_after=100
),
'TOC_1': StyleDefinition(
id=113, name='목차1수준', font_size=1200, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=180,
indent_left=0, indent_first=0, space_before=100, space_after=50
),
'TOC_2': StyleDefinition(
id=114, name='목차2수준', font_size=1100, font_bold=False,
font_color='#000000', align='LEFT', line_spacing=180,
indent_left=500, indent_first=0, space_before=0, space_after=0
),
}
# ⚠️ 개요 자동 번호 기능 활성화!
# idRef="0"은 numbering id=1을 참조하므로, 해당 패턴을 교체하면 동작함
class HwpxStyleInjector:
"""HWPX 스타일 주입기"""
def __init__(self):
self.temp_dir: Optional[Path] = None
self.role_to_style_id: Dict[str, int] = {}
self.role_to_para_id: Dict[str, int] = {} # 🆕
self.role_to_char_id: Dict[str, int] = {} # 🆕
self.next_char_id = 0
self.next_para_id = 0
self.next_style_id = 0
def _find_max_ids(self):
"""기존 스타일 교체: 바탕글(id=0)만 유지, 나머지는 우리 스타일로 교체"""
header_path = self.temp_dir / "Contents" / "header.xml"
if not header_path.exists():
self.next_char_id = 1
self.next_para_id = 1
self.next_style_id = 1
return
content = header_path.read_text(encoding='utf-8')
# 🆕 기존 "본문", "개요 1~10" 등 스타일 제거 (id=1~22)
# 바탕글(id=0)만 유지!
# style id=1~30 제거 (바탕글 제외)
content = re.sub(r'<hh:style id="([1-9]|[12]\d|30)"[^/]*/>\s*', '', content)
# itemCnt는 나중에 _update_item_counts에서 자동 업데이트됨
# 파일 저장
header_path.write_text(content, encoding='utf-8')
print(f" [INFO] 기존 스타일(본문, 개요1~10 등) 제거 완료")
# charPr, paraPr은 기존 것 다음부터 (참조 깨지지 않도록)
char_ids = [int(m) for m in re.findall(r'<hh:charPr id="(\d+)"', content)]
self.next_char_id = max(char_ids) + 1 if char_ids else 20
para_ids = [int(m) for m in re.findall(r'<hh:paraPr id="(\d+)"', content)]
self.next_para_id = max(para_ids) + 1 if para_ids else 20
# 스타일은 1부터 시작! (Ctrl+2 = id=1, Ctrl+3 = id=2, ...)
self.next_style_id = 1
def inject(self, hwpx_path: str, role_positions: Dict[str, List[tuple]]) -> str:
"""
HWPX 파일에 커스텀 스타일 주입
Args:
hwpx_path: 원본 HWPX 파일 경로
role_positions: 역할별 위치 정보 {role: [(section_idx, para_idx), ...]}
Returns:
수정된 HWPX 파일 경로
"""
print(f"\n🎨 HWPX 스타일 주입 시작...")
print(f" 입력: {hwpx_path}")
# 1. 임시 디렉토리에 압축 해제
self.temp_dir = Path(tempfile.mkdtemp(prefix='hwpx_inject_'))
print(f" 임시 폴더: {self.temp_dir}")
try:
with zipfile.ZipFile(hwpx_path, 'r') as zf:
zf.extractall(self.temp_dir)
# 압축 해제 직후 section 파일 크기 확인
print(f" [DEBUG] After unzip:")
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
sec_path = self.temp_dir / "Contents" / sec
if sec_path.exists():
print(f" [DEBUG] {sec} size: {sec_path.stat().st_size} bytes")
# 🆕 기존 최대 ID 찾기 (연속 ID 할당을 위해)
self._find_max_ids()
print(f" [DEBUG] Starting IDs: char={self.next_char_id}, para={self.next_para_id}, style={self.next_style_id}")
# 2. header.xml에 스타일 정의 추가
used_roles = set(role_positions.keys())
self._inject_header_styles(used_roles)
# 3. section*.xml에 styleIDRef 매핑
self._inject_section_styles(role_positions)
# 4. 다시 압축
output_path = hwpx_path # 원본 덮어쓰기
self._repack_hwpx(output_path)
print(f" ✅ 스타일 주입 완료: {output_path}")
return output_path
finally:
# 임시 폴더 정리
if self.temp_dir and self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
def _inject_header_styles(self, used_roles: set):
"""header.xml에 스타일 정의 추가 (모든 ROLE_STYLES 주입)"""
header_path = self.temp_dir / "Contents" / "header.xml"
if not header_path.exists():
print(" [경고] header.xml 없음")
return
content = header_path.read_text(encoding='utf-8')
# 🆕 모든 ROLE_STYLES 주입 (used_roles 무시)
char_props = []
para_props = []
styles = []
for role, style_def in ROLE_STYLES.items():
char_id = self.next_char_id
para_id = self.next_para_id
style_id = self.next_style_id
self.role_to_style_id[role] = style_id
self.role_to_para_id[role] = para_id # 🆕
self.role_to_char_id[role] = char_id # 🆕
# charPr 생성
char_props.append(self._make_char_pr(char_id, style_def))
# paraPr 생성
para_props.append(self._make_para_pr(para_id, style_def))
# style 생성
styles.append(self._make_style(style_id, style_def.name, para_id, char_id))
self.next_char_id += 1
self.next_para_id += 1
self.next_style_id += 1
if not styles:
print(" [정보] 주입할 스타일 없음")
return
# charProperties에 추가
content = self._insert_before_tag(
content, '</hh:charProperties>', '\n'.join(char_props) + '\n'
)
# paraProperties에 추가
content = self._insert_before_tag(
content, '</hh:paraProperties>', '\n'.join(para_props) + '\n'
)
# styles에 추가
content = self._insert_before_tag(
content, '</hh:styles>', '\n'.join(styles) + '\n'
)
# 🆕 numbering id=1 패턴 교체 (idRef="0"이 참조하는 기본 번호 모양)
# 이렇게 하면 개요 자동 번호가 "제1장, 1.1, 1.1.1..." 형식으로 동작!
content = self._replace_default_numbering(content)
# itemCnt 업데이트
content = self._update_item_counts(content)
header_path.write_text(content, encoding='utf-8')
print(f" → header.xml 수정 완료 ({len(styles)}개 스타일 추가)")
def _make_char_pr(self, id: int, style: StyleDefinition) -> str:
"""charPr XML 생성 (한 줄로!)"""
color = style.font_color.lstrip('#')
font_id = "1" if style.font_bold else "0"
return f'<hh:charPr id="{id}" height="{style.font_size}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:underline type="NONE" shape="SOLID" color="#000000"/><hh:strikeout shape="NONE" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/></hh:charPr>'
def _make_para_pr(self, id: int, style: StyleDefinition) -> str:
"""paraPr XML 생성 (한 줄로!)"""
# 개요 문단이면 type="OUTLINE", 아니면 type="NONE"
# idRef="0"은 numbering id=1 (기본 번호 모양)을 참조
if style.outline_level >= 0:
heading = f'<hh:heading type="OUTLINE" idRef="0" level="{style.outline_level}"/>'
else:
heading = '<hh:heading type="NONE" idRef="0" level="0"/>'
return f'<hh:paraPr id="{id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="{style.align}" vertical="BASELINE"/>{heading}<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/><hh:margin><hc:intent value="{style.indent_first}" unit="HWPUNIT"/><hc:left value="{style.indent_left}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{style.space_before}" unit="HWPUNIT"/><hc:next value="{style.space_after}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="{style.line_spacing}" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>'
def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str:
"""style XML 생성"""
safe_name = name.replace('<', '&lt;').replace('>', '&gt;')
return f'<hh:style id="{id}" type="PARA" name="{safe_name}" engName="" paraPrIDRef="{para_id}" charPrIDRef="{char_id}" nextStyleIDRef="{id}" langID="1042" lockForm="0"/>'
def _insert_before_tag(self, content: str, tag: str, insert_text: str) -> str:
"""특정 태그 앞에 텍스트 삽입"""
return content.replace(tag, insert_text + tag)
def _update_item_counts(self, content: str) -> str:
"""itemCnt 속성 업데이트"""
# charProperties itemCnt
char_count = content.count('<hh:charPr ')
content = re.sub(
r'<hh:charProperties itemCnt="(\d+)"',
f'<hh:charProperties itemCnt="{char_count}"',
content
)
# paraProperties itemCnt
para_count = content.count('<hh:paraPr ')
content = re.sub(
r'<hh:paraProperties itemCnt="(\d+)"',
f'<hh:paraProperties itemCnt="{para_count}"',
content
)
# styles itemCnt
style_count = content.count('<hh:style ')
content = re.sub(
r'<hh:styles itemCnt="(\d+)"',
f'<hh:styles itemCnt="{style_count}"',
content
)
# 🆕 numberings itemCnt
numbering_count = content.count('<hh:numbering ')
content = re.sub(
r'<hh:numberings itemCnt="(\d+)"',
f'<hh:numberings itemCnt="{numbering_count}"',
content
)
return content
def _replace_default_numbering(self, content: str) -> str:
"""numbering id=1의 패턴을 우리 패턴으로 교체"""
# 우리가 원하는 개요 번호 패턴
new_patterns = [
{'level': '1', 'format': 'DIGIT', 'pattern': '제^1장'},
{'level': '2', 'format': 'DIGIT', 'pattern': '^1.^2'},
{'level': '3', 'format': 'DIGIT', 'pattern': '^1.^2.^3'},
{'level': '4', 'format': 'HANGUL_SYLLABLE', 'pattern': '^4.'},
{'level': '5', 'format': 'DIGIT', 'pattern': '^5)'},
{'level': '6', 'format': 'HANGUL_SYLLABLE', 'pattern': '^6)'},
{'level': '7', 'format': 'CIRCLED_DIGIT', 'pattern': '^7'},
]
# numbering id="1" 찾기
match = re.search(r'(<hh:numbering id="1"[^>]*>)(.*?)(</hh:numbering>)', content, re.DOTALL)
if not match:
print(" [경고] numbering id=1 없음, 교체 건너뜀")
return content
numbering_content = match.group(2)
for np in new_patterns:
level = np['level']
fmt = np['format']
pattern = np['pattern']
# 해당 level의 paraHead 찾아서 교체
def replace_parahead(m):
tag = m.group(0)
# numFormat 변경
tag = re.sub(r'numFormat="[^"]*"', f'numFormat="{fmt}"', tag)
# 패턴(텍스트 내용) 변경
tag = re.sub(r'>([^<]*)</hh:paraHead>', f'>{pattern}</hh:paraHead>', tag)
return tag
numbering_content = re.sub(
rf'<hh:paraHead[^>]*level="{level}"[^>]*>.*?</hh:paraHead>',
replace_parahead,
numbering_content
)
new_content = match.group(1) + numbering_content + match.group(3)
print(" [INFO] numbering id=1 패턴 교체 완료 (제^1장, ^1.^2, ^1.^2.^3...)")
return content.replace(match.group(0), new_content)
def _adjust_tables(self, content: str) -> str:
"""표 셀 크기 자동 조정
1. 행 높이: 최소 800 hwpunit (내용 잘림 방지)
2. 열 너비: 표 전체 너비를 열 개수로 균등 분배 (또는 첫 열 좁게)
"""
def adjust_table(match):
tbl = match.group(0)
# 표 전체 너비 추출
sz_match = re.search(r'<hp:sz width="(\d+)"', tbl)
table_width = int(sz_match.group(1)) if sz_match else 47624
# 열 개수 추출
col_match = re.search(r'colCnt="(\d+)"', tbl)
col_cnt = int(col_match.group(1)) if col_match else 4
# 열 너비 계산 (첫 열은 30%, 나머지 균등)
first_col_width = int(table_width * 0.25)
other_col_width = (table_width - first_col_width) // (col_cnt - 1) if col_cnt > 1 else table_width
# 행 높이 최소값 설정
min_height = 800 # 약 8mm
# 셀 크기 조정
col_idx = [0] # closure용
def adjust_cell_sz(cell_match):
width = int(cell_match.group(1))
height = int(cell_match.group(2))
# 높이 조정
new_height = max(height, min_height)
return f'<hp:cellSz width="{width}" height="{new_height}"/>'
tbl = re.sub(
r'<hp:cellSz width="(\d+)" height="(\d+)"/>',
adjust_cell_sz,
tbl
)
return tbl
return re.sub(r'<hp:tbl[^>]*>.*?</hp:tbl>', adjust_table, content, flags=re.DOTALL)
def _inject_section_styles(self, role_positions: Dict[str, List[tuple]]):
"""section*.xml에 styleIDRef 매핑 (텍스트 매칭 방식)"""
contents_dir = self.temp_dir / "Contents"
# 🔍 디버그: role_to_style_id 확인
print(f" [DEBUG] role_to_style_id: {self.role_to_style_id}")
# section 파일들 찾기
section_files = sorted(contents_dir.glob("section*.xml"))
print(f" [DEBUG] section files: {[f.name for f in section_files]}")
total_modified = 0
for section_file in section_files:
print(f" [DEBUG] Processing: {section_file.name}")
original_content = section_file.read_text(encoding='utf-8')
print(f" [DEBUG] File size: {len(original_content)} bytes")
content = original_content # 작업용 복사본
# 🆕 머리말/꼬리말 영역 보존 (placeholder로 교체)
header_footer_map = {}
placeholder_idx = 0
def save_header_footer(match):
nonlocal placeholder_idx
key = f"__HF_PLACEHOLDER_{placeholder_idx}__"
header_footer_map[key] = match.group(0)
placeholder_idx += 1
return key
# 머리말/꼬리말 임시 교체
content = re.sub(r'<hp:header[^>]*>.*?</hp:header>', save_header_footer, content, flags=re.DOTALL)
content = re.sub(r'<hp:footer[^>]*>.*?</hp:footer>', save_header_footer, content, flags=re.DOTALL)
# 모든 <hp:p> 태그와 내부 텍스트 추출
para_pattern = r'(<hp:p [^>]*>)(.*?)(</hp:p>)'
section_modified = 0
def replace_style(match):
nonlocal total_modified, section_modified
open_tag = match.group(1)
inner = match.group(2)
close_tag = match.group(3)
# 텍스트 추출 (태그 제거)
text = re.sub(r'<[^>]+>', '', inner).strip()
if not text:
return match.group(0)
# 텍스트 앞부분으로 역할 판단
text_start = text[:50] # 처음 50자로 판단
matched_role = None
matched_style_id = None
matched_para_id = None
matched_char_id = None
# 제목 패턴 매칭 (앞에 특수문자 허용)
# Unicode: ■\u25a0 ▸\u25b8 ◆\u25c6 ▶\u25b6 ●\u25cf ○\u25cb ▪\u25aa ►\u25ba ☞\u261e ★\u2605 ※\u203b ·\u00b7
prefix = r'^[\u25a0\u25b8\u25c6\u25b6\u25cf\u25cb\u25aa\u25ba\u261e\u2605\u203b\u00b7\s]*'
# 🆕 FIGURE_CAPTION: "[그림 1-1]", "[그림 1-2]" 등 (가장 먼저 체크!)
# 그림 = \uadf8\ub9bc
if re.match(r'^\[\uadf8\ub9bc\s*[\d-]+\]', text_start):
matched_role = 'FIGURE_CAPTION'
# 🆕 TABLE_CAPTION: "<표 1-1>", "[표 1-1]" 등
# 표 = \ud45c
elif re.match(r'^[<\[]\ud45c\s*[\d-]+[>\]]', text_start):
matched_role = 'TABLE_CAPTION'
# H1: "제1장", "1 개요" 등
elif re.match(prefix + r'\uc81c?\s*\d+\uc7a5?\s', text_start) or re.match(prefix + r'[1-9]\s+[\uac00-\ud7a3]', text_start):
matched_role = 'H1'
# H3: "1.1.1 " (H2보다 먼저 체크!)
elif re.match(prefix + r'\d+\.\d+\.\d+\s', text_start):
matched_role = 'H3'
# H2: "1.1 "
elif re.match(prefix + r'\d+\.\d+\s', text_start):
matched_role = 'H2'
# H4: "가. "
elif re.match(prefix + r'[\uac00-\ud7a3]\.\s', text_start):
matched_role = 'H4'
# H5: "1) "
elif re.match(prefix + r'\d+\)\s', text_start):
matched_role = 'H5'
# H6: "(1) " 또는 "가) "
elif re.match(prefix + r'\(\d+\)\s', text_start):
matched_role = 'H6'
elif re.match(prefix + r'[\uac00-\ud7a3]\)\s', text_start):
matched_role = 'H6'
# LIST_ITEM: "○ ", "● ", "• " 등
elif re.match(r'^[\u25cb\u25cf\u25e6\u2022\u2023\u25b8]\s', text_start):
matched_role = 'LIST_ITEM'
elif re.match(r'^[-\u2013\u2014]\s', text_start):
matched_role = 'LIST_ITEM'
# 매칭된 역할이 있고 스타일 ID가 있으면 적용
if matched_role and matched_role in self.role_to_style_id:
matched_style_id = self.role_to_style_id[matched_role]
matched_para_id = self.role_to_para_id[matched_role]
matched_char_id = self.role_to_char_id[matched_role]
elif 'BODY' in self.role_to_style_id and len(text) > 20:
# 긴 텍스트는 본문으로 간주
matched_role = 'BODY'
matched_style_id = self.role_to_style_id['BODY']
matched_para_id = self.role_to_para_id['BODY']
matched_char_id = self.role_to_char_id['BODY']
if matched_style_id:
# 1. hp:p 태그의 styleIDRef 변경
if 'styleIDRef="' in open_tag:
new_open = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{matched_style_id}"', open_tag)
else:
new_open = open_tag.replace('<hp:p ', f'<hp:p styleIDRef="{matched_style_id}" ')
# 2. hp:p 태그의 paraPrIDRef도 변경! (스타일의 paraPrIDRef와 일치!)
new_open = re.sub(r'paraPrIDRef="[^"]*"', f'paraPrIDRef="{matched_para_id}"', new_open)
# 3. inner에서 hp:run의 charPrIDRef도 변경! (스타일의 charPrIDRef와 일치!)
new_inner = re.sub(r'(<hp:run[^>]*charPrIDRef=")[^"]*(")', f'\\g<1>{matched_char_id}\\2', inner)
# 🆕 4. 개요 문단이면 수동 번호 제거 (자동 번호가 붙으니까!)
if matched_role in ROLE_STYLES and ROLE_STYLES[matched_role].outline_level >= 0:
new_inner = self._remove_manual_numbering(new_inner, matched_role)
total_modified += 1
section_modified += 1
return new_open + new_inner + close_tag
return match.group(0)
new_content = re.sub(para_pattern, replace_style, content, flags=re.DOTALL)
# 🆕 표 크기 자동 조정
new_content = self._adjust_tables(new_content)
# 🆕 outlineShapeIDRef를 1로 변경 (우리가 교체한 numbering id=1 사용)
new_content = re.sub(
r'outlineShapeIDRef="[^"]*"',
'outlineShapeIDRef="1"',
new_content
)
# 🆕 머리말/꼬리말 복원
for key, original in header_footer_map.items():
new_content = new_content.replace(key, original)
print(f" [DEBUG] {section_file.name}: {section_modified} paras modified, content changed: {new_content != original_content}")
if new_content != original_content:
section_file.write_text(new_content, encoding='utf-8')
print(f" -> {section_file.name} saved")
print(f" -> Total {total_modified} paragraphs styled")
def _update_para_style(self, content: str, para_idx: int, style_id: int) -> str:
"""특정 인덱스의 문단 styleIDRef 변경"""
# <hp:p ...> 태그들 찾기
pattern = r'<hp:p\s[^>]*>'
matches = list(re.finditer(pattern, content))
if para_idx >= len(matches):
return content
match = matches[para_idx]
old_tag = match.group(0)
# styleIDRef 속성 변경 또는 추가
if 'styleIDRef=' in old_tag:
new_tag = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{style_id}"', old_tag)
else:
# 속성 추가
new_tag = old_tag.replace('<hp:p ', f'<hp:p styleIDRef="{style_id}" ')
return content[:match.start()] + new_tag + content[match.end():]
def _remove_manual_numbering(self, inner: str, role: str) -> str:
"""🆕 개요 문단에서 수동 번호 제거 (자동 번호가 붙으니까!)
HTML에서 "제1장 DX 개요""DX 개요" (자동으로 "제1장" 붙음)
HTML에서 "1.1 측량 DX""측량 DX" (자동으로 "1.1" 붙음)
"""
# 역할별 번호 패턴
patterns = {
'H1': r'^(제\s*\d+\s*장\s*)', # "제1장 " → 제거
'H2': r'^(\d+\.\d+\s+)', # "1.1 " → 제거
'H3': r'^(\d+\.\d+\.\d+\s+)', # "1.1.1 " → 제거
'H4': r'^([가-힣]\.\s+)', # "가. " → 제거
'H5': r'^(\d+\)\s+)', # "1) " → 제거
'H6': r'^([가-힣]\)\s+|\(\d+\)\s+)', # "가) " 또는 "(1) " → 제거
'H7': r'^([①②③④⑤⑥⑦⑧⑨⑩]+\s*)', # "① " → 제거
}
if role not in patterns:
return inner
pattern = patterns[role]
# <hp:t> 태그 내 텍스트에서 번호 제거
def remove_number(match):
text = match.group(1)
# 첫 번째 <hp:t> 내용에서만 번호 제거
new_text = re.sub(pattern, '', text, count=1)
return f'<hp:t>{new_text}</hp:t>'
# 첫 번째 hp:t 태그만 처리
new_inner = re.sub(r'<hp:t>([^<]*)</hp:t>', remove_number, inner, count=1)
return new_inner
def _repack_hwpx(self, output_path: str):
"""HWPX 재압축"""
print(f" [DEBUG] Repacking to: {output_path}")
print(f" [DEBUG] Source dir: {self.temp_dir}")
# 압축 전 section 파일 크기 확인
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
sec_path = self.temp_dir / "Contents" / sec
if sec_path.exists():
print(f" [DEBUG] {sec} size before zip: {sec_path.stat().st_size} bytes")
# 🆕 임시 파일에 먼저 저장 (원본 파일 잠금 문제 회피)
temp_output = output_path + ".tmp"
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축 없이 첫 번째로
mimetype_path = self.temp_dir / "mimetype"
if mimetype_path.exists():
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
file_count = 0
for root, dirs, files in os.walk(self.temp_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(self.temp_dir)
zf.write(file_path, arcname)
file_count += 1
print(f" [DEBUG] Total files zipped: {file_count}")
# 🆕 원본 삭제 후 임시 파일을 원본 이름으로 변경
import time
for attempt in range(3):
try:
if os.path.exists(output_path):
os.remove(output_path)
os.rename(temp_output, output_path)
break
except PermissionError:
print(f" [DEBUG] 파일 잠금 대기 중... ({attempt + 1}/3)")
time.sleep(0.5)
else:
# 3번 시도 실패 시 임시 파일 이름으로 유지
print(f" [경고] 원본 덮어쓰기 실패, 임시 파일 사용: {temp_output}")
output_path = temp_output
# 압축 후 결과 확인
print(f" [DEBUG] Output file size: {Path(output_path).stat().st_size} bytes")
def inject_styles_to_hwpx(hwpx_path: str, elements: list) -> str:
"""
편의 함수: StyledElement 리스트로부터 역할 위치 추출 후 스타일 주입
Args:
hwpx_path: HWPX 파일 경로
elements: StyleAnalyzer의 StyledElement 리스트
Returns:
수정된 HWPX 파일 경로
"""
# 역할별 위치 수집
# 참고: 현재는 section 0, para 순서대로 가정
role_positions: Dict[str, List[tuple]] = {}
for idx, elem in enumerate(elements):
role = elem.role
if role not in role_positions:
role_positions[role] = []
# (section_idx, para_idx) - 현재는 section 0 가정
role_positions[role].append((0, idx))
injector = HwpxStyleInjector()
return injector.inject(hwpx_path, role_positions)
# 테스트
if __name__ == "__main__":
# 테스트용
test_positions = {
'H1': [(0, 0), (0, 5)],
'H2': [(0, 1), (0, 6)],
'BODY': [(0, 2), (0, 3), (0, 4)],
}
# injector = HwpxStyleInjector()
# injector.inject("test.hwpx", test_positions)
print("HwpxStyleInjector 모듈 로드 완료")

View File

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""
HWPX 표 열 너비 수정기 v2
표 생성 후 HWPX 파일을 직접 수정하여 열 너비 적용
"""
import zipfile
import re
from pathlib import Path
import tempfile
import shutil
# mm → HWPML 단위 변환 (1mm ≈ 283.46 HWPML units)
MM_TO_HWPML = 7200 / 25.4 # ≈ 283.46
def inject_table_widths(hwpx_path: str, table_widths_list: list):
"""
HWPX 파일의 표 열 너비를 수정
Args:
hwpx_path: HWPX 파일 경로
table_widths_list: [[w1, w2, w3], [w1, w2], ...] 형태 (mm 단위)
"""
if not table_widths_list:
print(" [INFO] 수정할 표 없음")
return
print(f"📐 HWPX 표 열 너비 수정 시작... ({len(table_widths_list)}개 표)")
# HWPX 압축 해제
temp_dir = Path(tempfile.mkdtemp(prefix="hwpx_table_"))
with zipfile.ZipFile(hwpx_path, 'r') as zf:
zf.extractall(temp_dir)
# section*.xml 파일들에서 표 찾기
contents_dir = temp_dir / "Contents"
table_idx = 0
total_modified = 0
for section_file in sorted(contents_dir.glob("section*.xml")):
with open(section_file, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
# 모든 표(<hp:tbl>...</hp:tbl>) 찾기
tbl_pattern = re.compile(r'(<hp:tbl\b[^>]*>)(.*?)(</hp:tbl>)', re.DOTALL)
def process_table(match):
nonlocal table_idx, total_modified
if table_idx >= len(table_widths_list):
return match.group(0)
tbl_open = match.group(1)
tbl_content = match.group(2)
tbl_close = match.group(3)
col_widths_mm = table_widths_list[table_idx]
col_widths_hwpml = [int(w * MM_TO_HWPML) for w in col_widths_mm]
# 표 전체 너비 수정 (hp:sz width="...")
total_width = int(sum(col_widths_mm) * MM_TO_HWPML)
tbl_content = re.sub(
r'(<hp:sz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{total_width}{m.group(3)}',
tbl_content,
count=1
)
# 각 셀의 cellSz width 수정
# 방법: colAddr별로 너비 매핑
def replace_cell_width(tc_match):
tc_content = tc_match.group(0)
# colAddr 추출
col_addr_match = re.search(r'<hp:cellAddr\s+colAddr="(\d+)"', tc_content)
if not col_addr_match:
return tc_content
col_idx = int(col_addr_match.group(1))
if col_idx >= len(col_widths_hwpml):
return tc_content
new_width = col_widths_hwpml[col_idx]
# cellSz width 교체
tc_content = re.sub(
r'(<hp:cellSz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{new_width}{m.group(3)}',
tc_content
)
return tc_content
# 각 <hp:tc>...</hp:tc> 블록 처리
tbl_content = re.sub(
r'<hp:tc\b[^>]*>.*?</hp:tc>',
replace_cell_width,
tbl_content,
flags=re.DOTALL
)
print(f" ✅ 표 #{table_idx + 1}: {col_widths_mm} mm → HWPML 적용")
table_idx += 1
total_modified += 1
return tbl_open + tbl_content + tbl_close
# 표 처리
new_content = tbl_pattern.sub(process_table, content)
# 변경사항 있으면 저장
if new_content != original_content:
with open(section_file, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"{section_file.name} 저장됨")
# 다시 압축
repack_hwpx(temp_dir, hwpx_path)
# 임시 폴더 삭제
shutil.rmtree(temp_dir)
print(f" ✅ 총 {total_modified}개 표 열 너비 수정 완료")
def repack_hwpx(source_dir: Path, output_path: str):
"""HWPX 파일 다시 압축"""
import os
import time
temp_output = output_path + ".tmp"
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축 없이 첫 번째로
mimetype_path = source_dir / "mimetype"
if mimetype_path.exists():
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
for root, dirs, files in os.walk(source_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(source_dir)
zf.write(file_path, arcname)
# 원본 교체
for attempt in range(3):
try:
if os.path.exists(output_path):
os.remove(output_path)
os.rename(temp_output, output_path)
break
except PermissionError:
time.sleep(0.5)
# 테스트용
if __name__ == "__main__":
test_widths = [
[18.2, 38.9, 42.8, 70.1],
[19.9, 79.6, 70.5],
[28.7, 81.4, 59.9],
[19.2, 61.4, 89.5],
]
hwpx_path = r"C:\Users\User\AppData\Local\Temp\geulbeot_output.hwpx"
inject_table_widths(hwpx_path, test_widths)

View File

@@ -0,0 +1 @@
from .router import process_document, is_long_document

View File

@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""
router.py
기능:
- HTML 입력의 분량을 판단하여 적절한 파이프라인으로 분기
- 긴 문서 (5000자 이상): RAG 파이프라인 (step3→4→5→6→7→8→9)
- 짧은 문서 (5000자 미만): 직접 생성 (step7→8→9)
"""
import re
import os
from typing import Dict, Any
# 분량 판단 기준
LONG_DOC_THRESHOLD = 5000 # 5000자 이상이면 긴 문서
# 이미지 assets 경로 (개발용 고정) - r prefix 필수!
ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
def count_characters(html_content: str) -> int:
"""HTML 태그 제외한 순수 텍스트 글자 수 계산"""
# HTML 태그 제거
text_only = re.sub(r'<[^>]+>', '', html_content)
# 공백 정리
text_only = ' '.join(text_only.split())
return len(text_only)
def is_long_document(html_content: str) -> bool:
"""긴 문서 여부 판단"""
char_count = count_characters(html_content)
return char_count >= LONG_DOC_THRESHOLD
def convert_image_paths(html_content: str) -> str:
"""
HTML 내 이미지 경로를 서버 경로로 변환
- assets/xxx.png → /assets/xxx.png (Flask 서빙용)
- 절대 경로나 URL은 그대로 유지
"""
def replace_src(match):
original_path = match.group(1)
# 이미 절대 경로이거나 URL이면 그대로
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')):
return match.group(0)
# assets/로 시작하면 /assets/로 변환 (Flask 서빙)
if original_path.startswith('assets/'):
return f'src="/{original_path}"'
return match.group(0)
# src="..." 패턴 찾아서 변환
result = re.sub(r'src="([^"]+)"', replace_src, html_content)
return result
def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
"""
짧은 문서 파이프라인 (5000자 미만)
"""
try:
# 이미지 경로 변환
processed_html = convert_image_paths(html_content)
# TODO: step7, step8, step9 연동
return {
'success': True,
'pipeline': 'short',
'char_count': count_characters(html_content),
'html': processed_html
}
except Exception as e:
return {
'success': False,
'error': str(e),
'pipeline': 'short'
}
def inject_template_css(html_content: str, template_css: str) -> str:
"""
HTML에 템플릿 CSS 주입
- <style> 태그가 있으면 그 안에 추가
- 없으면 <head>에 새로 생성
"""
if not template_css:
return html_content
css_block = f"\n/* ===== 템플릿 스타일 ===== */\n{template_css}\n"
# 기존 </style> 태그 앞에 추가
if '</style>' in html_content:
return html_content.replace('</style>', f'{css_block}</style>', 1)
# <head> 태그 뒤에 새로 추가
elif '<head>' in html_content:
return html_content.replace('<head>', f'<head>\n<style>{css_block}</style>', 1)
# head도 없으면 맨 앞에 추가
else:
return f'<style>{css_block}</style>\n{html_content}'
def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
"""
긴 문서 파이프라인 (5000자 이상)
"""
try:
# 이미지 경로 변환
processed_html = convert_image_paths(html_content)
# TODO: step3~9 순차 실행
return {
'success': True,
'pipeline': 'long',
'char_count': count_characters(html_content),
'html': processed_html
}
except Exception as e:
return {
'success': False,
'error': str(e),
'pipeline': 'long'
}
def process_document(content: str, options: dict = None) -> Dict[str, Any]:
"""
메인 라우터 함수
- 분량에 따라 적절한 파이프라인으로 분기
Args:
content: HTML 문자열
options: 추가 옵션 (page_option, instruction 등)
Returns:
{'success': bool, 'html': str, 'pipeline': str, ...}
"""
if options is None:
options = {}
if not content or not content.strip():
return {
'success': False,
'error': '내용이 비어있습니다.'
}
char_count = count_characters(content)
if is_long_document(content):
result = run_long_pipeline(content, options)
else:
result = run_short_pipeline(content, options)
# 공통 정보 추가
result['char_count'] = char_count
result['threshold'] = LONG_DOC_THRESHOLD
# ⭐ 템플릿 CSS 주입
template_css = options.get('template_css')
if template_css and result.get('success') and result.get('html'):
result['html'] = inject_template_css(result['html'], template_css)
return result

View File

@@ -0,0 +1,784 @@
"""
측량/GIS/드론 관련 자료 PDF 변환 및 정리 시스템
- 모든 파일 형식을 PDF로 변환
- DWG 파일: DWG TrueView를 사용한 자동 PDF 변환
- 동영상 파일: Whisper를 사용한 음성→텍스트 변환 후 PDF 생성
- 원본 경로와 변환 파일 경로를 엑셀로 관리
"""
import os
import shutil
from pathlib import Path
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
import win32com.client
import pythoncom
from PIL import Image
import subprocess
import json
class SurveyingFileConverter:
def _dbg(self, msg):
if getattr(self, "debug", False):
print(msg)
def _ensure_ffmpeg_on_path(self):
import os
import shutil
from pathlib import Path
found = shutil.which("ffmpeg")
self._dbg(f"DEBUG ffmpeg which before: {found}")
if found:
self.ffmpeg_exe = found
return True
try:
import imageio_ffmpeg
src = Path(imageio_ffmpeg.get_ffmpeg_exe())
self._dbg(f"DEBUG imageio ffmpeg exe: {src}")
self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}")
if not src.exists():
return False
tools_dir = Path(self.output_dir) / "tools_ffmpeg"
tools_dir.mkdir(parents=True, exist_ok=True)
dst = tools_dir / "ffmpeg.exe"
if not dst.exists():
shutil.copyfile(str(src), str(dst))
os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "")
found2 = shutil.which("ffmpeg")
self._dbg(f"DEBUG ffmpeg which after: {found2}")
if found2:
self.ffmpeg_exe = found2
return True
return False
except Exception as e:
self._dbg(f"DEBUG ensure ffmpeg error: {e}")
return False
def __init__(self, source_dir, output_dir):
self.source_dir = Path(source_dir)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.debug = True
self.ffmpeg_exe = None
ok = self._ensure_ffmpeg_on_path()
self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}")
# 변환 로그를 저장할 리스트
self.conversion_log = []
# ★ 추가: 도메인 용어 사전
self.domain_terms = ""
# HWP 보안 모듈 후보 목록 추가
self.hwp_security_modules = [
"FilePathCheckerModuleExample",
"SecurityModule",
""
]
# 지원 파일 확장자 정의
self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'}
self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'}
self.text_extensions = {'.txt', '.csv', '.log', '.md'}
self.pdf_extension = {'.pdf'}
self.dwg_extensions = {'.dwg', '.dxf'}
# DWG TrueView 경로 설정 (설치 버전에 맞게 조정)
self.trueview_path = self._find_trueview()
def _find_trueview(self):
"""DWG TrueView 설치 경로 자동 탐색"""
possible_paths = [
r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe",
r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe",
r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe",
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe",
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe",
]
for path in possible_paths:
if Path(path).exists():
return path
return None
def get_all_files(self):
"""하위 모든 폴더의 파일 목록 가져오기"""
all_files = []
for file_path in self.source_dir.rglob('*'):
if file_path.is_file():
all_files.append(file_path)
return all_files
def extract_audio_from_video(self, video_path, audio_output_path):
try:
import imageio_ffmpeg
from pathlib import Path
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}")
self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}")
self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}")
self._dbg(f"DEBUG extract out path: {audio_output_path}")
cmd = [
ffmpeg_exe,
"-i", str(video_path),
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
"-y",
str(audio_output_path),
]
self._dbg("DEBUG extract cmd: " + " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True)
self._dbg(f"DEBUG extract returncode: {result.returncode}")
self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}")
return True
except subprocess.CalledProcessError as e:
self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}")
self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}")
return False
except Exception as e:
self._dbg(f"DEBUG extract exception: {e}")
return False
def transcribe_audio_with_whisper(self, audio_path):
try:
self._ensure_ffmpeg_on_path()
import shutil
from pathlib import Path
ffmpeg_path = shutil.which("ffmpeg")
self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}")
if not ffmpeg_path:
if self.ffmpeg_exe:
import os
os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "")
audio_file = Path(audio_path)
self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}")
self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}")
if not audio_file.exists() or audio_file.stat().st_size == 0:
return "[오디오 파일이 비어있거나 존재하지 않음]"
import whisper
model = whisper.load_model("medium") # ★ base → medium 변경
# ★ domain_terms를 initial_prompt로 사용
result = model.transcribe(
str(audio_path),
language="ko",
task="transcribe",
initial_prompt=self.domain_terms if self.domain_terms else None,
condition_on_previous_text=True, # ★ 다시 True로
)
# ★ 후처리: 반복 및 이상한 텍스트 제거
text = result["text"]
text = self.clean_transcript(text)
return text
except Exception as e:
import traceback
self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}")
return f"[음성 인식 실패: {str(e)}]"
def clean_transcript(self, text):
"""Whisper 결과 후처리 - 반복/환각 제거"""
import re
# 1. 영어/일본어/중국어 환각 제거
text = re.sub(r'[A-Za-z]{3,}', '', text) # 3글자 이상 영어 제거
text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 일본어 제거
text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시)
# 2. 반복 문장 제거
sentences = text.split('.')
seen = set()
unique_sentences = []
for s in sentences:
s_clean = s.strip()
if s_clean and s_clean not in seen:
seen.add(s_clean)
unique_sentences.append(s_clean)
text = '. '.join(unique_sentences)
# 3. 이상한 문자 정리
text = re.sub(r'\s+', ' ', text) # 다중 공백 제거
text = text.strip()
return text
def get_video_transcript(self, video_path):
"""동영상 파일의 음성을 텍스트로 변환"""
try:
# 임시 오디오 파일 경로
temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav"
# 1. 동영상에서 오디오 추출
if not self.extract_audio_from_video(video_path, temp_audio):
return self.get_basic_file_info(video_path) + "\n\n[오디오 추출 실패]"
if (not temp_audio.exists()) or temp_audio.stat().st_size == 0:
return self.get_basic_file_info(video_path) + "\n\n[오디오 파일 생성 실패]"
# 2. Whisper로 음성 인식
transcript = self.transcribe_audio_with_whisper(temp_audio)
# 3. 임시 오디오 파일 삭제
if temp_audio.exists():
temp_audio.unlink()
# 4. 결과 포맷팅
stat = video_path.stat()
lines = []
lines.append(f"동영상 파일 음성 전사 (Speech-to-Text)")
lines.append(f"=" * 60)
lines.append(f"파일명: {video_path.name}")
lines.append(f"경로: {video_path}")
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
lines.append("=" * 60)
lines.append("음성 내용:")
lines.append("=" * 60)
lines.append("")
lines.append(transcript)
return "\n".join(lines)
except Exception as e:
return self.get_basic_file_info(video_path) + f"\n\n[음성 인식 오류: {str(e)}]"
def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path):
"""DWG TrueView를 사용한 DWG → PDF 변환"""
if not self.trueview_path:
return False, "DWG TrueView가 설치되지 않음"
try:
# AutoCAD 스크립트 생성
script_content = f"""_-EXPORT_PDF{pdf_path}_Y"""
script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr"
with open(script_path, 'w') as f:
f.write(script_content)
# TrueView 실행
cmd = [
self.trueview_path,
str(dwg_path.absolute()),
"/b", str(script_path.absolute()),
"/nologo"
]
result = subprocess.run(cmd, timeout=120, capture_output=True)
# 스크립트 파일 삭제
if script_path.exists():
try:
script_path.unlink()
except:
pass
# PDF 생성 확인
if pdf_path.exists():
return True, "성공"
else:
return False, "PDF 생성 실패"
except subprocess.TimeoutExpired:
return False, "변환 시간 초과"
except Exception as e:
return False, f"DWG 변환 실패: {str(e)}"
def get_basic_file_info(self, file_path):
"""기본 파일 정보 반환"""
stat = file_path.stat()
lines = []
lines.append(f"파일 정보")
lines.append(f"=" * 60)
lines.append(f"파일명: {file_path.name}")
lines.append(f"경로: {file_path}")
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"수정일: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(lines)
def format_file_size(self, size_bytes):
"""파일 크기를 읽기 쉬운 형식으로 변환"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} TB"
def convert_image_to_pdf(self, image_path, output_path):
"""이미지 파일을 PDF로 변환"""
try:
img = Image.open(image_path)
# RGB 모드로 변환 (RGBA나 다른 모드 처리)
if img.mode in ('RGBA', 'LA', 'P'):
# 흰색 배경 생성
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
img.save(output_path, 'PDF', resolution=100.0)
return True, "성공"
except Exception as e:
return False, f"이미지 변환 실패: {str(e)}"
def convert_office_to_pdf(self, file_path, output_path):
"""Office 문서를 PDF로 변환"""
pythoncom.CoInitialize()
try:
ext = file_path.suffix.lower()
if ext in {'.hwp', '.hwpx'}:
return self.convert_hwp_to_pdf(file_path, output_path)
elif ext in {'.doc', '.docx'}:
return self.convert_word_to_pdf(file_path, output_path)
elif ext in {'.xls', '.xlsx'}:
return self.convert_excel_to_pdf(file_path, output_path)
elif ext in {'.ppt', '.pptx'}:
return self.convert_ppt_to_pdf(file_path, output_path)
else:
return False, "지원하지 않는 Office 형식"
except Exception as e:
return False, f"Office 변환 실패: {str(e)}"
finally:
pythoncom.CoUninitialize()
def convert_word_to_pdf(self, file_path, output_path):
"""Word 문서를 PDF로 변환"""
try:
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
doc = word.Documents.Open(str(file_path.absolute()))
doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF
doc.Close()
word.Quit()
return True, "성공"
except Exception as e:
return False, f"Word 변환 실패: {str(e)}"
def convert_excel_to_pdf(self, file_path, output_path):
"""Excel 파일을 PDF로 변환 - 열 너비에 맞춰 출력"""
try:
excel = win32com.client.Dispatch("Excel.Application")
excel.Visible = False
wb = excel.Workbooks.Open(str(file_path.absolute()))
# 모든 시트에 대해 페이지 설정
for ws in wb.Worksheets:
# 페이지 설정
ws.PageSetup.Zoom = False # 자동 크기 조정 비활성화
ws.PageSetup.FitToPagesWide = 1 # 너비를 1페이지에 맞춤
ws.PageSetup.FitToPagesTall = False # 높이는 자동 (내용에 따라)
# 여백 최소화 (단위: 포인트, 1cm ≈ 28.35 포인트)
ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1)
ws.PageSetup.RightMargin = excel.CentimetersToPoints(1)
ws.PageSetup.TopMargin = excel.CentimetersToPoints(1)
ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1)
# 용지 방향 자동 결정 (가로가 긴 경우 가로 방향)
used_range = ws.UsedRange
if used_range.Columns.Count > used_range.Rows.Count:
ws.PageSetup.Orientation = 2 # xlLandscape (가로)
else:
ws.PageSetup.Orientation = 1 # xlPortrait (세로)
# PDF로 저장
wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF
wb.Close()
excel.Quit()
return True, "성공"
except Exception as e:
return False, f"Excel 변환 실패: {str(e)}"
def convert_ppt_to_pdf(self, file_path, output_path):
"""PowerPoint 파일을 PDF로 변환"""
try:
ppt = win32com.client.Dispatch("PowerPoint.Application")
ppt.Visible = True
presentation = ppt.Presentations.Open(str(file_path.absolute()))
presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF
presentation.Close()
ppt.Quit()
return True, "성공"
except Exception as e:
return False, f"PowerPoint 변환 실패: {str(e)}"
def convert_hwp_to_pdf(self, file_path, output_path):
hwp = None
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
except Exception:
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
registered = False
last_reg_error = None
for module_name in getattr(self, "hwp_security_modules", [""]):
try:
hwp.RegisterModule("FilePathCheckDLL", module_name)
registered = True
break
except Exception as e:
last_reg_error = e
if not registered:
return False, f"HWP 보안 모듈 등록 실패: {last_reg_error}"
hwp.Open(str(file_path.absolute()), "", "")
hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute())
hwp.HParameterSet.HFileOpenSave.Format = "PDF"
hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
if output_path.exists() and output_path.stat().st_size > 0:
return True, "성공"
return False, "PDF 생성 확인 실패"
except Exception as e:
return False, f"HWP 변환 실패: {str(e)}"
finally:
try:
if hwp:
try:
hwp.Clear(1)
except Exception:
pass
try:
hwp.Quit()
except Exception:
pass
except Exception:
pass
def convert_text_to_pdf(self, text_path, output_path):
"""텍스트 파일을 PDF로 변환 (reportlab 사용)"""
try:
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# 한글 폰트 등록 (시스템에 설치된 폰트 사용)
try:
pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf'))
font_name = 'Malgun'
except:
font_name = 'Helvetica'
# 텍스트 읽기
with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
# PDF 생성
c = canvas.Canvas(str(output_path), pagesize=A4)
width, height = A4
c.setFont(font_name, 10)
# 여백 설정
margin = 50
y = height - margin
line_height = 14
# 줄 단위로 처리
for line in content.split('\n'):
if y < margin: # 페이지 넘김
c.showPage()
c.setFont(font_name, 10)
y = height - margin
# 긴 줄은 자동으로 줄바꿈
if len(line) > 100:
chunks = [line[i:i+100] for i in range(0, len(line), 100)]
for chunk in chunks:
c.drawString(margin, y, chunk)
y -= line_height
else:
c.drawString(margin, y, line)
y -= line_height
c.save()
return True, "성공"
except Exception as e:
return False, f"텍스트 변환 실패: {str(e)}"
def process_file(self, file_path):
"""개별 파일 처리"""
ext = file_path.suffix.lower()
# 출력 파일명 생성 (원본 경로 구조 유지)
relative_path = file_path.relative_to(self.source_dir)
output_subdir = self.output_dir / relative_path.parent
output_subdir.mkdir(parents=True, exist_ok=True)
# PDF 파일명
output_pdf = output_subdir / f"{file_path.stem}.pdf"
success = False
message = ""
try:
# 이미 PDF인 경우
if ext in self.pdf_extension:
shutil.copy2(file_path, output_pdf)
success = True
message = "PDF 복사 완료"
# DWG/DXF 파일
elif ext in self.dwg_extensions:
success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf)
# 이미지 파일
elif ext in self.image_extensions:
success, message = self.convert_image_to_pdf(file_path, output_pdf)
# Office 문서
elif ext in self.office_extensions:
success, message = self.convert_office_to_pdf(file_path, output_pdf)
# 동영상 파일 - 음성을 텍스트로 변환 후 PDF 생성
elif ext in self.video_extensions:
# 음성→텍스트 변환
transcript_text = self.get_video_transcript(file_path)
# 임시 txt 파일 생성
temp_txt = output_subdir / f"{file_path.stem}_transcript.txt"
with open(temp_txt, 'w', encoding='utf-8') as f:
f.write(transcript_text)
# txt를 PDF로 변환
success, message = self.convert_text_to_pdf(temp_txt, output_pdf)
if success:
message = "성공 (음성 인식 완료)"
# 임시 txt 파일은 남겨둠 (참고용)
# 텍스트 파일
elif ext in self.text_extensions:
success, message = self.convert_text_to_pdf(file_path, output_pdf)
else:
message = f"지원하지 않는 파일 형식: {ext}"
except Exception as e:
message = f"처리 중 오류: {str(e)}"
# 로그 기록
self.conversion_log.append({
'원본 경로': str(file_path),
'파일명': file_path.name,
'파일 형식': ext,
'변환 PDF 경로': str(output_pdf) if success else "",
'상태': "성공" if success else "실패",
'메시지': message,
'처리 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
return success, message
def create_excel_report(self, excel_path):
"""변환 결과를 엑셀로 저장"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "변환 결과"
# 헤더 스타일
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
# 헤더 작성
headers = ['번호', '원본 경로', '파일명', '파일 형식', '변환 PDF 경로', '상태', '메시지', '처리 시간']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 데이터 작성
for idx, log in enumerate(self.conversion_log, 2):
ws.cell(row=idx, column=1, value=idx-1)
ws.cell(row=idx, column=2, value=log['원본 경로'])
ws.cell(row=idx, column=3, value=log['파일명'])
ws.cell(row=idx, column=4, value=log['파일 형식'])
ws.cell(row=idx, column=5, value=log['변환 PDF 경로'])
# 상태에 따라 색상 표시
status_cell = ws.cell(row=idx, column=6, value=log['상태'])
if log['상태'] == "성공":
status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
status_cell.font = Font(color="006100")
else:
status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
status_cell.font = Font(color="9C0006")
ws.cell(row=idx, column=7, value=log['메시지'])
ws.cell(row=idx, column=8, value=log['처리 시간'])
# 열 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 요약 시트 추가
summary_ws = wb.create_sheet(title="요약")
total_files = len(self.conversion_log)
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
fail_count = total_files - success_count
summary_data = [
['항목', ''],
['총 파일 수', total_files],
['변환 성공', success_count],
['변환 실패', fail_count],
['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"],
['', ''],
['원본 폴더', str(self.source_dir)],
['출력 폴더', str(self.output_dir)],
['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
]
for row_idx, row_data in enumerate(summary_data, 1):
for col_idx, value in enumerate(row_data, 1):
cell = summary_ws.cell(row=row_idx, column=col_idx, value=value)
if row_idx == 1:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left')
summary_ws.column_dimensions['A'].width = 20
summary_ws.column_dimensions['B'].width = 60
# 저장
wb.save(excel_path)
print(f"\n엑셀 보고서 생성 완료: {excel_path}")
def run(self):
"""전체 변환 작업 실행"""
print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"원본 폴더: {self.source_dir}")
print(f"출력 폴더: {self.output_dir}")
# DWG TrueView 확인
if self.trueview_path:
print(f"DWG TrueView 발견: {self.trueview_path}")
else:
print("경고: DWG TrueView를 찾을 수 없습니다. DWG 파일 변환이 불가능합니다.")
print("-" * 80)
# 모든 파일 가져오기
all_files = self.get_all_files()
total_files = len(all_files)
# ★ 파일 분류: 동영상 vs 나머지
video_files = []
other_files = []
for file_path in all_files:
if file_path.suffix.lower() in self.video_extensions:
video_files.append(file_path)
else:
other_files.append(file_path)
print(f"\n{total_files}개 파일 발견")
print(f" - 문서/이미지 등: {len(other_files)}")
print(f" - 동영상: {len(video_files)}")
print("\n[1단계] 문서 파일 변환 시작...\n")
# ★ 1단계: 문서 파일 먼저 처리
for idx, file_path in enumerate(other_files, 1):
print(f"[{idx}/{len(other_files)}] {file_path.name} 처리 중...", end=' ')
success, message = self.process_file(file_path)
print(f"{'' if success else ''} {message}")
# ★ 2단계: domain.txt 로드
domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테스트 중(측량)\domain.txt
if domain_path.exists():
self.domain_terms = domain_path.read_text(encoding='utf-8')
print(f"\n[2단계] 도메인 용어 사전 로드 완료: {domain_path}")
print(f" - 용어 수: 약 {len(self.domain_terms.split())}개 단어")
else:
print(f"\n[2단계] 도메인 용어 사전 없음: {domain_path}")
print(" - 기본 음성 인식으로 진행합니다.")
# ★ 3단계: 동영상 파일 처리
if video_files:
print(f"\n[3단계] 동영상 음성 인식 시작...\n")
for idx, file_path in enumerate(video_files, 1):
print(f"[{idx}/{len(video_files)}] {file_path.name} 처리 중...", end=' ')
success, message = self.process_file(file_path)
print(f"{'' if success else ''} {message}")
# 엑셀 보고서 생성
excel_path = self.output_dir / f"변환_결과_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
self.create_excel_report(excel_path)
# 최종 요약
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
print("\n" + "=" * 80)
print(f"작업 완료!")
print(f"총 파일: {total_files}")
print(f"성공: {success_count}")
print(f"실패: {total_files - success_count}")
print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%")
print("=" * 80)
if __name__ == "__main__":
# 경로 설정
SOURCE_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\in"
OUTPUT_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out"
# 변환기 실행
converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR)
converter.run()

View File

@@ -0,0 +1,789 @@
# -*- coding: utf-8 -*-
"""
extract_1_v2.py
PDF에서 텍스트(md)와 이미지(png)를 추출
- 하위 폴더 구조 유지
- 이미지 메타데이터 JSON 생성 (폴더경로, 파일명, 페이지, 위치, 캡션 등)
"""
import fitz # PyMuPDF
import os
import re
import json
import numpy as np
from pathlib import Path
from datetime import datetime
from PIL import Image
import io
# ===== OCR 설정 (선택적) =====
try:
import pytesseract
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
print("[INFO] pytesseract 미설치 - 텍스트 잘림 필터 비활성화")
# ===== 경로 설정 =====
BASE_DIR = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") # PDF 원본 위치
OUTPUT_BASE = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
CAPTION_PATTERN = re.compile(
r'^\s*(?:[<\[\(\{]\s*)?(그림|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-]\s*\d+)?',
re.IGNORECASE
)
def get_figure_rects(page):
"""
Identifies figure regions based on '<그림 N>' captions and vector drawings.
Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index}
"""
drawings = page.get_drawings()
blocks = page.get_text("blocks")
captions = []
for i, b in enumerate(blocks):
text = b[4]
if CAPTION_PATTERN.search(text):
captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []})
if not captions:
return []
filtered_drawings_rects = []
for d in drawings:
r = d["rect"]
if r.height > page.rect.height / 3 and r.width < 5:
continue
if r.width > page.rect.width * 0.9:
continue
filtered_drawings_rects.append(r)
page_area = page.rect.get_area()
img_rects = []
for b in page.get_text("dict")["blocks"]:
if b.get("type") == 1:
ir = fitz.Rect(b["bbox"])
if ir.get_area() < page_area * 0.01:
continue
img_rects.append(ir)
remaining_drawings = filtered_drawings_rects + img_rects
caption_clusters = {cap['index']: [cap['rect']] for cap in captions}
def is_text_between(r1, r2, text_blocks):
if r1.intersects(r2):
return False
union = r1 | r2
for b in text_blocks:
b_rect = fitz.Rect(b[:4])
text_content = b[4]
if len(text_content.strip()) < 20:
continue
if not b_rect.intersects(union):
continue
if b_rect.intersects(r1) or b_rect.intersects(r2):
continue
return True
return False
changed = True
while changed:
changed = False
to_remove = []
for d_rect in remaining_drawings:
best_cluster_key = None
min_dist = float('inf')
for cap_index, cluster_rects in caption_clusters.items():
for r in cluster_rects:
dist = 0
if d_rect.intersects(r):
dist = 0
else:
x_dist = 0
if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1
elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1
y_dist = 0
if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1
elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1
if x_dist < 150 and y_dist < 150:
dist = max(x_dist, y_dist) + 0.1
else:
dist = float('inf')
if dist < min_dist:
if not is_text_between(r, d_rect, blocks):
min_dist = dist
best_cluster_key = cap_index
if min_dist == 0:
break
if best_cluster_key is not None and min_dist < 150:
caption_clusters[best_cluster_key].append(d_rect)
to_remove.append(d_rect)
changed = True
for r in to_remove:
remaining_drawings.remove(r)
figure_regions = []
for cap in captions:
cluster_rects = caption_clusters[cap['index']]
content_rects = cluster_rects[1:]
if not content_rects:
continue
union_rect = content_rects[0]
for r in content_rects[1:]:
union_rect = union_rect | r
union_rect.x0 = max(0, union_rect.x0 - 5)
union_rect.x1 = min(page.rect.width, union_rect.x1 + 5)
union_rect.y0 = max(0, union_rect.y0 - 5)
union_rect.y1 = min(page.rect.height, union_rect.y1 + 5)
cap_rect = cap['rect']
if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2:
if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2
else:
if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2
area = union_rect.get_area()
page_area = page.rect.get_area()
if area < page_area * 0.01:
continue
if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6:
continue
if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6:
continue
text_blocks = page.get_text("blocks")
text_count = 0
for b in text_blocks:
b_rect = fitz.Rect(b[:4])
if not b_rect.intersects(union_rect):
continue
text = b[4].strip()
if len(text) < 5:
continue
text_count += 1
if text_count < 0:
continue
figure_regions.append({
'rect': union_rect,
'caption_index': cap['index'],
'caption_rect': cap['rect'],
'caption_text': cap['text'].strip() # ★ 캡션 텍스트 저장
})
return figure_regions
def pixmap_metrics(pix):
arr = np.frombuffer(pix.samples, dtype=np.uint8)
c = 4 if pix.alpha else 3
arr = arr.reshape(pix.height, pix.width, c)[:, :, :3]
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8)
white = gray > 245
nonwhite_ratio = float(1.0 - white.mean())
gx = np.abs(np.diff(gray.astype(np.int16), axis=1))
gy = np.abs(np.diff(gray.astype(np.int16), axis=0))
edge = (gx[:-1, :] + gy[:, :-1]) > 40
edge_ratio = float(edge.mean())
var = float(gray.var())
return nonwhite_ratio, edge_ratio, var
def keep_figure(pix):
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
if nonwhite_ratio < 0.004:
return False, nonwhite_ratio, edge_ratio, var
if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20:
return False, nonwhite_ratio, edge_ratio, var
return True, nonwhite_ratio, edge_ratio, var
# ===== 추가 이미지 필터 함수들 (v2.1) =====
def pix_to_pil(pix):
"""PyMuPDF Pixmap을 PIL Image로 변환"""
img_data = pix.tobytes("png")
return Image.open(io.BytesIO(img_data))
def has_cut_text_at_boundary(pix, margin=5):
"""
이미지 경계에서 텍스트가 잘렸는지 감지
- 이미지 테두리 근처에 텍스트 박스가 있으면 잘린 것으로 판단
Args:
pix: PyMuPDF Pixmap
margin: 경계로부터의 여유 픽셀 (기본 5px)
Returns:
bool: 텍스트가 잘렸으면 True
"""
if not TESSERACT_AVAILABLE:
return False # OCR 없으면 필터 비활성화
try:
img = pix_to_pil(pix)
width, height = img.size
# OCR로 텍스트 위치 추출
data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT)
for i, text in enumerate(data['text']):
text = str(text).strip()
if len(text) < 2: # 너무 짧은 텍스트는 무시
continue
x = data['left'][i]
y = data['top'][i]
w = data['width'][i]
h = data['height'][i]
# 텍스트가 이미지 경계에 너무 가까우면 = 잘린 것
# 왼쪽 경계
if x <= margin:
return True
# 오른쪽 경계
if x + w >= width - margin:
return True
# 상단 경계 (헤더 제외를 위해 좀 더 여유)
if y <= margin and h < height * 0.3:
return True
# 하단 경계
if y + h >= height - margin:
return True
return False
except Exception as e:
# OCR 실패 시 필터 통과 (이미지 유지)
return False
def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500):
"""
배경 패턴 + 텍스트만 있는 장식용 이미지인지 감지
- 엣지가 적고 (복잡한 도표/사진이 아님)
- 색상 다양성이 낮으면 (단순 그라데이션 배경)
Args:
pix: PyMuPDF Pixmap
edge_threshold: 엣지 비율 임계값 (기본 0.02 = 2%)
color_var_threshold: 색상 분산 임계값
Returns:
bool: 장식용 배경이면 True
"""
try:
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
# 엣지가 거의 없고 (단순한 이미지)
# 색상 분산도 낮으면 (배경 패턴)
if edge_ratio < edge_threshold and var < color_var_threshold:
# 추가 확인: 텍스트만 있는지 OCR로 체크
if TESSERACT_AVAILABLE:
try:
img = pix_to_pil(pix)
text = pytesseract.image_to_string(img, lang='kor+eng').strip()
# 텍스트가 있고, 이미지가 단순하면 = 텍스트 배경
if len(text) > 3 and edge_ratio < 0.015:
return True
except:
pass
return True
return False
except Exception:
return False
def is_header_footer_region(rect, page_rect, height_threshold=0.12):
"""
헤더/푸터 영역에 있는 이미지인지 감지
- 페이지 상단 12% 또는 하단 12%에 위치
- 높이가 낮은 strip 형태
Args:
rect: 이미지 영역 (fitz.Rect)
page_rect: 페이지 전체 영역 (fitz.Rect)
height_threshold: 헤더/푸터 영역 비율 (기본 12%)
Returns:
bool: 헤더/푸터 영역이면 True
"""
page_height = page_rect.height
img_height = rect.height
# 상단 영역 체크
if rect.y0 < page_height * height_threshold:
# 높이가 페이지의 15% 미만인 strip이면 헤더
if img_height < page_height * 0.15:
return True
# 하단 영역 체크
if rect.y1 > page_height * (1 - height_threshold):
# 높이가 페이지의 15% 미만인 strip이면 푸터
if img_height < page_height * 0.15:
return True
return False
def should_filter_image(pix, rect, page_rect):
"""
이미지를 필터링해야 하는지 종합 판단
Args:
pix: PyMuPDF Pixmap
rect: 이미지 영역
page_rect: 페이지 전체 영역
Returns:
tuple: (필터링 여부, 필터링 사유)
"""
# 1. 헤더/푸터 영역 체크
if is_header_footer_region(rect, page_rect):
return True, "header_footer"
# 2. 텍스트 잘림 체크
if has_cut_text_at_boundary(pix):
return True, "cut_text"
# 3. 장식용 배경 체크
if is_decorative_background(pix):
return True, "decorative_background"
return False, None
def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata):
"""
PDF 내용 추출
Args:
pdf_path: PDF 파일 경로
output_md_path: 출력 MD 파일 경로
img_dir: 이미지 저장 폴더
metadata: 메타데이터 딕셔너리 (폴더 경로, 파일명 등)
Returns:
image_metadata_list: 추출된 이미지들의 메타데이터 리스트
"""
os.makedirs(img_dir, exist_ok=True)
image_metadata_list = [] # ★ 이미지 메타데이터 수집
doc = fitz.open(pdf_path)
total_pages = len(doc)
with open(output_md_path, "w", encoding="utf-8") as md_file:
# ★ 메타데이터 헤더 추가
md_file.write(f"---\n")
md_file.write(f"source_pdf: {metadata['pdf_name']}\n")
md_file.write(f"source_folder: {metadata['relative_folder']}\n")
md_file.write(f"total_pages: {total_pages}\n")
md_file.write(f"extracted_at: {datetime.now().isoformat()}\n")
md_file.write(f"---\n\n")
md_file.write(f"# {metadata['pdf_name']}\n\n")
for page_num, page in enumerate(doc):
md_file.write(f"\n## Page {page_num + 1}\n\n")
img_rel_dir = os.path.basename(img_dir)
figure_regions = get_figure_rects(page)
kept_figures = []
for i, fig in enumerate(figure_regions):
rect = fig['rect']
pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB)
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
if not ok:
continue
pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB)
# ★ 추가 필터 적용 (v2.1)
should_filter, filter_reason = should_filter_image(pix, rect, page.rect)
if should_filter:
continue
img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png"
img_path = os.path.join(img_dir, img_name)
pix.save(img_path)
fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/")
fig['img_name'] = img_name
kept_figures.append(fig)
# ★ 이미지 메타데이터 수집
image_metadata_list.append({
"image_file": img_name,
"image_path": str(Path(img_dir) / img_name),
"type": "figure",
"source_pdf": metadata['pdf_name'],
"source_folder": metadata['relative_folder'],
"full_path": metadata['full_path'],
"page": page_num + 1,
"total_pages": total_pages,
"caption": fig.get('caption_text', ''),
"rect": {
"x0": round(rect.x0, 2),
"y0": round(rect.y0, 2),
"x1": round(rect.x1, 2),
"y1": round(rect.y1, 2)
}
})
figure_regions = kept_figures
caption_present = any(
CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks")
)
uncaptioned_idx = 0
items = []
def inside_any_figure(block_rect, figures):
for fig in figures:
intersect = block_rect & fig["rect"]
if intersect.get_area() > 0.5 * block_rect.get_area():
return True
return False
def is_full_width_rect(r, page_rect):
return r.width >= page_rect.width * 0.78
def figure_anchor_rect(fig, page_rect):
cap = fig["caption_rect"]
rect = fig["rect"]
if cap.y0 >= rect.y0:
y = max(0.0, cap.y0 - 0.02)
else:
y = min(page_rect.height - 0.02, cap.y1 + 0.02)
return fitz.Rect(cap.x0, y, cap.x1, y + 0.02)
for fig in figure_regions:
anchor = figure_anchor_rect(fig, page.rect)
md = (
f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n"
f"*{fig.get('caption_text', '')}*\n\n"
)
items.append({
"kind": "figure",
"rect": anchor,
"kind_order": 0,
"md": md,
})
raw_blocks = page.get_text("dict")["blocks"]
for block in raw_blocks:
block_rect = fitz.Rect(block["bbox"])
if block.get("type") == 0:
if inside_any_figure(block_rect, figure_regions):
continue
items.append({
"kind": "text",
"rect": block_rect,
"kind_order": 2,
"block": block,
})
continue
if block.get("type") == 1:
if inside_any_figure(block_rect, figure_regions):
continue
if caption_present:
continue
page_area = page.rect.get_area()
if block_rect.get_area() < page_area * 0.005:
continue
ratio = block_rect.width / max(1.0, block_rect.height)
if ratio < 0.25 or ratio > 4.0:
continue
pix_preview = page.get_pixmap(
clip=block_rect, dpi=80, colorspace=fitz.csRGB
)
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
if not ok:
continue
pix = page.get_pixmap(
clip=block_rect, dpi=150, colorspace=fitz.csRGB
)
# ★ 추가 필터 적용 (v2.1)
should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect)
if should_filter:
continue
img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png"
img_path = os.path.join(img_dir, img_name)
pix.save(img_path)
rel = os.path.join(img_rel_dir, img_name).replace("\\", "/")
r = block_rect
md = (
f'\n![Photo]({rel})\n'
f'*Page {page_num + 1} Photo*\n\n'
)
items.append({
"kind": "raster",
"rect": block_rect,
"kind_order": 1,
"md": md,
})
# ★ 캡션 없는 이미지 메타데이터
image_metadata_list.append({
"image_file": img_name,
"image_path": str(Path(img_dir) / img_name),
"type": "photo",
"source_pdf": metadata['pdf_name'],
"source_folder": metadata['relative_folder'],
"full_path": metadata['full_path'],
"page": page_num + 1,
"total_pages": total_pages,
"caption": "",
"rect": {
"x0": round(r.x0, 2),
"y0": round(r.y0, 2),
"x1": round(r.x1, 2),
"y1": round(r.y1, 2)
}
})
uncaptioned_idx += 1
continue
# 읽기 순서 정렬
text_items = [it for it in items if it["kind"] == "text"]
page_w = page.rect.width
mid = page_w / 2.0
candidates = []
for it in text_items:
r = it["rect"]
if is_full_width_rect(r, page.rect):
continue
if r.width < page_w * 0.2:
continue
candidates.append(it)
left = [it for it in candidates if it["rect"].x0 < mid * 0.95]
right = [it for it in candidates if it["rect"].x0 > mid * 1.05]
two_cols = len(left) >= 3 and len(right) >= 3
col_y0 = None
col_y1 = None
seps = []
if two_cols and left and right:
col_y0 = min(
min(it["rect"].y0 for it in left),
min(it["rect"].y0 for it in right),
)
col_y1 = max(
max(it["rect"].y1 for it in left),
max(it["rect"].y1 for it in right),
)
for it in text_items:
r = it["rect"]
if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect):
seps.append(r.y0)
seps = sorted(set(seps))
def seg_index(y0, separators):
if not separators:
return 0
n = 0
for s in separators:
if y0 >= s:
n += 1
else:
break
return n
def order_key(it):
r = it["rect"]
if not two_cols:
return (r.y0, r.x0, it["kind_order"])
if col_y0 is not None and r.y1 <= col_y0:
return (0, r.y0, r.x0, it["kind_order"])
if col_y1 is not None and r.y0 >= col_y1:
return (2, r.y0, r.x0, it["kind_order"])
seg = seg_index(r.y0, seps)
if is_full_width_rect(r, page.rect):
col = 2
else:
col = 0 if r.x0 < mid else 1
return (1, seg, col, r.y0, r.x0, it["kind_order"])
items.sort(key=order_key)
for it in items:
if it["kind"] in ("figure", "raster"):
md_file.write(it["md"])
continue
block = it["block"]
for line in block.get("lines", []):
for span in line.get("spans", []):
md_file.write(span.get("text", "") + " ")
md_file.write("\n")
md_file.write("\n")
doc.close()
return image_metadata_list
def process_all_pdfs():
"""
BASE_DIR 하위의 모든 PDF를 재귀적으로 처리
폴더 구조를 유지하면서 OUTPUT_BASE에 저장
"""
# 출력 폴더 생성
OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
# 전체 이미지 메타데이터 수집
all_image_metadata = []
# 처리 통계
stats = {
"total_pdfs": 0,
"success": 0,
"failed": 0,
"total_images": 0
}
# 실패 로그
failed_files = []
print(f"=" * 60)
print(f"PDF 추출 시작")
print(f"원본 폴더: {BASE_DIR}")
print(f"출력 폴더: {OUTPUT_BASE}")
print(f"=" * 60)
# 모든 PDF 파일 찾기
pdf_files = list(BASE_DIR.rglob("*.pdf"))
stats["total_pdfs"] = len(pdf_files)
print(f"\n{len(pdf_files)}개 PDF 발견\n")
for idx, pdf_path in enumerate(pdf_files, 1):
try:
# 상대 경로 계산
relative_path = pdf_path.relative_to(BASE_DIR)
relative_folder = str(relative_path.parent)
if relative_folder == ".":
relative_folder = ""
pdf_name = pdf_path.name
pdf_stem = pdf_path.stem
# 출력 경로 설정 (폴더 구조 유지)
output_folder = OUTPUT_BASE / relative_path.parent
output_folder.mkdir(parents=True, exist_ok=True)
output_md = output_folder / f"{pdf_stem}.md"
img_folder = output_folder / f"{pdf_stem}_img"
# 메타데이터 준비
metadata = {
"pdf_name": pdf_name,
"pdf_stem": pdf_stem,
"relative_folder": relative_folder,
"full_path": str(relative_path),
}
print(f"[{idx}/{len(pdf_files)}] {relative_path}")
# PDF 처리
image_metas = extract_pdf_content(
str(pdf_path),
str(output_md),
str(img_folder),
metadata
)
all_image_metadata.extend(image_metas)
stats["success"] += 1
stats["total_images"] += len(image_metas)
print(f" ✓ 완료 (이미지 {len(image_metas)}개)")
except Exception as e:
stats["failed"] += 1
failed_files.append({
"file": str(pdf_path),
"error": str(e)
})
print(f" ✗ 실패: {e}")
# 전체 이미지 메타데이터 저장
meta_output_path = OUTPUT_BASE / "image_metadata.json"
with open(meta_output_path, "w", encoding="utf-8") as f:
json.dump(all_image_metadata, f, ensure_ascii=False, indent=2)
# 처리 요약 저장
summary = {
"processed_at": datetime.now().isoformat(),
"source_dir": str(BASE_DIR),
"output_dir": str(OUTPUT_BASE),
"statistics": stats,
"failed_files": failed_files
}
summary_path = OUTPUT_BASE / "extraction_summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
# 결과 출력
print(f"\n" + "=" * 60)
print(f"추출 완료!")
print(f"=" * 60)
print(f"총 PDF: {stats['total_pdfs']}")
print(f"성공: {stats['success']}")
print(f"실패: {stats['failed']}")
print(f"추출된 이미지: {stats['total_images']}")
print(f"\n이미지 메타데이터: {meta_output_path}")
print(f"처리 요약: {summary_path}")
if failed_files:
print(f"\n실패한 파일:")
for f in failed_files:
print(f" - {f['file']}: {f['error']}")
if __name__ == "__main__":
process_all_pdfs()

View File

@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
"""
domain_prompt.py
기능:
- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파일들의
파일명과 내용 일부를 샘플링한다.
- 이 샘플을 기반으로, 문서 묶음의 분야/업무 맥락을 파악하고
"너는 ~~ 분야의 전문가이다. 나는 ~~를 하고 싶다..." 형식의
도메인 전용 시스템 프롬프트를 자동 생성한다.
- 결과는 output/context/domain_prompt.txt 로 저장된다.
이 domain_prompt.txt 내용은 이후 모든 GPT 호출(system role)에 공통으로 붙여 사용할 수 있다.
"""
import os
import sys
import json
from pathlib import Path
import pdfplumber
import fitz # PyMuPDF
from PIL import Image
import pytesseract
import pandas as pd
from openai import OpenAI
import pytesseract
from api_config import API_KEYS
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조만 유지, 키는 마스터가 직접 입력) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== OCR 설정 =====
OCR_LANG = "kor+eng"
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"}
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def safe_rel(p: Path) -> str:
try:
return str(p.relative_to(DATA_ROOT))
except Exception:
return str(p)
def ocr_image(img_path: Path) -> str:
try:
return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip()
except Exception as e:
log(f"[WARN] OCR 실패: {safe_rel(img_path)} | {e}")
return ""
def sample_from_pdf(p: Path, max_chars: int = 1000) -> str:
texts = []
try:
with pdfplumber.open(str(p)) as pdf:
# 앞쪽 몇 페이지만 샘플링
for page in pdf.pages[:3]:
t = page.extract_text() or ""
if t:
texts.append(t)
if sum(len(x) for x in texts) >= max_chars:
break
except Exception as e:
log(f"[WARN] PDF 샘플 추출 실패: {safe_rel(p)} | {e}")
joined = "\n".join(texts)
return joined[:max_chars]
def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str:
texts = [f"[파일명] {p.name}"]
try:
xls = pd.ExcelFile(str(p))
for sheet_name in xls.sheet_names[:3]:
try:
df = xls.parse(sheet_name)
except Exception as e:
log(f"[WARN] 시트 로딩 실패: {safe_rel(p)} | {sheet_name} | {e}")
continue
texts.append(f"\n[시트] {sheet_name}")
texts.append("컬럼: " + ", ".join(map(str, df.columns)))
head = df.head(5)
texts.append(head.to_string(index=False))
if sum(len(x) for x in texts) >= max_chars:
break
except Exception as e:
log(f"[WARN] XLSX 샘플 추출 실패: {safe_rel(p)} | {e}")
joined = "\n".join(texts)
return joined[:max_chars]
def sample_from_text_file(p: Path, max_chars: int = 1000) -> str:
try:
t = p.read_text(encoding="utf-8", errors="ignore")
except Exception:
t = p.read_text(encoding="cp949", errors="ignore")
return t[:max_chars]
def gather_file_samples(
max_files_per_type: int = 100,
max_total_samples: int = 300,
max_chars_per_sample: int = 1000,
):
file_names = []
samples = []
count_pdf = 0
count_xlsx = 0
count_img = 0
count_txt = 0
for root, dirs, files in os.walk(DATA_ROOT):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
cur_dir = Path(root)
for fname in files:
fpath = cur_dir / fname
ext = fpath.suffix.lower()
# 파일명은 전체 다 모으되, 샘플 추출은 제한
file_names.append(safe_rel(fpath))
if len(samples) >= max_total_samples:
continue
try:
if ext == ".pdf" and count_pdf < max_files_per_type:
s = sample_from_pdf(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[PDF] {safe_rel(fpath)}\n{s}")
count_pdf += 1
continue
if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type:
s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}")
count_xlsx += 1
continue
if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type:
s = ocr_image(fpath)
if s.strip():
samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}")
count_img += 1
continue
if ext in {".txt", ".md"} and count_txt < max_files_per_type:
s = sample_from_text_file(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}")
count_txt += 1
continue
except Exception as e:
log(f"[WARN] 샘플 추출 실패: {safe_rel(fpath)} | {e}")
continue
return file_names, samples
def build_domain_prompt():
"""
파일명 + 내용 샘플을 GPT에게 넘겨
'너는 ~~ 분야의 전문가이다...' 형태의 시스템 프롬프트를 생성한다.
"""
log("도메인 프롬프트 생성을 위한 샘플 수집 중...")
file_names, samples = gather_file_samples()
if not file_names and not samples:
log("파일 샘플이 없어 도메인 프롬프트를 생성할 수 없습니다.")
sys.exit(1)
file_names_text = "\n".join(file_names[:80])
sample_text = "\n\n".join(samples[:30])
prompt = f"""
다음은 한 기업의 '이슈 리포트 및 시스템 관련 자료'로 추정되는 파일들의 목록과,
각 파일에서 일부 추출한 내용 샘플이다.
[파일명 목록]
{file_names_text}
[내용 샘플]
{sample_text}
위 자료를 바탕으로 다음을 수행하라.
1) 이 문서 묶음이 어떤 산업, 업무, 분야에 대한 것인지,
핵심 키워드를 포함해 2~3줄 정도로 설명하라.
2) 이후, 이 문서들을 다루는 AI에게 사용할 "프롬프트 머리말"을 작성하라.
이 머리말은 모든 후속 프롬프트 앞에 항상 붙일 예정이며,
다음 조건을 만족해야 한다.
- 첫 문단: "너는 ~~ 분야의 전문가이다." 형식으로, 이 문서 묶음의 분야와 역할을 정의한다.
- 두 번째 문단 이후: "나는 ~~을 하고 싶다.", "우리는 ~~ 의 문제를 분석하고 개선방안을 찾고자 한다."
사용자가 AI에게 요구하는 전반적 목적과 관점을 정리한다.
- 총 5~7줄 정도의 한국어 문장으로 작성한다.
- 이후에 붙을 프롬프트(청킹, 요약, RAG, 보고서 작성 등)와 자연스럽게 연결될 수 있도록,
역할(role), 목적, 기준(추측 금지, 사실 기반, 근거 명시 등)을 모두 포함한다.
출력 형식:
- 설명과 머리말을 한 번에 출력하되,
별도의 마크다운 없이 순수 텍스트로만 작성하라.
- 이 출력 전체를 domain_prompt.txt에 그대로 저장할 것이다.
"""
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{
"role": "system",
"content": "너는 문서 묶음의 분야를 식별하고, 그에 맞는 AI 시스템 프롬프트와 컨텍스트를 설계하는 컨설턴트이다."
},
{
"role": "user",
"content": prompt
}
],
)
content = (resp.choices[0].message.content or "").strip()
out_path = CONTEXT_DIR / "domain_prompt.txt"
out_path.write_text(content, encoding="utf-8")
log(f"도메인 프롬프트 생성 완료: {out_path}")
return content
def main():
log("=== 도메인 프롬프트 생성 시작 ===")
out_path = CONTEXT_DIR / "domain_prompt.txt"
if out_path.exists():
log(f"이미 domain_prompt.txt가 존재합니다: {out_path}")
log("기존 파일을 사용하려면 종료하고, 재생성이 필요하면 파일을 삭제한 뒤 다시 실행하십시오.")
else:
build_domain_prompt()
log("=== 도메인 프롬프트 작업 종료 ===")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,357 @@
# -*- coding: utf-8 -*-
"""
chunk_and_summary_v2.py
기능:
- 정리중 폴더 아래의 .md 파일들을 대상으로
1) domain_prompt.txt 기반 GPT 의미 청킹
2) 청크별 요약 생성
3) 청크 내 이미지 참조 보존
4) JSON 저장 (원문+청크+요약+이미지)
5) RAG용 *_chunks.json 저장
전제:
- extract_1_v2.py 실행 후 .md 파일들이 존재할 것
- step1_domainprompt.py 실행 후 domain_prompt.txt가 존재할 것
"""
import os
import sys
import json
import re
from pathlib import Path
from datetime import datetime
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 =====
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
TEXT_DIR = OUTPUT_ROOT / "text"
JSON_DIR = OUTPUT_ROOT / "json"
RAG_DIR = OUTPUT_ROOT / "rag"
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 스킵할 폴더 =====
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"}
# ===== 이미지 참조 패턴 =====
IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log(f"domain_prompt.txt가 없습니다: {p}")
log("먼저 step1_domainprompt.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def safe_rel(p: Path) -> str:
"""DATA_ROOT 기준 상대 경로 반환"""
try:
return str(p.relative_to(DATA_ROOT))
except Exception:
return str(p)
def extract_text_md(p: Path) -> str:
"""마크다운 파일 텍스트 읽기"""
try:
return p.read_text(encoding="utf-8", errors="ignore")
except Exception:
return p.read_text(encoding="cp949", errors="ignore")
def find_images_in_text(text: str) -> list:
"""텍스트에서 이미지 참조 찾기"""
matches = IMAGE_PATTERN.findall(text)
return [{"alt": m[0], "path": m[1]} for m in matches]
def semantic_chunk(domain_prompt: str, text: str, source_name: str):
"""GPT 기반 의미 청킹"""
if not text.strip():
return []
# 텍스트가 너무 짧으면 그냥 하나의 청크로
if len(text) < 500:
return [{
"title": "전체 내용",
"keywords": "",
"content": text
}]
user_prompt = f"""
아래 문서를 의미 단위(문단/항목/섹션 등)로 분리하고,
각 청크는 title / keywords / content 를 포함한 JSON 배열로 출력하라.
규칙:
1. 추측 금지, 문서 내용 기반으로만 분리
2. 이미지 참조(![...](...))는 관련 텍스트와 같은 청크에 포함
3. 각 청크는 최소 100자 이상
4. keywords는 쉼표로 구분된 핵심 키워드 3~5개
문서:
{text[:12000]}
JSON 배열만 출력하라. 다른 설명 없이.
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 의미 기반 청킹 전문가이다. JSON 배열만 출력한다."},
{"role": "user", "content": user_prompt},
],
)
data = resp.choices[0].message.content.strip()
# JSON 파싱 시도
# ```json ... ``` 형식 처리
if "```json" in data:
data = data.split("```json")[1].split("```")[0].strip()
elif "```" in data:
data = data.split("```")[1].split("```")[0].strip()
if data.startswith("["):
return json.loads(data)
except json.JSONDecodeError as e:
log(f"[WARN] JSON 파싱 실패 ({source_name}): {e}")
except Exception as e:
log(f"[WARN] semantic_chunk API 실패 ({source_name}): {e}")
# fallback: 페이지/섹션 기반 분리
log(f"[INFO] Fallback 청킹 적용: {source_name}")
return fallback_chunk(text)
def fallback_chunk(text: str) -> list:
"""GPT 실패 시 대체 청킹 (페이지/섹션 기반)"""
chunks = []
# 페이지 구분자로 분리 시도
if "## Page " in text:
pages = re.split(r'\n## Page \d+\n', text)
for i, page_content in enumerate(pages):
if page_content.strip():
chunks.append({
"title": f"Page {i+1}",
"keywords": "",
"content": page_content.strip()
})
else:
# 빈 줄 2개 이상으로 분리
sections = re.split(r'\n{3,}', text)
for i, section in enumerate(sections):
if section.strip() and len(section.strip()) > 50:
chunks.append({
"title": f"섹션 {i+1}",
"keywords": "",
"content": section.strip()
})
# 청크가 없으면 전체를 하나로
if not chunks:
chunks.append({
"title": "전체 내용",
"keywords": "",
"content": text.strip()
})
return chunks
def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str:
"""청크 요약 생성"""
if not text.strip():
return ""
# 이미지 참조 제거 후 요약 (텍스트만)
text_only = IMAGE_PATTERN.sub('', text).strip()
if len(text_only) < 100:
return text_only
prompt = f"""
아래 텍스트를 {limit}자 이내로 사실 기반으로 요약하라.
추측 금지, 고유명사와 수치는 보존.
{text_only[:8000]}
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 사실만 요약하는 전문가이다."},
{"role": "user", "content": prompt},
],
)
return resp.choices[0].message.content.strip()
except Exception as e:
log(f"[WARN] summary 실패: {e}")
return text_only[:limit]
def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int:
"""
의미 청킹 → 요약 → JSON 저장
Returns:
생성된 청크 수
"""
stem = src.stem
folder_ctx = safe_rel(src.parent)
# 원문 저장
(TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore")
# 의미 청킹
chunks = semantic_chunk(domain_prompt, text, src.name)
if not chunks:
log(f"[WARN] 청크 없음: {src.name}")
return 0
rag_items = []
for idx, ch in enumerate(chunks, start=1):
content = ch.get("content", "")
# 요약 생성
summ = summary_chunk(domain_prompt, content, 300)
# 이 청크에 포함된 이미지 찾기
images_in_chunk = find_images_in_text(content)
rag_items.append({
"source": src.name,
"source_path": safe_rel(src),
"chunk": idx,
"total_chunks": len(chunks),
"title": ch.get("title", ""),
"keywords": ch.get("keywords", ""),
"text": content,
"summary": summ,
"folder_context": folder_ctx,
"images": images_in_chunk,
"has_images": len(images_in_chunk) > 0
})
# JSON 저장
(JSON_DIR / f"{stem}.json").write_text(
json.dumps(rag_items, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# RAG용 JSON 저장
(RAG_DIR / f"{stem}_chunks.json").write_text(
json.dumps(rag_items, ensure_ascii=False, indent=2),
encoding="utf-8"
)
return len(chunks)
def main():
log("=" * 60)
log("청킹/요약 파이프라인 시작")
log(f"데이터 폴더: {DATA_ROOT}")
log(f"출력 폴더: {OUTPUT_ROOT}")
log("=" * 60)
# 도메인 프롬프트 로드
domain_prompt = load_domain_prompt()
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
# 통계
stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0}
# .md 파일 찾기
md_files = []
for root, dirs, files in os.walk(DATA_ROOT):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
for fname in files:
if fname.lower().endswith(".md"):
md_files.append(Path(root) / fname)
log(f"\n{len(md_files)}개 .md 파일 발견\n")
for idx, fpath in enumerate(md_files, 1):
try:
rel_path = safe_rel(fpath)
log(f"[{idx}/{len(md_files)}] {rel_path}")
# 텍스트 읽기
text = extract_text_md(fpath)
if not text.strip():
log(f" ⚠ 빈 파일, 스킵")
continue
# 이미지 개수 확인
images = find_images_in_text(text)
stats["images"] += len(images)
# 청킹 및 저장
chunk_count = save_chunk_files(fpath, text, domain_prompt)
stats["docs"] += 1
stats["chunks"] += chunk_count
log(f"{chunk_count}개 청크, {len(images)}개 이미지")
except Exception as e:
stats["errors"] += 1
log(f" ✗ 오류: {e}")
# 전체 통계 저장
summary = {
"processed_at": datetime.now().isoformat(),
"data_root": str(DATA_ROOT),
"output_root": str(OUTPUT_ROOT),
"statistics": stats
}
(LOG_DIR / "chunk_summary_stats.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# 결과 출력
log("\n" + "=" * 60)
log("청킹/요약 완료!")
log("=" * 60)
log(f"처리된 문서: {stats['docs']}")
log(f"생성된 청크: {stats['chunks']}")
log(f"포함된 이미지: {stats['images']}")
log(f"오류: {stats['errors']}")
log(f"\n결과 저장 위치:")
log(f" - 원문: {TEXT_DIR}")
log(f" - JSON: {JSON_DIR}")
log(f" - RAG: {RAG_DIR}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""
build_rag.py
기능:
- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파일들을 읽어서
text + summary 를 임베딩(text-embedding-3-small)한다.
- FAISS IndexFlatIP 인덱스를 구축하여
output/rag/faiss.index, meta.json, vectors.npy 를 생성한다.
"""
import os
import sys
import json
from pathlib import Path
import numpy as np
import faiss
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
RAG_DIR = OUTPUT_ROOT / "rag"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [RAG_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조 유지) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
EMBED_MODEL = "text-embedding-3-small"
client = OpenAI(api_key=OPENAI_API_KEY)
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def embed_texts(texts):
if not texts:
return np.zeros((0, 1536), dtype="float32")
embs = []
B = 96
for i in range(0, len(texts), B):
batch = texts[i:i+B]
resp = client.embeddings.create(model=EMBED_MODEL, input=batch)
for d in resp.data:
embs.append(np.array(d.embedding, dtype="float32"))
return np.vstack(embs)
def _build_embed_input(u: dict) -> str:
"""
text + summary 를 합쳐 임베딩 입력을 만든다.
- text, summary 중 없는 것은 생략
- 공백 정리
- 최대 길이 제한
"""
sum_ = (u.get("summary") or "").strip()
txt = (u.get("text") or "").strip()
if txt and sum_:
merged = txt + "\n\n요약: " + sum_[:1000]
else:
merged = txt or sum_
merged = " ".join(merged.split())
if not merged:
return ""
if len(merged) > 4000:
merged = merged[:4000]
return merged
def build_faiss_index():
docs = []
metas = []
rag_files = list(RAG_DIR.glob("*_chunks.json"))
if not rag_files:
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
sys.exit(1)
for f in rag_files:
try:
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
except Exception as e:
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
continue
for u in units:
embed_input = _build_embed_input(u)
if not embed_input:
continue
if len(embed_input) < 40:
continue
docs.append(embed_input)
metas.append({
"source": u.get("source", ""),
"chunk": int(u.get("chunk", 0)),
"folder_context": u.get("folder_context", "")
})
if not docs:
log("임베딩할 텍스트가 없습니다.")
sys.exit(1)
log(f"임베딩 대상 텍스트 수: {len(docs)}")
E = embed_texts(docs)
if E.shape[0] != len(docs):
log(f"[WARN] 임베딩 수 불일치: E={E.shape[0]}, docs={len(docs)}")
faiss.normalize_L2(E)
index = faiss.IndexFlatIP(E.shape[1])
index.add(E)
np.save(str(RAG_DIR / "vectors.npy"), E)
(RAG_DIR / "meta.json").write_text(
json.dumps(metas, ensure_ascii=False, indent=2),
encoding="utf-8"
)
faiss.write_index(index, str(RAG_DIR / "faiss.index"))
log(f"FAISS 인덱스 구축 완료: 벡터 수={len(metas)}")
def main():
log("=== FAISS RAG 인덱스 구축 시작 ===")
build_faiss_index()
log("=== FAISS RAG 인덱스 구축 종료 ===")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""
make_corpus_v2.py
기능:
- output/rag/*_chunks.json 에서 모든 청크의 summary를 모아
- AI가 CEL 목적(교육+자사솔루션 홍보)에 맞게 압축 정리
- 중복은 빈도 표시, 희귀하지만 중요한 건 [핵심] 표시
- 결과를 output/context/corpus.txt 로 저장
전제:
- chunk_and_summary.py 실행 후 *_chunks.json 들이 존재해야 한다.
- domain_prompt.txt가 존재해야 한다.
"""
import os
import sys
import json
from pathlib import Path
from datetime import datetime
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
RAG_DIR = OUTPUT_ROOT / "rag"
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 압축 설정 =====
BATCH_SIZE = 80 # 한 번에 처리할 요약 개수
MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결과 글자수
MAX_FINAL_CHARS = 8000 # 최종 corpus 글자수
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 step1을 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_all_summaries() -> list:
"""모든 청크의 summary + 출처 정보 수집"""
summaries = []
rag_files = sorted(RAG_DIR.glob("*_chunks.json"))
if not rag_files:
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
sys.exit(1)
for f in rag_files:
try:
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
except Exception as e:
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
continue
for u in units:
summ = (u.get("summary") or "").strip()
source = (u.get("source") or "").strip()
keywords = (u.get("keywords") or "")
if summ:
# 출처와 키워드 포함
entry = f"[{source}] {summ}"
if keywords:
entry += f" (키워드: {keywords})"
summaries.append(entry)
return summaries
def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str:
"""배치 단위로 요약들을 AI가 압축"""
batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)])
prompt = f"""
아래는 문서에서 추출한 요약 {len(batch)}개이다. (배치 {batch_num}/{total_batches})
[요약 목록]
{batch_text}
다음 기준으로 이 요약들을 압축 정리하라:
1) 중복/유사 내용: 하나로 통합하되, 여러 문서에서 언급되면 "(N회 언급)" 표시
2) domain_prompt에 명시된 핵심 솔루션/시스템: 반드시 보존하고 [솔루션] 표시
3) domain_prompt의 목적에 중요한 내용 우선 보존:
- 해당 분야의 기초 개념
- 기존 방식의 한계점과 문제점
- 새로운 기술/방식의 장점
4) 단순 나열/절차만 있는 내용: 과감히 축약
5) 희귀하지만 핵심적인 인사이트: [핵심] 표시
출력 형식:
- 주제별로 그룹핑
- 각 항목은 1~2문장으로 간결하게
- 전체 {MAX_CHARS_PER_BATCH}자 이내
- 마크다운 없이 순수 텍스트로
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 문서 요약을 주제별로 압축 정리하는 전문가이다."},
{"role": "user", "content": prompt}
]
)
result = resp.choices[0].message.content.strip()
log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)")
return result
except Exception as e:
log(f"[ERROR] 배치 {batch_num} 압축 실패: {e}")
# 실패 시 원본 일부 반환
return "\n".join(batch[:10])
def merge_compressed_parts(domain_prompt: str, parts: list) -> str:
"""배치별 압축 결과를 최종 통합"""
if len(parts) == 1:
return parts[0]
all_parts = "\n\n---\n\n".join([f"[파트 {i+1}]\n{p}" for i, p in enumerate(parts)])
prompt = f"""
아래는 대량의 문서 요약을 배치별로 압축한 결과이다.
이것을 최종 corpus로 통합하라.
[배치별 압축 결과]
{all_parts}
통합 기준:
1) 파트 간 중복 내용 제거 및 통합
2) domain_prompt에 명시된 목적과 흐름에 맞게 재구성
3) [솔루션], [핵심], (N회 언급) 표시는 유지
4) 전체 {MAX_FINAL_CHARS}자 이내
출력: 주제별로 정리된 최종 corpus (마크다운 없이)
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 콘텐츠 기획을 위한 corpus를 설계하는 전문가이다."},
{"role": "user", "content": prompt}
]
)
return resp.choices[0].message.content.strip()
except Exception as e:
log(f"[ERROR] 최종 통합 실패: {e}")
return "\n\n".join(parts)
def main():
log("=" * 60)
log("corpus 생성 시작 (AI 압축 버전)")
log("=" * 60)
# 도메인 프롬프트 로드
domain_prompt = load_domain_prompt()
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
# 모든 요약 수집
summaries = load_all_summaries()
if not summaries:
log("summary가 없습니다. corpus를 생성할 수 없습니다.")
sys.exit(1)
log(f"원본 요약 수집 완료: {len(summaries)}")
# 원본 저장 (백업)
raw_corpus = "\n".join(summaries)
raw_path = CONTEXT_DIR / "corpus_raw.txt"
raw_path.write_text(raw_corpus, encoding="utf-8")
log(f"원본 corpus 백업: {raw_path} ({len(raw_corpus)}자)")
# 배치별 압축
total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE
log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 총 {total_batches}배치)")
compressed_parts = []
for i in range(0, len(summaries), BATCH_SIZE):
batch = summaries[i:i+BATCH_SIZE]
batch_num = (i // BATCH_SIZE) + 1
compressed = compress_batch(domain_prompt, batch, batch_num, total_batches)
compressed_parts.append(compressed)
# 최종 통합
log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파트)")
final_corpus = merge_compressed_parts(domain_prompt, compressed_parts)
# 저장
out_path = CONTEXT_DIR / "corpus.txt"
out_path.write_text(final_corpus, encoding="utf-8")
# 통계
log("\n" + "=" * 60)
log("corpus 생성 완료!")
log("=" * 60)
log(f"원본 요약: {len(summaries)}개 ({len(raw_corpus)}자)")
log(f"압축 corpus: {len(final_corpus)}")
log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%")
log(f"\n저장 위치:")
log(f" - 원본: {raw_path}")
log(f" - 압축: {out_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,504 @@
# -*- coding: utf-8 -*-
"""
make_outline.py
기능:
- output_context/context/domain_prompt.txt
- output_context/context/corpus.txt
을 기반으로 목차를 생성하고,
1) outline_issue_report.txt 저장
2) outline_issue_report.html 저장 (테스트.html 레이아웃 기반 표 형태)
"""
import os
import sys
import re
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Tuple
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조 유지) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
RE_KEYWORDS = re.compile(r"(#\S+)")
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$")
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_corpus() -> str:
p = CONTEXT_DIR / "corpus.txt"
if not p.exists():
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
# 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$")
RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$")
def generate_outline(domain_prompt: str, corpus: str) -> str:
sys_msg = {
"role": "system",
"content": (
domain_prompt + "\n\n"
"너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
"주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
),
}
user_msg = {
"role": "user",
"content": f"""
아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
[corpus]
{corpus}
요구 사항:
1) 첫 줄에 보고서 제목 1개를 작성하라.
2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
- 대목차: 1. / 2. / 3. ...
- 중목차: 1.1 / 1.2 / ...
- 소목차: 1.1.1 / 1.1.2 / ...
3) **수량 제약 (중요)**:
- 대목차(1.)는 5~8개로 구성하라.
- **중목차(1.1) 하나당 소목차(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사이로 구성하라.** (절대 1개만 만들지 말 것)
- 소목차(1.1.1) 하나당 '핵심주제(꼭지)'는 반드시 2개에서 3개 사이로 구성하라.
[소목차 작성 형식]
1.1.1 소목차 제목
- 핵심주제 1 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
- 핵심주제 2 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
5) [유형] 분류 가이드:
- [비교형]: 기존 vs DX 방식의 비교표(Table)가 필수적인 경우
- [기술형]: RMSE, GSD, 중복도 등 정밀 수치와 사양 설명이 핵심인 경우
- [절차형]: 단계별 워크플로 및 체크리스트가 중심인 경우
- [인사이트형]: 한계점 분석 및 전문가 제언(☞)이 중심인 경우
6) 집필가이드는 50자 내외로, "어떤 데이터를 검색해서 어떤 표를 그려라"와 같이 구체적으로 지시하라.
7) 대목차는 최대 8개 이내로 구성하라.
"""
}
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[sys_msg, user_msg],
)
return (resp.choices[0].message.content or "").strip()
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()]
if not lines: return "", []
title = lines[0].strip() # 첫 줄은 보고서 제목
rows = []
current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
for ln in lines[1:]:
raw = ln.strip()
# 1. 소목차 헤더(1.1.1 제목) 발견 시
m3_head = RE_L3_HEAD.match(raw)
if m3_head:
num, s_title = m3_head.groups()
current_section = {
"depth": 3,
"num": num,
"title": s_title,
"sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
}
rows.append(current_section)
continue
# 2. 세부 꼭지(- 주제 | #키워드 | [유형] | 가이드) 발견 시
m_topic = RE_L3_TOPIC.match(raw)
if m_topic and current_section:
t_title, kws_raw, t_type, guide = m_topic.groups()
# 키워드 추출 (#키워드 형태)
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
# 현재 소목차(current_section)의 리스트에 추가
current_section["sub_topics"].append({
"topic_title": t_title,
"keywords": kws,
"type": t_type,
"guide": guide
})
continue
# 3. 대목차(1.) 처리
m1 = RE_L1.match(raw)
if m1:
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
# 4. 중목차(1.1) 처리
m2 = RE_L2.match(raw)
if m2:
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
return title, rows
def html_escape(s: str) -> str:
s = s or ""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;"))
def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]:
"""
A4 1장에 표가 길어지면 넘치므로, 단순 행 개수로 페이지 분할한다.
"""
out = []
cur = []
for r in rows:
cur.append(r)
if len(cur) >= max_rows_per_page:
out.append(cur)
cur = []
if cur:
out.append(cur)
return out
def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
"""
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
"""
head = """
<table>
<thead>
<tr>
<th>구분</th>
<th>번호</th>
<th>제목</th>
<th>키워드</th>
</tr>
</thead>
<tbody>
"""
body_parts = []
for r in rows:
depth = r["depth"]
num = html_escape(r["num"])
title = html_escape(r["title"])
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
kw = html_escape(kw)
if depth == 1:
body_parts.append(
f"""
<tr>
<td class="group-cell">대목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
elif depth == 2:
body_parts.append(
f"""
<tr>
<td class="group-cell">중목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
else:
body_parts.append(
f"""
<tr>
<td class="group-cell">소목차</td>
<td>{num}</td>
<td>{title}</td>
<td>{kw}</td>
</tr>
"""
)
tail = """
</tbody>
</table>
"""
return head + "\n".join(body_parts) + tail
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str:
"""
테스트.html 레이아웃 구조를 그대로 따라 A4 시트 형태로 HTML 생성
"""
css = r"""
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary-blue: #3057B9;
--gray-light: #F2F2F2;
--gray-medium: #E6E6E6;
--gray-dark: #666666;
--border-light: #DDDDDD;
--text-black: #000000;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #f0f0f0;
color: var(--text-black);
line-height: 1.35;
display: flex;
justify-content: center;
padding: 10px 0;
}
.sheet {
background-color: white;
width: 210mm;
height: 297mm;
padding: 20mm 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: 12px;
}
@media print {
body { background: none; padding: 0; }
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
font-size: 8.5pt;
color: var(--gray-dark);
}
.header-title {
font-size: 24pt;
font-weight: 900;
margin-bottom: 8px;
letter-spacing: -1.5px;
color: #111;
}
.title-divider {
height: 4px;
background-color: var(--primary-blue);
width: 100%;
margin-bottom: 20px;
}
.lead-box {
background-color: var(--gray-light);
padding: 18px 20px;
margin-bottom: 5px;
border-radius: 2px;
text-align: center;
}
.lead-box div {
font-size: 13pt;
font-weight: 700;
color: var(--primary-blue);
letter-spacing: -0.5px;
}
.lead-notes {
font-size: 8.5pt;
color: #777;
margin-bottom: 20px;
padding-left: 5px;
text-align: right;
}
.body-content { flex: 1; }
.section { margin-bottom: 22px; }
.section-title {
font-size: 13pt;
font-weight: 700;
display: flex;
align-items: center;
margin-bottom: 10px;
color: #111;
}
.section-title::before {
content: "";
display: inline-block;
width: 10px;
height: 10px;
background-color: #999;
margin-right: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 9.5pt;
border-top: 1.5px solid #333;
}
th {
background-color: var(--gray-medium);
font-weight: 700;
padding: 10px;
border: 1px solid var(--border-light);
}
td {
padding: 10px;
border: 1px solid var(--border-light);
vertical-align: middle;
}
.group-cell {
background-color: #F9F9F9;
font-weight: 700;
width: 16%;
text-align: center;
color: var(--primary-blue);
white-space: nowrap;
}
.page-footer {
margin-top: 15px;
padding-top: 10px;
display: flex;
justify-content: space-between;
font-size: 8.5pt;
color: var(--gray-dark);
border-top: 1px solid #EEE;
}
.footer-page { flex: 1; text-align: center; }
"""
pages = chunk_rows(rows, max_rows_per_page=26)
html_pages = []
total_pages = len(pages) if pages else 1
for i, page_rows in enumerate(pages, start=1):
table_html = build_outline_table_html(page_rows)
html_pages.append(f"""
<div class="sheet">
<header class="page-header">
<div class="header-left">
보고서: 목차 자동 생성 결과
</div>
<div class="header-right">
작성일자: {datetime.now().strftime("%Y. %m. %d.")}
</div>
</header>
<div class="title-block">
<h1 class="header-title">{html_escape(report_title)}</h1>
<div class="title-divider"></div>
</div>
<div class="body-content">
<div class="lead-box">
<div>확정 목차 표 형태 정리본</div>
</div>
<div class="lead-notes">목차는 outline_issue_report.txt를 기반으로 표로 재구성됨</div>
<div class="section">
<div class="section-title">목차</div>
{table_html}
</div>
</div>
<footer class="page-footer">
<div class="footer-slogan">Word Style v2 Outline</div>
<div class="footer-page">- {i} / {total_pages} -</div>
<div class="footer-info">outline_issue_report.html</div>
</footer>
</div>
""")
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{html_escape(report_title)} - Outline</title>
<style>{css}</style>
</head>
<body>
{''.join(html_pages)}
</body>
</html>
"""
def main():
log("=== 목차 생성 시작 ===")
domain_prompt = load_domain_prompt()
corpus = load_corpus()
outline = generate_outline(domain_prompt, corpus)
# TXT 저장 유지
out_txt = CONTEXT_DIR / "outline_issue_report.txt"
out_txt.write_text(outline, encoding="utf-8")
log(f"목차 TXT 저장 완료: {out_txt}")
# HTML 추가 저장
title, rows = parse_outline(outline)
out_html = CONTEXT_DIR / "outline_issue_report.html"
out_html.write_text(build_outline_html(title, rows), encoding="utf-8")
log(f"목차 HTML 저장 완료: {out_html}")
log("=== 목차 생성 종료 ===")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,935 @@
"""
HTML 스타일 분석기 v3.0
HTML 요소를 분석하여 역할(Role)을 자동 분류
✅ v3.0 변경사항:
- 글벗 HTML 구조 완벽 지원 (.sheet, .body-content)
- 머리말/꼬리말/페이지번호 제거
- 강력한 중복 콘텐츠 필터링
- 제목 계층 구조 정확한 인식
"""
import re
from bs4 import BeautifulSoup, Tag, NavigableString
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple, Set
from enum import Enum
class DocumentSection(Enum):
"""문서 섹션 유형"""
COVER = "cover" # 표지
TOC = "toc" # 목차
CONTENT = "content" # 본문
@dataclass
class StyledElement:
"""스타일이 지정된 요소"""
role: str # 역할 (H1, BODY, TH 등)
text: str # 텍스트 내용
tag: str # 원본 HTML 태그
html: str # 원본 HTML
section: str # 섹션 (cover, toc, content)
attributes: Dict # 추가 속성 (이미지 src 등)
def __repr__(self):
preview = self.text[:30] + "..." if len(self.text) > 30 else self.text
return f"<{self.role}> {preview}"
class StyleAnalyzer:
"""HTML 문서를 분석하여 역할 분류"""
# 번호 패턴 정의
PATTERNS = {
# 장 번호: "제1장", "제2장"
"chapter": re.compile(r'^제\s*\d+\s*장'),
# 1단계 제목: "1 ", "2 " (숫자+공백, 점 없음)
"h1_num": re.compile(r'^(\d+)\s+[가-힣]'),
# 대항목: "1.", "2."
"h2_num": re.compile(r'^(\d+)\.\s'),
# 중항목: "1.1 ", "1.2 "
"h3_num": re.compile(r'^(\d+)\.(\d+)\s'),
# 소항목: "1.1.1"
"h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'),
# 세부: "1)", "2)"
"h5_paren": re.compile(r'^(\d+)\)\s*'),
# 세세부: "(1)", "(2)"
"h6_paren": re.compile(r'^\((\d+)\)\s*'),
# 가나다: "가.", "나."
"h4_korean": re.compile(r'^[가-하]\.\s'),
# 가나다 괄호: "가)", "나)"
"h5_korean": re.compile(r'^[가-하]\)\s'),
# 원문자: "①", "②"
"h6_circle": re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]'),
# 목록: "•", "-", "○"
"list_bullet": re.compile(r'^[•\-○]\s'),
# 페이지 번호 패턴: "- 1 -", "- 12 -"
"page_number": re.compile(r'^-\s*\d+\s*-$'),
# 꼬리말 패턴: "문서제목- 1 -"
"footer_pattern": re.compile(r'.+[-]\s*\d+\s*[-]$'),
}
# 제거할 텍스트 패턴들
REMOVE_PATTERNS = [
re.compile(r'^-\s*\d+\s*-$'), # "- 1 -"
re.compile(r'[-]\s*\d+\s*[-]\s*$'), # "문서제목- 1 -"
re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (이미지 크기)
re.compile(r'^\[이미지 없음:.*\]$'), # "[이미지 없음: xxx]"
re.compile(r'^\[그림\s*\d+-\d+\]$'), # "[그림 1-1]"
]
def __init__(self):
self.elements: List[StyledElement] = []
self.current_section = DocumentSection.CONTENT
self.seen_texts: Set[str] = set() # 중복 방지용
self.document_title = "" # 문서 제목 (꼬리말 제거용)
def analyze(self, html: str) -> List[StyledElement]:
"""HTML 문서 분석하여 역할 분류된 요소 리스트 반환"""
soup = BeautifulSoup(html, 'html.parser')
self.elements = []
self.seen_texts = set()
# 1. 전처리: 불필요한 요소 제거
self._preprocess(soup)
# 2. 문서 제목 추출 (꼬리말 패턴 감지용)
self._extract_document_title(soup)
# 3. 섹션 감지 및 순회
self._detect_and_process_sections(soup)
# 4. 후처리: 중복 및 불필요 요소 제거
self._postprocess()
return self.elements
def _preprocess(self, soup: BeautifulSoup):
"""HTML 전처리 - 불필요한 요소 제거"""
print(" 🔧 HTML 전처리 중...")
# 1. 스크립트/스타일 태그 제거
removed_count = 0
for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']):
tag.decompose()
removed_count += 1
if removed_count > 0:
print(f" - script/style 등 {removed_count}개 제거")
# 2. 머리말/꼬리말 영역 제거 (글벗 HTML 구조)
header_footer_count = 0
for selector in ['.page-header', '.page-footer', '.header', '.footer',
'[class*="header"]', '[class*="footer"]',
'.running-header', '.running-footer']:
for elem in soup.select(selector):
# 실제 콘텐츠 헤더가 아닌 페이지 헤더만 제거
text = elem.get_text(strip=True)
if self._is_header_footer_text(text):
elem.decompose()
header_footer_count += 1
if header_footer_count > 0:
print(f" - 머리말/꼬리말 {header_footer_count}개 제거")
# 3. 숨겨진 요소 제거
hidden_count = 0
for elem in soup.select('[style*="display:none"], [style*="display: none"]'):
elem.decompose()
hidden_count += 1
for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'):
elem.decompose()
hidden_count += 1
# 4. #raw-container 외부의 .sheet 제거 (글벗 구조)
raw_container = soup.find(id='raw-container')
if raw_container:
print(" - 글벗 구조 감지: #raw-container 우선 사용")
# raw-container 외부의 모든 .sheet 제거
for sheet in soup.select('.sheet'):
if not self._is_descendant_of(sheet, raw_container):
sheet.decompose()
def _extract_document_title(self, soup: BeautifulSoup):
"""문서 제목 추출 (꼬리말 패턴 감지용)"""
# 표지에서 제목 찾기
cover = soup.find(id='box-cover') or soup.find(class_='box-cover')
if cover:
h1 = cover.find('h1')
if h1:
self.document_title = h1.get_text(strip=True)
print(f" - 문서 제목 감지: {self.document_title[:30]}...")
def _is_header_footer_text(self, text: str) -> bool:
"""머리말/꼬리말 텍스트인지 판단"""
if not text:
return False
# 페이지 번호 패턴
if self.PATTERNS['page_number'].match(text):
return True
# "문서제목- 1 -" 패턴
if self.PATTERNS['footer_pattern'].match(text):
return True
# 문서 제목 + 페이지번호 조합
if self.document_title and self.document_title in text:
if re.search(r'[-]\s*\d+\s*[-]', text):
return True
return False
def _should_skip_text(self, text: str) -> bool:
"""건너뛸 텍스트인지 판단"""
if not text:
return True
# 제거 패턴 체크
for pattern in self.REMOVE_PATTERNS:
if pattern.match(text):
return True
# 머리말/꼬리말 체크
if self._is_header_footer_text(text):
return True
# 문서 제목만 있는 줄 (꼬리말에서 온 것)
if self.document_title and text.strip() == self.document_title:
# 이미 표지에서 처리했으면 스킵
if any(e.role == 'COVER_TITLE' and self.document_title in e.text
for e in self.elements):
return True
return False
def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool:
"""element가 ancestor의 자손인지 확인"""
parent = element.parent
while parent:
if parent == ancestor:
return True
parent = parent.parent
return False
def _detect_and_process_sections(self, soup: BeautifulSoup):
"""섹션 감지 및 처리"""
# 글벗 구조 (#raw-container) 우선 처리
raw = soup.find(id='raw-container')
if raw:
self._process_geulbeot_structure(raw)
return
# .sheet 구조 처리 (렌더링된 페이지)
sheets = soup.select('.sheet')
if sheets:
self._process_sheet_structure(sheets)
return
# 일반 HTML 구조 처리
self._process_generic_html(soup)
def _process_geulbeot_structure(self, raw: Tag):
"""글벗 HTML #raw-container 구조 처리"""
print(" 📄 글벗 #raw-container 구조 처리 중...")
# 표지
cover = raw.find(id='box-cover')
if cover:
print(" - 표지 섹션")
self.current_section = DocumentSection.COVER
self._process_cover(cover)
# 목차
toc = raw.find(id='box-toc')
if toc:
print(" - 목차 섹션")
self.current_section = DocumentSection.TOC
self._process_toc(toc)
# 요약
summary = raw.find(id='box-summary')
if summary:
print(" - 요약 섹션")
self.current_section = DocumentSection.CONTENT
self._process_content_element(summary)
# 본문
content = raw.find(id='box-content')
if content:
print(" - 본문 섹션")
self.current_section = DocumentSection.CONTENT
self._process_content_element(content)
def _process_sheet_structure(self, sheets: List[Tag]):
"""글벗 .sheet 페이지 구조 처리"""
print(f" 📄 .sheet 페이지 구조 처리 중... ({len(sheets)}페이지)")
for i, sheet in enumerate(sheets):
# 페이지 내 body-content만 추출
body_content = sheet.select_one('.body-content')
if body_content:
self._process_content_element(body_content)
else:
# body-content가 없으면 머리말/꼬리말 제외하고 처리
for child in sheet.children:
if isinstance(child, Tag):
classes = child.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 머리말/꼬리말 스킵
if any(x in class_str.lower() for x in ['header', 'footer']):
continue
self._process_content_element(child)
def _process_generic_html(self, soup: BeautifulSoup):
"""일반 HTML 구조 처리"""
print(" 📄 일반 HTML 구조 처리 중...")
# 표지
cover = soup.find(class_=re.compile(r'cover|title-page|box-cover'))
if cover:
self.current_section = DocumentSection.COVER
self._process_cover(cover)
# 목차
toc = soup.find(class_=re.compile(r'toc|table-of-contents'))
if toc:
self.current_section = DocumentSection.TOC
self._process_toc(toc)
# 본문
self.current_section = DocumentSection.CONTENT
main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup
for child in main_content.children:
if isinstance(child, Tag):
self._process_content_element(child)
def _process_cover(self, cover: Tag):
"""표지 처리"""
# H1 = 제목
h1 = cover.find('h1')
if h1:
text = h1.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_TITLE",
text=text,
tag="h1",
html=str(h1)[:200],
section="cover",
attributes={}
))
# H2 = 부제목
h2 = cover.find('h2')
if h2:
text = h2.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_SUBTITLE",
text=text,
tag="h2",
html=str(h2)[:200],
section="cover",
attributes={}
))
# P = 정보
for p in cover.find_all('p'):
text = p.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_INFO",
text=text,
tag="p",
html=str(p)[:200],
section="cover",
attributes={}
))
def _process_toc(self, toc: Tag):
"""목차 처리"""
# UL/OL 기반 목차
for li in toc.find_all('li'):
text = li.get_text(strip=True)
if not text or self._is_duplicate(text):
continue
classes = li.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 레벨 판단 (구체적 → 일반 순서!)
if 'lvl-1' in class_str or 'toc-lvl-1' in class_str:
role = "TOC_H1"
elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str:
role = "TOC_H2"
elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str:
role = "TOC_H3"
elif self.PATTERNS['h4_num'].match(text): # 1.1.1 먼저!
role = "TOC_H3"
elif self.PATTERNS['h3_num'].match(text): # 1.1 그다음
role = "TOC_H2"
elif self.PATTERNS['h2_num'].match(text): # 1. 그다음
role = "TOC_H1"
else:
role = "TOC_H1"
self.elements.append(StyledElement(
role=role,
text=text,
tag="li",
html=str(li)[:200],
section="toc",
attributes={}
))
def _process_content_element(self, element: Tag):
"""본문 요소 재귀 처리"""
if not isinstance(element, Tag):
return
tag_name = element.name.lower() if element.name else ""
classes = element.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 머리말/꼬리말 클래스 스킵
if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']):
return
# 테이블 특수 처리
if tag_name == 'table':
self._process_table(element)
return
# 그림 특수 처리
if tag_name in ['figure', 'img']:
self._process_figure(element)
return
# 텍스트 추출
text = self._get_direct_text(element)
if text:
# 건너뛸 텍스트 체크
if self._should_skip_text(text):
pass # 자식은 계속 처리
elif not self._is_duplicate(text):
role = self._classify_role(element, tag_name, classes, text)
if role:
self.elements.append(StyledElement(
role=role,
text=text,
tag=tag_name,
html=str(element)[:200],
section=self.current_section.value,
attributes=dict(element.attrs) if element.attrs else {}
))
# 자식 요소 재귀 처리 (컨테이너 태그)
if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body',
'ul', 'ol', 'dl', 'blockquote']:
for child in element.children:
if isinstance(child, Tag):
self._process_content_element(child)
def _get_direct_text(self, element: Tag) -> str:
"""요소의 직접 텍스트만 추출 (자식 컨테이너 제외)"""
# 제목 태그는 전체 텍스트
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']:
return element.get_text(strip=True)
# 컨테이너 태그는 직접 텍스트만
texts = []
for child in element.children:
if isinstance(child, NavigableString):
t = str(child).strip()
if t:
texts.append(t)
return ' '.join(texts)
def _is_duplicate(self, text: str) -> bool:
"""중복 텍스트인지 확인"""
if not text:
return True
# 정규화
normalized = re.sub(r'\s+', ' ', text.strip())
# 짧은 텍스트는 중복 허용 (번호 등)
if len(normalized) < 10:
return False
# 첫 50자로 체크
key = normalized[:50]
if key in self.seen_texts:
return True
self.seen_texts.add(key)
return False
def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]:
"""요소의 역할 분류
⚠️ 중요: 패턴 매칭은 반드시 구체적인 것 → 일반적인 것 순서로!
1.1.1 → 1.1 → 1. → 1
(1) → 1)
가) → 가.
"""
class_str = ' '.join(classes) if classes else ''
# ============ 제목 태그 (HTML 태그 우선) ============
if tag == 'h1':
return "H1"
if tag == 'h2':
return "H2"
if tag == 'h3':
return "H3"
if tag == 'h4':
return "H4"
if tag == 'h5':
return "H5"
if tag == 'h6':
return "H6"
# ============ 본문 (p, div 등) - 번호 패턴으로 분류 ============
if tag in ['p', 'div', 'span']:
# ------ 숫자.숫자 패턴 (구체적 → 일반 순서!) ------
# "1.1.1" 패턴 (가장 구체적 - 먼저 체크!)
if self.PATTERNS['h4_num'].match(text):
if len(text) < 100:
return "H3"
return "BODY"
# "1.1 " 패턴
if self.PATTERNS['h3_num'].match(text):
if len(text) < 100:
return "H2"
return "BODY"
# "1." 패턴
if self.PATTERNS['h2_num'].match(text):
if len(text) < 100:
return "H1"
return "BODY"
# "1 가나다..." 패턴 (숫자+공백+한글)
if self.PATTERNS['h1_num'].match(text):
return "H1"
# ------ 괄호 패턴 (구체적 → 일반 순서!) ------
# "(1)" 패턴 (괄호로 감싼 게 더 구체적 - 먼저 체크!)
if self.PATTERNS['h6_paren'].match(text):
if element.find('strong') or len(text) < 80:
return "H5"
return "BODY"
# "1)" 패턴
if self.PATTERNS['h5_paren'].match(text):
if element.find('strong') or len(text) < 80:
return "H4"
return "BODY"
# ------ 한글 패턴 (구체적 → 일반 순서!) ------
# "가)" 패턴 (괄호가 더 구체적 - 먼저 체크!)
if self.PATTERNS['h5_korean'].match(text):
return "H5"
# "가." 패턴
if self.PATTERNS['h4_korean'].match(text):
return "H4"
# ------ 특수 기호 패턴 ------
# "①②③" 패턴
if self.PATTERNS['h6_circle'].match(text):
return "H6"
# ------ 기타 ------
# 강조 박스
if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']):
return "HIGHLIGHT_BOX"
# 일반 본문
return "BODY"
# ============ 목록 ============
if tag == 'li':
return "LIST_ITEM"
# ============ 정의 목록 ============
if tag == 'dt':
return "H5"
if tag == 'dd':
return "BODY"
return "BODY"
def _process_table(self, table: Tag):
"""테이블 처리 - 구조 데이터 포함"""
# 캡션
caption = table.find('caption')
caption_text = ""
if caption:
caption_text = caption.get_text(strip=True)
if caption_text and not self._is_duplicate(caption_text):
self.elements.append(StyledElement(
role="TABLE_CAPTION",
text=caption_text,
tag="caption",
html=str(caption)[:100],
section=self.current_section.value,
attributes={}
))
# 🆕 표 구조 데이터 수집
table_data = {'rows': [], 'caption': caption_text}
for tr in table.find_all('tr'):
row = []
for cell in tr.find_all(['th', 'td']):
cell_info = {
'text': cell.get_text(strip=True),
'is_header': cell.name == 'th',
'colspan': int(cell.get('colspan', 1)),
'rowspan': int(cell.get('rowspan', 1)),
'bg_color': self._extract_bg_color(cell),
}
row.append(cell_info)
if row:
table_data['rows'].append(row)
# 🆕 TABLE 요소로 추가 (개별 TH/TD 대신)
if table_data['rows']:
self.elements.append(StyledElement(
role="TABLE",
text=f"[표: {len(table_data['rows'])}행]",
tag="table",
html=str(table)[:200],
section=self.current_section.value,
attributes={'table_data': table_data}
))
def _extract_bg_color(self, element: Tag) -> str:
"""요소에서 배경색 추출"""
style = element.get('style', '')
# background-color 추출
match = re.search(r'background-color:\s*([^;]+)', style)
if match:
return self._normalize_color(match.group(1))
# bgcolor 속성
bgcolor = element.get('bgcolor', '')
if bgcolor:
return self._normalize_color(bgcolor)
return ''
def _process_figure(self, element: Tag):
"""그림 처리"""
img = element.find('img') if element.name == 'figure' else element
if img and img.name == 'img':
src = img.get('src', '')
alt = img.get('alt', '')
if src: # src가 있을 때만 추가
self.elements.append(StyledElement(
role="FIGURE",
text=alt or "이미지",
tag="img",
html=str(img)[:100],
section=self.current_section.value,
attributes={"src": src, "alt": alt}
))
# 캡션
if element.name == 'figure':
figcaption = element.find('figcaption')
if figcaption:
text = figcaption.get_text(strip=True)
if text and not self._should_skip_text(text):
self.elements.append(StyledElement(
role="FIGURE_CAPTION",
text=text,
tag="figcaption",
html=str(figcaption)[:100],
section=self.current_section.value,
attributes={}
))
def _postprocess(self):
"""후처리: 불필요 요소 제거"""
print(f" 🧹 후처리 중... (처리 전: {len(self.elements)}개)")
filtered = []
for elem in self.elements:
# 빈 텍스트 제거
if not elem.text or not elem.text.strip():
continue
# 머리말/꼬리말 텍스트 제거
if self._is_header_footer_text(elem.text):
continue
# 제거 패턴 체크
skip = False
for pattern in self.REMOVE_PATTERNS:
if pattern.match(elem.text.strip()):
skip = True
break
if not skip:
filtered.append(elem)
self.elements = filtered
print(f" - 처리 후: {len(self.elements)}")
def get_role_summary(self) -> Dict[str, int]:
"""역할별 요소 수 요약"""
summary = {}
for elem in self.elements:
summary[elem.role] = summary.get(elem.role, 0) + 1
return dict(sorted(summary.items()))
def extract_css_styles(self, html: str) -> Dict[str, Dict]:
"""
HTML에서 역할별 CSS 스타일 추출
Returns: {역할: {font_size, color, bold, ...}}
"""
soup = BeautifulSoup(html, 'html.parser')
role_styles = {}
# <style> 태그에서 CSS 파싱
style_tag = soup.find('style')
if style_tag:
css_text = style_tag.string or ''
role_styles.update(self._parse_css_rules(css_text))
# 인라인 스타일에서 추출 (요소별)
for elem in self.elements:
if elem.role not in role_styles:
role_styles[elem.role] = self._extract_inline_style(elem.html)
return role_styles
def _parse_css_rules(self, css_text: str) -> Dict[str, Dict]:
"""CSS 텍스트에서 규칙 파싱"""
import re
rules = {}
# h1, h2, .section-title 등의 패턴
pattern = r'([^{]+)\{([^}]+)\}'
for match in re.finditer(pattern, css_text):
selector = match.group(1).strip()
properties = match.group(2)
style = {}
for prop in properties.split(';'):
if ':' in prop:
key, value = prop.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'font-size':
style['font_size'] = self._parse_font_size(value)
elif key == 'color':
style['color'] = self._normalize_color(value)
elif key == 'font-weight':
style['bold'] = value in ['bold', '700', '800', '900']
elif key == 'text-align':
style['align'] = value
# 셀렉터 → 역할 매핑
role = self._selector_to_role(selector)
if role:
rules[role] = style
return rules
def _selector_to_role(self, selector: str) -> str:
"""CSS 셀렉터 → 역할 매핑"""
selector = selector.lower().strip()
mapping = {
'h1': 'H1', 'h2': 'H2', 'h3': 'H3', 'h4': 'H4',
'.cover-title': 'COVER_TITLE',
'.section-title': 'H1',
'th': 'TH', 'td': 'TD',
'p': 'BODY',
}
for key, role in mapping.items():
if key in selector:
return role
return None
def _parse_font_size(self, value: str) -> float:
"""폰트 크기 파싱 (pt 단위로 변환)"""
import re
match = re.search(r'([\d.]+)(pt|px|em|rem)?', value)
if match:
size = float(match.group(1))
unit = match.group(2) or 'pt'
if unit == 'px':
size = size * 0.75 # px → pt
elif unit in ['em', 'rem']:
size = size * 11 # 기본 11pt 기준
return size
return 11.0
def _normalize_color(self, value: str) -> str:
"""색상값 정규화 (#RRGGBB)"""
import re
value = value.strip().lower()
# 이미 #rrggbb 형식
if re.match(r'^#[0-9a-f]{6}$', value):
return value.upper()
# #rgb → #rrggbb
if re.match(r'^#[0-9a-f]{3}$', value):
return f'#{value[1]*2}{value[2]*2}{value[3]*2}'.upper()
# rgb(r, g, b)
match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', value)
if match:
r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
return f'#{r:02X}{g:02X}{b:02X}'
# 색상 이름
color_names = {
'black': '#000000', 'white': '#FFFFFF',
'red': '#FF0000', 'green': '#008000', 'blue': '#0000FF',
'navy': '#1A365D',
}
return color_names.get(value, '#000000')
def _extract_inline_style(self, html: str) -> Dict:
"""HTML 요소에서 인라인 스타일 추출"""
style = {}
# style 속성 찾기
match = re.search(r'style\s*=\s*["\']([^"\']+)["\']', html)
if match:
style_str = match.group(1)
for prop in style_str.split(';'):
if ':' in prop:
key, value = prop.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'font-size':
style['font_size'] = self._parse_font_size(value)
elif key == 'color':
style['color'] = self._normalize_color(value)
elif key == 'font-weight':
style['bold'] = value in ['bold', '700', '800', '900']
elif key == 'text-align':
style['align'] = value
elif key == 'background-color':
style['bg_color'] = self._normalize_color(value)
return style
def _extract_bg_color(self, element) -> str:
"""요소에서 배경색 추출"""
if not hasattr(element, 'get'):
return ''
style = element.get('style', '')
# background-color 추출
match = re.search(r'background-color:\s*([^;]+)', style)
if match:
return self._normalize_color(match.group(1))
# bgcolor 속성
bgcolor = element.get('bgcolor', '')
if bgcolor:
return self._normalize_color(bgcolor)
return ''
def export_for_hwp(self) -> List[Dict]:
"""HWP 변환용 데이터 내보내기"""
return [
{
"role": e.role,
"text": e.text,
"tag": e.tag,
"section": e.section,
"attributes": e.attributes
}
for e in self.elements
]
if __name__ == "__main__":
# 테스트
test_html = """
<html>
<head>
<script>var x = 1;</script>
<style>.test { color: red; }</style>
</head>
<body>
<div class="sheet">
<div class="page-header">건설·토목 측량 DX 실무지침</div>
<div class="body-content">
<h1>1 DX 개요와 기본 개념</h1>
<h2>1.1 측량 DX 프레임</h2>
<h3>1.1.1 측량 DX 발전 단계</h3>
<p>1) <strong>Digitization 정의</strong></p>
<p>본문 내용입니다. 이것은 충분히 긴 텍스트로 본문으로 인식되어야 합니다.</p>
<p>(1) 단계별 정의 및 진화</p>
<p>측량 기술의 발전은 장비의 변화와 성과물의 차원에 따라 구분된다.</p>
</div>
<div class="page-footer">건설·토목 측량 DX 실무지침- 1 -</div>
</div>
<div class="sheet">
<div class="page-header">건설·토목 측량 DX 실무지침</div>
<div class="body-content">
<p>① 첫 번째 항목</p>
<table>
<caption>표 1. 데이터 비교</caption>
<tr><th>구분</th><th>내용</th></tr>
<tr><td>항목1</td><td>설명1</td></tr>
</table>
</div>
<div class="page-footer">건설·토목 측량 DX 실무지침- 2 -</div>
</div>
</body>
</html>
"""
analyzer = StyleAnalyzer()
elements = analyzer.analyze(test_html)
print("\n" + "="*60)
print("분석 결과")
print("="*60)
for elem in elements:
print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}")
print("\n" + "="*60)
print("역할 요약")
print("="*60)
for role, count in analyzer.get_role_summary().items():
print(f" {role}: {count}")

View File

View File

@@ -0,0 +1,769 @@
# HWP/HWPX ↔ HTML/CSS 도메인 가이드
> **목적**: HWPX에서 문서 유형·스타일·템플릿을 추출하거나, HTML → HWPX → HWP 변환 시
> 하드코딩 없이 이 가이드를 참조하여 정확한 매핑을 수행한다.
> **출처**: 한글과컴퓨터 공식 "글 문서 파일 구조 5.0" (revision 1.3, 2018-11-08)
> **범위**: HWP 5.0 바이너리 스펙의 개념 체계 + HWPX XML 태그 + HTML/CSS 매핑
---
## 0. 문서 형식 관계
```
HWP (바이너리) HWPX (XML) HTML/CSS
───────────────── ───────────────────── ─────────────────
Compound File ZIP Archive 단일 HTML 파일
├─ FileHeader ├─ META-INF/ ├─ <head>
├─ DocInfo │ └─ manifest.xml │ ├─ <meta>
│ (글꼴, 스타일, ├─ Contents/ │ └─ <style>
│ 테두리/배경, │ ├─ header.xml └─ <body>
│ 글자모양 등) │ │ (DocInfo 대응) ├─ 헤더 영역
├─ BodyText/ │ ├─ section0.xml │ ├─ 본문
│ └─ Section0 │ │ (본문 대응) │ └─ 푸터 영역
├─ BinData/ │ └─ section1.xml └─ @page CSS
│ └─ 이미지 등 ├─ BinData/
└─ PrvImage │ └─ 이미지 파일
└─ version.xml
```
**핵심**: HWP 바이너리의 레코드 구조와 HWPX XML의 엘리먼트는 1:1 대응한다.
이 가이드는 두 형식의 공통 개념 체계를 기준으로, CSS 변환까지 연결한다.
---
## 1. 단위 체계
### 1.1 HWPUNIT (글 내부 단위)
HWP는 1/7200 인치를 기본 단위로 사용한다.
| 변환 대상 | 공식 | 예시 |
|-----------|------|------|
| HWPUNIT → mm | `hwpunit / 7200 * 25.4` | 7200 → 25.4mm (= 1인치) |
| HWPUNIT → pt | `hwpunit / 7200 * 72` | 7200 → 72pt |
| HWPUNIT → px (96dpi) | `hwpunit / 7200 * 96` | 7200 → 96px |
| mm → HWPUNIT | `mm / 25.4 * 7200` | 25.4mm → 7200 |
| pt → HWPUNIT | `pt / 72 * 7200` | 10pt → 1000 |
```python
def hwpunit_to_mm(hwpunit): return hwpunit / 7200 * 25.4
def hwpunit_to_pt(hwpunit): return hwpunit / 7200 * 72
def hwpunit_to_px(hwpunit): return hwpunit / 7200 * 96
def mm_to_hwpunit(mm): return mm / 25.4 * 7200
```
### 1.2 글자 크기 (CharShape)
HWP의 글자 크기는 HWPUNIT 단위이지만 100배 스케일이 적용되어 있다.
| HWP 값 | 실제 크기 | CSS |
|--------|----------|-----|
| 1000 | 10pt | `font-size: 10pt` |
| 1200 | 12pt | `font-size: 12pt` |
| 2400 | 24pt | `font-size: 24pt` |
```python
def charsize_to_pt(hwp_size): return hwp_size / 100 # 1000 → 10pt
```
### 1.3 COLORREF (색상)
HWP는 0x00BBGGRR 형식(리틀 엔디안 BGR). CSS는 #RRGGBB.
| HWP COLORREF | 분해 | CSS |
|-------------|------|-----|
| 0x00000000 | R=0, G=0, B=0 | `#000000` (검정) |
| 0x00FF0000 | R=0, G=0, B=255 | `#0000ff` (파랑) |
| 0x0000FF00 | R=0, G=255, B=0 | `#00ff00` (초록) |
| 0x000000FF | R=255, G=0, B=0 | `#ff0000` (빨강) |
| 0x00FFFFFF | R=255, G=255, B=255 | `#ffffff` (흰색) |
```python
def colorref_to_css(colorref):
r = colorref & 0xFF
g = (colorref >> 8) & 0xFF
b = (colorref >> 16) & 0xFF
return f'#{r:02x}{g:02x}{b:02x}'
```
**HWPX XML에서의 색상**: `#RRGGBB` 형식으로 직접 기록됨 (변환 불필요).
---
## 2. 테두리/배경 (BorderFill)
> HWP: `HWPTAG_BORDER_FILL` (DocInfo 레코드)
> HWPX: `<hh:borderFill>` (header.xml 내)
> 용도: 표 셀, 문단, 쪽 테두리/배경에 공통 적용
### 2.1 테두리선 종류
| HWP 값 | 이름 | HWPX type 속성 | CSS border-style |
|--------|------|---------------|-----------------|
| 0 | 실선 | `SOLID` | `solid` |
| 1 | 긴 점선 | `DASH` | `dashed` |
| 2 | 점선 | `DOT` | `dotted` |
| 3 | -.-.-. | `DASH_DOT` | `dashed` (근사) |
| 4 | -..-.. | `DASH_DOT_DOT` | `dashed` (근사) |
| 5 | 긴 Dash | `LONG_DASH` | `dashed` |
| 6 | 큰 동그라미 | `CIRCLE` | `dotted` (근사) |
| 7 | 2중선 | `DOUBLE` | `double` |
| 8 | 가는선+굵은선 | `THIN_THICK` | `double` (근사) |
| 9 | 굵은선+가는선 | `THICK_THIN` | `double` (근사) |
| 10 | 가는+굵은+가는 | `THIN_THICK_THIN` | `double` (근사) |
| 11 | 물결 | `WAVE` | `solid` (근사) |
| 12 | 물결 2중선 | `DOUBLE_WAVE` | `double` (근사) |
| 13 | 두꺼운 3D | `THICK_3D` | `ridge` |
| 14 | 두꺼운 3D(역) | `THICK_3D_REV` | `groove` |
| 15 | 3D 단선 | `3D` | `outset` |
| 16 | 3D 단선(역) | `3D_REV` | `inset` |
| — | 없음 | `NONE` | `none` |
### 2.2 테두리선 굵기
| HWP 값 | 실제 굵기 | HWPX width 속성 | CSS border-width |
|--------|----------|----------------|-----------------|
| 0 | 0.1 mm | `0.1mm` | `0.1mm``0.4px` |
| 1 | 0.12 mm | `0.12mm` | `0.12mm``0.5px` |
| 2 | 0.15 mm | `0.15mm` | `0.15mm``0.6px` |
| 3 | 0.2 mm | `0.2mm` | `0.2mm``0.8px` |
| 4 | 0.25 mm | `0.25mm` | `0.25mm``1px` |
| 5 | 0.3 mm | `0.3mm` | `0.3mm``1.1px` |
| 6 | 0.4 mm | `0.4mm` | `0.4mm``1.5px` |
| 7 | 0.5 mm | `0.5mm` | `0.5mm``1.9px` |
| 8 | 0.6 mm | `0.6mm` | `0.6mm``2.3px` |
| 9 | 0.7 mm | `0.7mm` | `0.7mm``2.6px` |
| 10 | 1.0 mm | `1.0mm` | `1mm``3.8px` |
| 11 | 1.5 mm | `1.5mm` | `1.5mm``5.7px` |
| 12 | 2.0 mm | `2.0mm` | `2mm``7.6px` |
| 13 | 3.0 mm | `3.0mm` | `3mm``11.3px` |
| 14 | 4.0 mm | `4.0mm` | `4mm``15.1px` |
| 15 | 5.0 mm | `5.0mm` | `5mm``18.9px` |
```python
BORDER_WIDTH_MAP = {
0: 0.1, 1: 0.12, 2: 0.15, 3: 0.2, 4: 0.25, 5: 0.3,
6: 0.4, 7: 0.5, 8: 0.6, 9: 0.7, 10: 1.0, 11: 1.5,
12: 2.0, 13: 3.0, 14: 4.0, 15: 5.0
}
def border_width_to_css(hwp_val):
mm = BORDER_WIDTH_MAP.get(hwp_val, 0.12)
return f'{mm}mm' # 또는 mm * 3.7795px
```
### 2.3 테두리 4방향 순서
| HWP 배열 인덱스 | HWPX 속성 | CSS 대응 |
|:---:|:---:|:---:|
| [0] | `<left>` / `<hh:left>` | `border-left` |
| [1] | `<right>` / `<hh:right>` | `border-right` |
| [2] | `<top>` / `<hh:top>` | `border-top` |
| [3] | `<bottom>` / `<hh:bottom>` | `border-bottom` |
### 2.4 채우기 (Fill) 정보
| 채우기 종류 (type 비트) | HWPX 엘리먼트 | CSS 대응 |
|:---:|:---:|:---:|
| 0x00 — 없음 | (없음) | `background: none` |
| 0x01 — 단색 | `<hh:windowBrush>` 또는 `<hh:colorFill>` | `background-color: #...` |
| 0x02 — 이미지 | `<hh:imgBrush>` | `background-image: url(...)` |
| 0x04 — 그러데이션 | `<hh:gradation>` | `background: linear-gradient(...)` |
**단색 채우기 구조** (가장 빈번):
```xml
<!-- HWPX header.xml -->
<hh:borderFill id="4">
<hh:slash .../>
<hh:backSlash .../>
<hh:left type="SOLID" width="0.12mm" color="#000000"/>
<hh:right type="SOLID" width="0.12mm" color="#000000"/>
<hh:top type="SOLID" width="0.12mm" color="#000000"/>
<hh:bottom type="SOLID" width="0.12mm" color="#000000"/>
<hh:diagonal .../>
<hh:fillBrush>
<hh:windowBrush faceColor="#E8F5E9" hatchColor="none" .../>
</hh:fillBrush>
</hh:borderFill>
```
```css
/* CSS 대응 */
.cell-bf4 {
border-left: 0.12mm solid #000000;
border-right: 0.12mm solid #000000;
border-top: 0.12mm solid #000000;
border-bottom: 0.12mm solid #000000;
background-color: #E8F5E9;
}
```
### 2.5 HWPX borderFill → CSS 변환 함수 (의사 코드)
```python
def borderfill_to_css(bf_element):
"""HWPX <hh:borderFill> 엘리먼트 → CSS 딕셔너리"""
css = {}
for side in ['left', 'right', 'top', 'bottom']:
el = bf_element.find(f'hh:{side}')
if el is None:
css[f'border-{side}'] = 'none'
continue
btype = el.get('type', 'NONE')
width = el.get('width', '0.12mm')
color = el.get('color', '#000000')
if btype == 'NONE':
css[f'border-{side}'] = 'none'
else:
css_style = BORDER_TYPE_MAP.get(btype, 'solid')
css[f'border-{side}'] = f'{width} {css_style} {color}'
# 배경
fill = bf_element.find('.//hh:windowBrush')
if fill is not None:
face = fill.get('faceColor', 'none')
if face and face != 'none':
css['background-color'] = face
return css
```
---
## 3. 글꼴 (FaceName)
> HWP: `HWPTAG_FACE_NAME` (DocInfo)
> HWPX: `<hh:fontface>` → `<hh:font>` (header.xml)
> CSS: `font-family`
### 3.1 언어별 글꼴 시스템
HWP는 한글·영문·한자·일어·기타·기호·사용자 총 7개 언어 슬롯에 각각 다른 글꼴을 지정한다.
| 언어 인덱스 | HWPX lang 속성 | 주요 글꼴 예시 |
|:---:|:---:|:---:|
| 0 | `HANGUL` | 맑은 고딕, 나눔고딕 |
| 1 | `LATIN` | Arial, Times New Roman |
| 2 | `HANJA` | (한글 글꼴 공유) |
| 3 | `JAPANESE` | MS Mincho |
| 4 | `OTHER` | — |
| 5 | `SYMBOL` | Symbol, Wingdings |
| 6 | `USER` | — |
**CSS 매핑**: 일반적으로 한글(0)과 영문(1) 글꼴을 `font-family` 스택으로 결합.
```css
/* HWPX: hangul="맑은 고딕" latin="Arial" */
font-family: "맑은 고딕", Arial, sans-serif;
```
### 3.2 글꼴 관련 HWPX 구조
```xml
<!-- header.xml -->
<hh:fontfaces>
<hh:fontface lang="HANGUL">
<hh:font face="맑은 고딕" type="TTF" id="0"/>
</hh:fontface>
<hh:fontface lang="LATIN">
<hh:font face="Arial" type="TTF" id="0"/>
</hh:fontface>
...
</hh:fontfaces>
```
---
## 4. 글자 모양 (CharShape)
> HWP: `HWPTAG_CHAR_SHAPE` (DocInfo, 72바이트)
> HWPX: `<hh:charPr>` (header.xml charProperties 내)
> CSS: font-*, color, text-decoration 등
### 4.1 주요 속성 매핑
| HWP 필드 | HWPX 속성 | CSS 속성 | 비고 |
|----------|----------|---------|------|
| 글꼴 ID [7] | `fontRef` | `font-family` | 언어별 참조 |
| 장평 [7] | `ratio` | `font-stretch` | 50%~200% |
| 자간 [7] | `spacing` | `letter-spacing` | -50%~50%, pt 변환 필요 |
| 기준 크기 | `height` | `font-size` | 값/100 = pt |
| 글자 색 | `color` 속성 | `color` | COLORREF → #RRGGBB |
| 밑줄 색 | `underline color` | `text-decoration-color` | |
| 진하게(bit 1) | `bold="true"` | `font-weight: bold` | |
| 기울임(bit 0) | `italic="true"` | `font-style: italic` | |
| 밑줄(bit 2-3) | `underline type` | `text-decoration: underline` | |
| 취소선(bit 18-20) | `strikeout type` | `text-decoration: line-through` | |
| 위첨자(bit 15) | `supscript` | `vertical-align: super; font-size: 70%` | |
| 아래첨자(bit 16) | `subscript` | `vertical-align: sub; font-size: 70%` | |
### 4.2 HWPX charPr 구조 예시
```xml
<hh:charPr id="1" height="1000" bold="false" italic="false"
underline="NONE" strikeout="NONE" color="#000000">
<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0"
other="0" symbol="0" user="0"/>
<hh:ratio hangul="100" latin="100" .../>
<hh:spacing hangul="0" latin="0" .../>
<hh:relSz hangul="100" latin="100" .../>
<hh:offset hangul="0" latin="0" .../>
</hh:charPr>
```
---
## 5. 문단 모양 (ParaShape)
> HWP: `HWPTAG_PARA_SHAPE` (DocInfo, 54바이트)
> HWPX: `<hh:paraPr>` (header.xml paraProperties 내)
> CSS: text-align, margin, line-height, text-indent 등
### 5.1 정렬 방식
| HWP 값 (bit 2-4) | HWPX 속성값 | CSS text-align |
|:---:|:---:|:---:|
| 0 | `JUSTIFY` | `justify` |
| 1 | `LEFT` | `left` |
| 2 | `RIGHT` | `right` |
| 3 | `CENTER` | `center` |
| 4 | `DISTRIBUTE` | `justify` (근사) |
| 5 | `DISTRIBUTE_SPACE` | `justify` (근사) |
### 5.2 줄 간격 종류
| HWP 값 | HWPX 속성값 | CSS line-height | 비고 |
|:---:|:---:|:---:|:---:|
| 0 | `PERCENT` | `160%` (예) | 글자 크기 기준 % |
| 1 | `FIXED` | `24pt` (예) | 고정 pt |
| 2 | `BETWEEN_LINES` | — | 여백만 지정 |
| 3 | `AT_LEAST` | — | 최소값 |
### 5.3 주요 속성 매핑
| HWP 필드 | HWPX 속성 | CSS 속성 | 단위 |
|----------|----------|---------|------|
| 왼쪽 여백 | `margin left` | `margin-left` / `padding-left` | HWPUNIT → mm |
| 오른쪽 여백 | `margin right` | `margin-right` / `padding-right` | HWPUNIT → mm |
| 들여쓰기 | `indent` | `text-indent` | HWPUNIT → mm |
| 문단 간격 위 | `spacing before` | `margin-top` | HWPUNIT → mm |
| 문단 간격 아래 | `spacing after` | `margin-bottom` | HWPUNIT → mm |
| 줄 간격 | `lineSpacing` | `line-height` | 종류에 따라 다름 |
| BorderFill ID | `borderFillIDRef` | border + background | ID로 참조 |
### 5.4 HWPX paraPr 구조 예시
```xml
<hh:paraPr id="0" align="JUSTIFY">
<hh:margin left="0" right="0" indent="0"/>
<hh:spacing before="0" after="0"
lineSpacingType="PERCENT" lineSpacing="160"/>
<hh:border borderFillIDRef="1"
left="0" right="0" top="0" bottom="0"/>
<hh:autoSpacing eAsianEng="false" eAsianNum="false"/>
</hh:paraPr>
```
---
## 6. 표 (Table) 구조
> HWP: `HWPTAG_TABLE` (본문 레코드)
> HWPX: `<hp:tbl>` (section*.xml 내)
> HTML: `<table>`, `<tr>`, `<td>`/`<th>`
### 6.1 표 속성 매핑
| HWP 필드 | HWPX 속성 | HTML/CSS 대응 | 비고 |
|----------|----------|-------------|------|
| RowCount | `rowCnt` | (행 수) | |
| nCols | `colCnt` | (열 수) | `<colgroup>` 참조 |
| CellSpacing | `cellSpacing` | `border-spacing` | HWPUNIT16 |
| 안쪽 여백 | `cellMargin` left/right/top/bottom | `padding` | |
| BorderFill ID | `borderFillIDRef` | 표 전체 테두리 | |
| 쪽나눔(bit 0-1) | `pageBreak` | `page-break-inside` | 0=avoid, 1=auto |
| 제목줄 반복(bit 2) | `repeatHeader` | `<thead>` 출력 | |
### 6.2 열 너비
```xml
<!-- HWPX -->
<hp:tbl colCnt="3" rowCnt="5" ...>
<hp:colSz>
<hp:widthList>8504 8504 8504</hp:widthList> <!-- HWPUNIT -->
</hp:colSz>
...
</hp:tbl>
```
```html
<!-- HTML 변환 -->
<colgroup>
<col style="width: 33.33%"> <!-- 8504 / 총합 * 100 -->
<col style="width: 33.33%">
<col style="width: 33.33%">
</colgroup>
```
### 6.3 셀 (Cell) 속성
| HWP 필드 | HWPX 속성 | HTML 속성 | 비고 |
|----------|----------|----------|------|
| Column 주소 | `colAddr` | — | 0부터 시작 |
| Row 주소 | `rowAddr` | — | 0부터 시작 |
| 열 병합 개수 | `colSpan` | `colspan` | 1 = 병합 없음 |
| 행 병합 개수 | `rowSpan` | `rowspan` | 1 = 병합 없음 |
| 셀 폭 | `width` | `width` | HWPUNIT |
| 셀 높이 | `height` | `height` | HWPUNIT |
| 셀 여백 [4] | `cellMargin` | `padding` | HWPUNIT16 → mm |
| BorderFill ID | `borderFillIDRef` | `border` + `background` | 셀별 스타일 |
### 6.4 HWPX 셀 구조 예시
```xml
<hp:tc colAddr="0" rowAddr="0" colSpan="2" rowSpan="1"
width="17008" height="2400" borderFillIDRef="4">
<hp:cellMargin left="510" right="510" top="142" bottom="142"/>
<hp:cellAddr colAddr="0" rowAddr="0"/>
<hp:subList ...>
<hp:p ...>
<!-- 셀 내용 -->
</hp:p>
</hp:subList>
</hp:tc>
```
```html
<!-- HTML 변환 -->
<td colspan="2" style="
width: 60mm;
height: 8.5mm;
padding: 0.5mm 1.8mm;
border: 0.12mm solid #000;
background-color: #E8F5E9;
">셀 내용</td>
```
### 6.5 병합 셀 처리 규칙
HWP/HWPX에서 병합된 셀은 **왼쪽 위 셀만 존재**하고, 병합에 포함된 다른 셀은 아예 없다.
HTML에서는 colspan/rowspan으로 표현하고, 병합된 위치의 `<td>`를 생략한다.
```
HWPX: colSpan="2", rowSpan="3" at (col=0, row=0)
→ 이 셀이 col 0~1, row 0~2를 차지
→ col=1/row=0, col=0/row=1, col=1/row=1, col=0/row=2, col=1/row=2 셀은 없음
HTML: <td colspan="2" rowspan="3">...</td>
→ 해당 행/열 위치에서 <td> 생략
```
---
## 7. 용지 설정 (PageDef / SecPr)
> HWP: `HWPTAG_PAGE_DEF` (구역 정의 하위)
> HWPX: `<hp:secPr>` → `<hp:pageDef>` (section*.xml 내)
> CSS: `@page`, `@media print`
### 7.1 용지 크기 사전 정의
| 용지 이름 | 가로 (mm) | 세로 (mm) | HWPUNIT (가로×세로) |
|----------|----------|----------|:---:|
| A4 | 210 | 297 | 59528 × 84188 |
| A3 | 297 | 420 | 84188 × 119055 |
| B5 | 176 | 250 | 49896 × 70866 |
| Letter | 215.9 | 279.4 | 61200 × 79200 |
| Legal | 215.9 | 355.6 | 61200 × 100800 |
### 7.2 여백 매핑
```xml
<!-- HWPX section0.xml -->
<hp:secPr>
<hp:pageDef width="59528" height="84188"
landscape="NARROWLY"> <!-- 좁게 = 세로 -->
<hp:margin left="8504" right="8504"
top="5668" bottom="4252"
header="4252" footer="4252"
gutter="0"/>
</hp:pageDef>
</hp:secPr>
```
```css
/* CSS 변환 */
@page {
size: A4 portrait; /* 210mm × 297mm */
margin-top: 20mm; /* 5668 / 7200 * 25.4 ≈ 20mm */
margin-bottom: 15mm; /* 4252 → 15mm */
margin-left: 30mm; /* 8504 → 30mm */
margin-right: 30mm; /* 8504 → 30mm */
}
/* 머리말/꼬리말 여백은 CSS에서 body padding으로 근사 */
```
### 7.3 용지 방향
| HWP 값 (bit 0) | HWPX 속성값 | CSS |
|:---:|:---:|:---:|
| 0 | `NARROWLY` (좁게) | `portrait` |
| 1 | `WIDELY` (넓게) | `landscape` |
---
## 8. 머리말/꼬리말 (Header/Footer)
> HWP: `HWPTAG_CTRL_HEADER` → 컨트롤 ID `head` / `foot`
> HWPX: `<hp:headerFooter>` (section*.xml 내, 또는 별도 header/footer 영역)
> HTML: 페이지 상단/하단 고정 영역
### 8.1 머리말/꼬리말 적용 범위
| HWP/HWPX 설정 | 의미 |
|:---:|:---:|
| 양쪽 | 모든 쪽에 동일 |
| 짝수쪽만 | 짝수 페이지 |
| 홀수쪽만 | 홀수 페이지 |
### 8.2 HTML 근사 표현
```html
<!-- 머리말 -->
<div class="page-header" style="
position: absolute; top: 0; left: 0; right: 0;
height: 15mm; /* header margin 값 */
padding: 0 30mm; /* 좌우 본문 여백 */
">
<table class="header-table">...</table>
</div>
<!-- 꼬리말 -->
<div class="page-footer" style="
position: absolute; bottom: 0; left: 0; right: 0;
height: 15mm; /* footer margin 값 */
padding: 0 30mm;
">
<span class="footer-text">페이지 번호</span>
</div>
```
---
## 9. 구역 정의 (Section)
> HWP: 구역 정의 컨트롤 (`secd`)
> HWPX: `<hp:secPr>` (section*.xml 최상위)
### 9.1 구역 속성
| 속성 | HWPX | CSS/HTML 대응 | 비고 |
|------|------|-------------|------|
| 머리말 감춤 | `hideHeader` | header 영역 display:none | |
| 꼬리말 감춤 | `hideFooter` | footer 영역 display:none | |
| 텍스트 방향 | `textDirection` | `writing-mode` | 0=가로, 1=세로 |
| 단 정의 | `<hp:colDef>` | CSS `columns` / `column-count` | |
| 쪽 번호 | `pageStartNo` | 쪽 번호 출력 값 | 0=이어서 |
---
## 10. HTML → HWPX → HWP 변환 파이프라인
### 10.1 전체 흐름
```
[HTML (우리 출력)]
↓ (1) HTML 파싱 → CSS 속성 추출
[중간 표현 (JSON)]
↓ (2) 이 가이드의 역방향 매핑
[HWPX (XML ZIP)]
↓ (3) 한컴오피스 변환 도구
[HWP (바이너리)]
```
### 10.2 단계별 매핑 방향
| 단계 | 입력 | 출력 | 참조할 가이드 섹션 |
|:---:|:---:|:---:|:---:|
| HTML → HWPX | CSS border | `<hh:borderFill>` 생성 | §2 역방향 |
| HTML → HWPX | CSS font | `<hh:charPr>` + `<hh:fontface>` | §3, §4 역방향 |
| HTML → HWPX | CSS text-align 등 | `<hh:paraPr>` | §5 역방향 |
| HTML → HWPX | `<table>` | `<hp:tbl>` + `<hp:tc>` | §6 역방향 |
| HTML → HWPX | @page CSS | `<hp:pageDef>` | §7 역방향 |
| HTML → HWPX | header/footer div | `<hp:headerFooter>` | §8 역방향 |
### 10.3 CSS → HWPX 역변환 예시
```python
def css_border_to_hwpx(css_border):
"""'0.12mm solid #000000' → HWPX 속성"""
parts = css_border.split()
width = parts[0] # '0.12mm'
style = parts[1] # 'solid'
color = parts[2] # '#000000'
hwpx_type = CSS_TO_BORDER_TYPE.get(style, 'SOLID')
return {
'type': hwpx_type,
'width': width,
'color': color
}
CSS_TO_BORDER_TYPE = {
'solid': 'SOLID', 'dashed': 'DASH', 'dotted': 'DOT',
'double': 'DOUBLE', 'ridge': 'THICK_3D', 'groove': 'THICK_3D_REV',
'outset': '3D', 'inset': '3D_REV', 'none': 'NONE'
}
```
### 10.4 HWPX ZIP 구조 생성
```
output.hwpx (ZIP)
├── META-INF/
│ └── manifest.xml ← 파일 목록
├── Contents/
│ ├── header.xml ← DocInfo (글꼴, 스타일, borderFill)
│ ├── section0.xml ← 본문 (문단, 표, 머리말/꼬리말)
│ └── content.hpf ← 콘텐츠 메타
├── BinData/ ← 이미지 등
├── Preview/
│ └── PrvImage.png ← 미리보기
└── version.xml ← 버전 정보
```
**header.xml 필수 구조**:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<hh:head xmlns:hh="...">
<hh:beginNum .../>
<hh:refList>
<hh:fontfaces>...</hh:fontfaces> <!-- §3 -->
<hh:borderFills>...</hh:borderFills> <!-- §2 -->
<hh:charProperties>...</hh:charProperties> <!-- §4 -->
<hh:tabProperties>...</hh:tabProperties>
<hh:numberingProperties>...</hh:numberingProperties>
<hh:bulletProperties>...</hh:bulletProperties>
<hh:paraProperties>...</hh:paraProperties> <!-- §5 -->
<hh:styles>...</hh:styles>
</hh:refList>
</hh:head>
```
**section0.xml 필수 구조**:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<hp:sec xmlns:hp="...">
<hp:secPr>
<hp:pageDef .../> <!-- §7 -->
<hp:headerFooter .../> <!-- §8 -->
</hp:secPr>
<hp:p paraPrIDRef="0" styleIDRef="0"> <!-- 문단 -->
<hp:run charPrIDRef="0">
<hp:t>텍스트</hp:t>
</hp:run>
</hp:p>
<hp:p ...>
<hp:ctrl>
<hp:tbl ...>...</hp:tbl> <!-- §6 -->
</hp:ctrl>
</hp:p>
</hp:sec>
```
---
## 11. 시스템 적용 가이드
### 11.1 적용 대상 모듈
| 모듈 | 파일 | 이 가이드 활용 방식 |
|------|------|:---:|
| **doc_template_analyzer.py** | HWPX → HTML 템플릿 추출 | §2,6,7,8 정방향 (HWPX→CSS) |
| **template_manager.py** | 추출된 스타일 저장/로드 | §2 borderFill ID 매핑 |
| **custom_doc_type.py** | HTML 문서 생성 | §2,4,5 CSS 값 참조 |
| **hwpx_converter.py** (예정) | HTML → HWPX 변환 | §2~8 역방향 (CSS→HWPX) |
| **hwp_converter.py** (예정) | HWPX → HWP 변환 | §1 단위 변환 |
### 11.2 하드코딩 제거 전략
**현재 문제 (AS-IS)**:
```python
# doc_template_analyzer.py에 하드코딩됨
border_css = "2px solid var(--primary)"
header_bg = "#E8F5E9"
```
**해결 방향 (TO-BE)**:
```python
# style.json에서 추출된 borderFill 참조
bf = style['border_fills']['3'] # id=3
border_css = f"{bf['top']['width']} {bf['top']['css_style']} {bf['top']['color']}"
# → "0.12mm solid #000000"
header_bf = style['border_fills']['4'] # id=4 (헤더 배경 포함)
header_bg = header_bf.get('background', 'none')
# → "#E8F5E9"
```
### 11.3 이 가이드를 코드에서 참조하는 방식
이 문서(`hwpx_domain_guide.md`)는 다음과 같이 활용한다:
1. **변환 테이블을 JSON으로 추출**`hwpx_mappings.json`
- 테두리선 종류, 굵기, 색상 변환 등의 룩업 테이블
2. **변환 함수 라이브러리**`hwpx_utils.py`
- `hwpunit_to_mm()`, `borderfill_to_css()`, `css_border_to_hwpx()`
3. **AI 프롬프트 컨텍스트** → 문서 유형/구조 분석 시 참조
- "이 HWPX의 borderFill id=3은 실선 0.12mm 검정이므로 표 일반 셀에 해당"
4. **검증 기준** → 변환 결과물 검증 시 정확성 확인
- 추출된 CSS가 원본 HWPX의 스펙과 일치하는지
---
## 부록 A. 빠른 참조 — HWPX XML 태그 ↔ HWP 레코드 대응
| HWP 레코드 (Tag ID) | HWPX XML 엘리먼트 | 위치 |
|---------------------|------------------|------|
| HWPTAG_DOCUMENT_PROPERTIES | `<hh:beginNum>` 등 | header.xml |
| HWPTAG_ID_MAPPINGS | (암묵적) | header.xml |
| HWPTAG_FACE_NAME | `<hh:font>` | header.xml > fontfaces |
| HWPTAG_BORDER_FILL | `<hh:borderFill>` | header.xml > borderFills |
| HWPTAG_CHAR_SHAPE | `<hh:charPr>` | header.xml > charProperties |
| HWPTAG_TAB_DEF | `<hh:tabPr>` | header.xml > tabProperties |
| HWPTAG_NUMBERING | `<hh:numbering>` | header.xml > numberingProperties |
| HWPTAG_BULLET | `<hh:bullet>` | header.xml > bulletProperties |
| HWPTAG_PARA_SHAPE | `<hh:paraPr>` | header.xml > paraProperties |
| HWPTAG_STYLE | `<hh:style>` | header.xml > styles |
| HWPTAG_PARA_HEADER | `<hp:p>` | section*.xml |
| HWPTAG_TABLE | `<hp:tbl>` | section*.xml > p > ctrl |
| (셀 속성) | `<hp:tc>` | section*.xml > tbl > tr > tc |
| HWPTAG_PAGE_DEF | `<hp:pageDef>` | section*.xml > secPr |
| (머리말/꼬리말) | `<hp:headerFooter>` | section*.xml > secPr |
## 부록 B. 빠른 참조 — CSS → HWPX 역변환
| CSS 속성 | HWPX 대응 | 변환 공식 |
|----------|----------|----------|
| `font-family` | `<hh:font face="...">` | 첫 번째 값 → hangul, 두 번째 → latin |
| `font-size: 10pt` | `<hh:charPr height="1000">` | pt × 100 |
| `font-weight: bold` | `bold="true"` | |
| `font-style: italic` | `italic="true"` | |
| `color: #1a365d` | `color="#1a365d"` | 동일 |
| `text-align: center` | `align="CENTER"` | 대문자 |
| `margin-left: 30mm` | `left="8504"` | mm → HWPUNIT |
| `line-height: 160%` | `lineSpacing="160"` + `type="PERCENT"` | |
| `border: 1px solid #000` | `<hh:borderFill>` 내 각 방향 | §2 참조 |
| `background-color: #E8F5E9` | `<hh:windowBrush faceColor="...">` | |
| `padding: 2mm 5mm` | `<hp:cellMargin top="567" left="1417">` | mm → HWPUNIT |
| `width: 210mm` | `width="59528"` | mm → HWPUNIT |
| `@page { size: A4 }` | `<hp:pageDef width="59528" height="84188">` | |
---
*이 가이드는 한글과컴퓨터의 "글 문서 파일 구조 5.0 (revision 1.3)"을 참고하여 작성되었습니다.*

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
"""
HWP/HWPX ↔ HTML/CSS 변환 유틸리티
hwpx_domain_guide.md의 매핑 테이블을 코드화.
하드코딩 없이 이 모듈의 함수/상수를 참조하여 정확한 변환을 수행한다.
참조: 한글과컴퓨터 "글 문서 파일 구조 5.0" (revision 1.3, 2018-11-08)
"""
# ================================================================
# §1. 단위 변환
# ================================================================
def hwpunit_to_mm(hwpunit):
"""HWPUNIT → mm (1 HWPUNIT = 1/7200 inch)"""
return hwpunit / 7200 * 25.4
def hwpunit_to_pt(hwpunit):
"""HWPUNIT → pt"""
return hwpunit / 7200 * 72
def hwpunit_to_px(hwpunit, dpi=96):
"""HWPUNIT → px (기본 96dpi)"""
return hwpunit / 7200 * dpi
def mm_to_hwpunit(mm):
"""mm → HWPUNIT"""
return mm / 25.4 * 7200
def pt_to_hwpunit(pt):
"""pt → HWPUNIT"""
return pt / 72 * 7200
def px_to_hwpunit(px, dpi=96):
"""px → HWPUNIT"""
return px / dpi * 7200
def charsize_to_pt(hwp_size):
"""HWP 글자 크기 → pt (100 스케일 제거)
예: 1000 → 10pt, 2400 → 24pt
"""
return hwp_size / 100
def pt_to_charsize(pt):
"""pt → HWP 글자 크기
예: 10pt → 1000, 24pt → 2400
"""
return int(pt * 100)
# ================================================================
# §1.3 색상 변환
# ================================================================
def colorref_to_css(colorref):
"""HWP COLORREF (0x00BBGGRR) → CSS #RRGGBB
HWP는 리틀 엔디안 BGR 순서:
- 0x00FF0000 → B=255,G=0,R=0 → #0000ff (파랑)
- 0x000000FF → B=0,G=0,R=255 → #ff0000 (빨강)
"""
r = colorref & 0xFF
g = (colorref >> 8) & 0xFF
b = (colorref >> 16) & 0xFF
return f'#{r:02x}{g:02x}{b:02x}'
def css_to_colorref(css_color):
"""CSS #RRGGBB → HWP COLORREF (0x00BBGGRR)"""
css_color = css_color.lstrip('#')
if len(css_color) == 3: # 단축형 #rgb → #rrggbb
css_color = ''.join(c * 2 for c in css_color)
r = int(css_color[0:2], 16)
g = int(css_color[2:4], 16)
b = int(css_color[4:6], 16)
return (b << 16) | (g << 8) | r
# ================================================================
# §2. 테두리/배경 (BorderFill) 매핑
# ================================================================
# §2.1 테두리선 종류: HWPX type → CSS border-style
BORDER_TYPE_TO_CSS = {
'NONE': 'none',
'SOLID': 'solid',
'DASH': 'dashed',
'DOT': 'dotted',
'DASH_DOT': 'dashed', # CSS 근사
'DASH_DOT_DOT': 'dashed', # CSS 근사
'LONG_DASH': 'dashed',
'CIRCLE': 'dotted', # CSS 근사 (큰 동그라미 → dot)
'DOUBLE': 'double',
'THIN_THICK': 'double', # CSS 근사
'THICK_THIN': 'double', # CSS 근사
'THIN_THICK_THIN':'double', # CSS 근사
'WAVE': 'solid', # CSS 근사 (물결 → 실선)
'DOUBLE_WAVE': 'double', # CSS 근사
'THICK_3D': 'ridge',
'THICK_3D_REV': 'groove',
'3D': 'outset',
'3D_REV': 'inset',
}
# CSS border-style → HWPX type (역방향)
CSS_TO_BORDER_TYPE = {
'none': 'NONE',
'solid': 'SOLID',
'dashed': 'DASH',
'dotted': 'DOT',
'double': 'DOUBLE',
'ridge': 'THICK_3D',
'groove': 'THICK_3D_REV',
'outset': '3D',
'inset': '3D_REV',
}
# §2.2 HWP 바이너리 테두리 굵기 값 → 실제 mm
BORDER_WIDTH_HWP_TO_MM = {
0: 0.1, 1: 0.12, 2: 0.15, 3: 0.2, 4: 0.25, 5: 0.3,
6: 0.4, 7: 0.5, 8: 0.6, 9: 0.7, 10: 1.0, 11: 1.5,
12: 2.0, 13: 3.0, 14: 4.0, 15: 5.0,
}
# ================================================================
# §5. 정렬 매핑
# ================================================================
# §5.1 HWPX align → CSS text-align
ALIGN_TO_CSS = {
'JUSTIFY': 'justify',
'LEFT': 'left',
'RIGHT': 'right',
'CENTER': 'center',
'DISTRIBUTE': 'justify', # CSS 근사
'DISTRIBUTE_SPACE': 'justify', # CSS 근사
}
# CSS text-align → HWPX align (역방향)
CSS_TO_ALIGN = {
'justify': 'JUSTIFY',
'left': 'LEFT',
'right': 'RIGHT',
'center': 'CENTER',
}
# ================================================================
# §5.2 줄 간격 매핑
# ================================================================
LINE_SPACING_TYPE_TO_CSS = {
'PERCENT': 'percent', # 글자에 따라 (%) → line-height: 160%
'FIXED': 'fixed', # 고정값 → line-height: 24pt
'BETWEEN_LINES': 'between', # 여백만 지정
'AT_LEAST': 'at_least', # 최소
}
# ================================================================
# 종합 변환 함수
# ================================================================
def hwpx_border_to_css(border_attrs):
"""HWPX 테두리 속성 dict → CSS border 문자열
Args:
border_attrs: {'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'}
Returns:
'0.12mm solid #000000' 또는 'none'
"""
btype = border_attrs.get('type', 'NONE')
if btype == 'NONE' or btype is None:
return 'none'
width = border_attrs.get('width', '0.12mm')
color = border_attrs.get('color', '#000000')
css_style = BORDER_TYPE_TO_CSS.get(btype, 'solid')
return f'{width} {css_style} {color}'
def css_border_to_hwpx(css_border):
"""CSS border 문자열 → HWPX 속성 dict
Args:
css_border: '0.12mm solid #000000' 또는 'none'
Returns:
{'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'}
"""
if not css_border or css_border.strip() == 'none':
return {'type': 'NONE', 'width': '0mm', 'color': '#000000'}
parts = css_border.strip().split()
width = parts[0] if len(parts) > 0 else '0.12mm'
style = parts[1] if len(parts) > 1 else 'solid'
color = parts[2] if len(parts) > 2 else '#000000'
return {
'type': CSS_TO_BORDER_TYPE.get(style, 'SOLID'),
'width': width,
'color': color,
}
def hwpx_borderfill_to_css(bf_element_attrs):
"""HWPX <hh:borderFill> 전체 속성 → CSS dict
Args:
bf_element_attrs: {
'left': {'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'},
'right': {'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'},
'top': {'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'},
'bottom': {'type': 'SOLID', 'width': '0.12mm', 'color': '#000000'},
'background': '#E8F5E9' or None,
}
Returns:
{
'border-left': '0.12mm solid #000000',
'border-right': '0.12mm solid #000000',
'border-top': '0.12mm solid #000000',
'border-bottom': '0.12mm solid #000000',
'background-color': '#E8F5E9',
}
"""
css = {}
for side in ['left', 'right', 'top', 'bottom']:
border = bf_element_attrs.get(side, {})
css[f'border-{side}'] = hwpx_border_to_css(border)
bg = bf_element_attrs.get('background')
if bg and bg != 'none':
css['background-color'] = bg
return css
def hwpx_align_to_css(hwpx_align):
"""HWPX 정렬 값 → CSS text-align"""
return ALIGN_TO_CSS.get(hwpx_align, 'left')
def css_align_to_hwpx(css_align):
"""CSS text-align → HWPX 정렬 값"""
return CSS_TO_ALIGN.get(css_align, 'LEFT')
def hwpx_line_spacing_to_css(spacing_type, spacing_value):
"""HWPX 줄 간격 → CSS line-height
Args:
spacing_type: 'PERCENT' | 'FIXED' | 'BETWEEN_LINES' | 'AT_LEAST'
spacing_value: 숫자값
Returns:
CSS line-height 문자열 (예: '160%', '24pt')
"""
if spacing_type == 'PERCENT':
return f'{spacing_value}%'
elif spacing_type == 'FIXED':
pt = hwpunit_to_pt(spacing_value)
return f'{pt:.1f}pt'
else:
return f'{spacing_value}%' # 기본 근사
# ================================================================
# 용지 크기 사전 정의 (§7.1)
# ================================================================
PAPER_SIZES = {
'A4': {'width_mm': 210, 'height_mm': 297, 'width_hu': 59528, 'height_hu': 84188},
'A3': {'width_mm': 297, 'height_mm': 420, 'width_hu': 84188, 'height_hu': 119055},
'B5': {'width_mm': 176, 'height_mm': 250, 'width_hu': 49896, 'height_hu': 70866},
'Letter': {'width_mm': 215.9, 'height_mm': 279.4, 'width_hu': 61200, 'height_hu': 79200},
'Legal': {'width_mm': 215.9, 'height_mm': 355.6, 'width_hu': 61200, 'height_hu': 100800},
}
def detect_paper_size(width_hu, height_hu, tolerance=200):
"""HWPUNIT 용지 크기 → 용지 이름 추정
Args:
width_hu: 가로 크기 (HWPUNIT)
height_hu: 세로 크기 (HWPUNIT)
tolerance: 허용 오차 (HWPUNIT)
Returns:
'A4', 'A3', 'Letter' 등 또는 'custom'
"""
for name, size in PAPER_SIZES.items():
if (abs(width_hu - size['width_hu']) <= tolerance and
abs(height_hu - size['height_hu']) <= tolerance):
return name
# landscape 체크
if (abs(width_hu - size['height_hu']) <= tolerance and
abs(height_hu - size['width_hu']) <= tolerance):
return f'{name}_landscape'
return 'custom'
# ================================================================
# 편의 함수
# ================================================================
def css_style_string(css_dict):
"""CSS dict → CSS style 문자열
예: {'border-left': '1px solid #000', 'padding': '5mm'}
'border-left: 1px solid #000; padding: 5mm;'
"""
return ' '.join(f'{k}: {v};' for k, v in css_dict.items() if v)
def mm_format(hwpunit, decimal=1):
"""HWPUNIT → 'Xmm' 포맷 문자열"""
mm = hwpunit_to_mm(hwpunit)
return f'{mm:.{decimal}f}mm'

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""
handlers 패키지
문서 유형별 처리 로직을 분리하여 관리
"""
from .doc_template_analyzer import DocTemplateAnalyzer

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
"""
기획서(briefing) 처리 모듈
"""
from .processor import BriefingProcessor

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""
기획서(briefing) 처리 로직
- 1~2페이지 압축형 보고서
- Navy 양식
"""
import os
import json
from pathlib import Path
from flask import jsonify, session
from handlers.common import call_claude, extract_json, extract_html, load_prompt, client
class BriefingProcessor:
"""기획서 처리 클래스"""
def __init__(self):
self.prompts_dir = Path(__file__).parent / 'prompts'
def _load_prompt(self, filename: str) -> str:
"""프롬프트 로드"""
return load_prompt(str(self.prompts_dir), filename)
def _get_step1_prompt(self) -> str:
"""1단계: 구조 추출 프롬프트"""
prompt = self._load_prompt('step1_extract.txt')
if prompt:
return prompt
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
def _get_step1_5_prompt(self) -> str:
"""1.5단계: 배치 계획 프롬프트"""
prompt = self._load_prompt('step1_5_plan.txt')
if prompt:
return prompt
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
def _get_step2_prompt(self) -> str:
"""2단계: HTML 생성 프롬프트"""
prompt = self._load_prompt('step2_generate.txt')
if prompt:
return prompt
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool:
"""페이지당 콘텐츠 양 체크"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
sheets = soup.find_all('div', class_='sheet')
for sheet in sheets:
sections = sheet.find_all('div', class_='section')
if len(sections) > max_sections_per_page:
return True
all_li = sheet.find_all('li')
if len(all_li) > 12:
return True
steps = sheet.find_all('div', class_='process-step')
if len(steps) > 6:
return True
return False
def generate(self, content: str, options: dict) -> dict:
"""기획서 생성"""
try:
if not content.strip():
return {'error': '내용을 입력하거나 파일을 업로드해주세요.'}
page_option = options.get('page_option', '1')
department = options.get('department', '총괄기획실')
additional_prompt = options.get('instruction', '')
# ============== 1단계: 구조 추출 ==============
step1_prompt = self._get_step1_prompt()
step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요.
## 원본 HTML
{content}
---
위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력."""
step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000)
structure_json = extract_json(step1_response)
if not structure_json:
structure_json = {"raw_content": content, "parse_failed": True}
# ============== 1.5단계: 배치 계획 ==============
step1_5_prompt = self._get_step1_5_prompt()
step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요.
## 문서 구조 (JSON)
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
## 페이지 수
{page_option}페이지
---
배치 계획 JSON만 출력하세요. 설명 없이 JSON만."""
step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000)
page_plan = extract_json(step1_5_response)
if not page_plan:
page_plan = {"page_plan": {}, "parse_failed": True}
# ============== 2단계: HTML 생성 ==============
page_instructions = {
'1': '1페이지로 핵심 내용만 압축하여 작성하세요.',
'2': '2페이지로 작성하세요. 1페이지는 본문, 2페이지는 [첨부]입니다.',
'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부] 형태로 분할합니다.'
}
step2_prompt = self._get_step2_prompt()
step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요.
## 배치 계획
{json.dumps(page_plan, ensure_ascii=False, indent=2)}
## 문서 구조 (JSON)
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
## 페이지 옵션
{page_instructions.get(page_option, page_instructions['1'])}
## 부서명
{department}
## 추가 요청사항
{additional_prompt if additional_prompt else '없음'}
---
위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요.
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력."""
step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000)
html_content = extract_html(step2_response)
# 후처리 검증
if self._content_too_long(html_content):
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요.
{html_content}
코드 블록 없이 압축된 완전한 HTML만 출력하세요."""
compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000)
html_content = extract_html(compress_response)
# 세션에 저장
session['original_html'] = content
session['current_html'] = html_content
session['structure_json'] = json.dumps(structure_json, ensure_ascii=False)
session['conversation'] = []
return {
'success': True,
'html': html_content,
'structure': structure_json
}
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()}
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
"""피드백 반영"""
try:
if not feedback.strip():
return {'error': '피드백 내용을 입력해주세요.'}
if not current_html:
return {'error': '수정할 HTML이 없습니다.'}
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
## 규칙
1. 피드백에서 언급된 부분만 정확히 수정
2. 나머지 구조와 스타일은 그대로 유지
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
4. 코드 블록(```) 없이 순수 HTML만 출력
5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능
## 원본 HTML (참고용)
{original_html[:3000] if original_html else '없음'}...
## 현재 HTML
{current_html}
## 사용자 피드백
{feedback}
---
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
response = call_claude("", refine_prompt, max_tokens=8000)
new_html = extract_html(response)
session['current_html'] = new_html
return {
'success': True,
'html': new_html
}
except Exception as e:
return {'error': str(e)}
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
"""선택된 부분만 수정"""
try:
if not current_html or not selected_text or not user_request:
return {'error': '필수 데이터가 없습니다.'}
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8000,
messages=[{
"role": "user",
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
## 전체 문서 (컨텍스트 파악용)
{current_html}
## 수정 대상 텍스트
"{selected_text}"
## 수정 요청
{user_request}
## 규칙
1. 요청을 분석하여 수정 유형을 판단:
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등)
2. 반드시 다음 형식으로만 출력:
TYPE: (TEXT 또는 STRUCTURE)
CONTENT:
(수정된 내용)
3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이)
4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지)
5. 개조식 문체 유지 (~임, ~함, ~필요)
"""
}]
)
result = message.content[0].text
result = result.replace('```html', '').replace('```', '').strip()
edit_type = 'TEXT'
content = result
if 'TYPE:' in result and 'CONTENT:' in result:
type_line = result.split('CONTENT:')[0]
if 'STRUCTURE' in type_line:
edit_type = 'STRUCTURE'
content = result.split('CONTENT:')[1].strip()
return {
'success': True,
'type': edit_type,
'html': content
}
except Exception as e:
return {'error': str(e)}

View File

@@ -0,0 +1,104 @@
당신은 임원보고용 문서 구성 전문가입니다.
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
## 입력
- step1에서 추출된 JSON 구조 데이터
## 출력
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
---
## 배치 원칙
### 1페이지 (본문) - "왜? 무엇이 문제?"
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
### 2페이지~ (첨부) - "어떻게? 상세 기준"
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
- **bottom-box**: 해당 페이지 내용 요약
---
## 요소 역할 분류 기준
| 역할 | 설명 | 배치 |
|------|------|------|
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
| 프로세스 | 단계별 절차, Step | 첨부 |
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
| 체크리스트 | 항목별 점검사항 | 첨부 |
| 상세가이드 | 세부 설명, 예시 | 첨부 |
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
---
## 출력 JSON 스키마
```json
{
"page_plan": {
"page_1": {
"type": "본문",
"lead": {
"source_section": "원본 섹션명 또는 null",
"text": "lead-box에 들어갈 핵심 명제 문장"
},
"sections": [
{
"source": "원본 섹션 제목",
"role": "논리/근거 | 리스크 | 주의사항",
"new_title": "변환 후 섹션 제목 (필요시 수정)"
}
],
"bottom": {
"label": "핵심 결론",
"source": "원본에서 가져올 문장 또는 조합할 키워드",
"text": "bottom-box에 들어갈 최종 문장"
}
},
"page_2": {
"type": "첨부",
"title": "[첨부] 페이지 제목",
"sections": [
{
"source": "원본 섹션 제목",
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
"new_title": "변환 후 섹션 제목"
}
],
"bottom": {
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
"source": "원본에서 가져올 문장",
"text": "bottom-box에 들어갈 최종 문장"
}
}
},
"page_count": 2
}
```
---
## 판단 규칙
1. **프로세스/Step 있으면** → 무조건 첨부로
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
---
## 주의사항
1. 원본에 없는 내용 추가/추론 금지
2. 원본 문장을 선별/조합만 허용
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
4. JSON만 출력 (설명 없이)

View File

@@ -0,0 +1,122 @@
당신은 HTML 문서 구조 분석 전문가입니다.
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
## 규칙
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
2. 문서의 논리적 구조를 정확히 파악
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
## 출력 JSON 스키마
```json
{
"title": "문서 제목 (원문 그대로)",
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
"lead": {
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
"highlight_keywords": ["강조할 키워드1", "키워드2"]
},
"sections": [
{
"number": 1,
"title": "섹션 제목 (원문 그대로)",
"type": "list | table | grid | process | qa | text",
"content": {
// type에 따라 다름 (아래 참조)
}
}
],
"conclusion": {
"label": "라벨 (예: 핵심 결론, 요약 등)",
"text": "결론 텍스트 (원문 그대로, 한 문장)"
}
}
```
## 섹션 type별 content 구조
### type: "list"
```json
{
"items": [
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
]
}
```
### type: "table"
```json
{
"columns": ["컬럼1", "컬럼2", "컬럼3"],
"rows": [
{
"cells": [
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
]
}
],
"footnote": "표 하단 주석 (있으면)"
}
```
- badge 값: "safe" | "caution" | "risk" | null
- highlight: true면 빨간색 강조
### type: "grid"
```json
{
"columns": 2,
"items": [
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
{"title": "② 항목 제목", "text": "설명", "highlight": []}
]
}
```
### type: "two-column"
```json
{
"items": [
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
{"title": "② 제목", "text": "내용", "highlight": []}
]
}
```
### type: "process"
```json
{
"steps": [
{"number": 1, "title": "단계명", "text": "설명"},
{"number": 2, "title": "단계명", "text": "설명"}
]
}
```
### type: "qa"
```json
{
"items": [
{"question": "질문?", "answer": "답변"},
{"question": "질문?", "answer": "답변"}
]
}
```
### type: "text"
```json
{
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
}
```
## 중요
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
3. **JSON만 출력** - 설명 없이 순수 JSON만
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑

View File

@@ -0,0 +1,440 @@
당신은 HTML 보고서 생성 전문가입니다.
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
## 출력 규칙
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
2. 코드 블록(```) 없이 **순수 HTML만** 출력
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
4. 아래 CSS를 **정확히** 사용
## 페이지 옵션
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
## HTML 템플릿 구조
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{{title}}</title>
<style>
/* 아래 CSS 전체 포함 */
</style>
</head>
<body>
<div class="sheet">
<header class="page-header">
<div class="header-left">{{department}}</div>
<div class="header-right">{{title_en}}</div>
</header>
<div class="title-block">
<h1 class="header-title">{{title}}</h1>
<div class="title-divider"></div>
</div>
<div class="body-content">
<div class="lead-box">
<div>{{lead.text}} - <b>키워드</b> 강조</div>
</div>
<!-- sections -->
<div class="bottom-box">
<div class="bottom-left">{{conclusion.label}}</div>
<div class="bottom-right">{{conclusion.text}}</div>
</div>
</div>
<footer class="page-footer">- 1 -</footer>
</div>
</body>
</html>
```
## 섹션 type별 HTML 변환
### list → ul/li
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<ul>
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
</ul>
</div>
```
### table → data-table
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<table class="data-table">
<thead>
<tr>
<th width="25%">{{col1}}</th>
<th width="25%">{{col2}}</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="2"><strong>{{text}}</strong></td>
<td class="highlight-red">{{text}}</td>
</tr>
</tbody>
</table>
</div>
```
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
- highlight가 true면: `class="highlight-red"`
### grid → strategy-grid
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="strategy-grid">
<div class="strategy-item">
<div class="strategy-title">{{item.title}}</div>
<p>{{item.text}} <b>{{highlight}}</b></p>
</div>
</div>
</div>
```
### two-column → two-col
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="two-col">
<div class="info-box">
<div class="info-box-title">{{item.title}}</div>
<p>{{item.text}} <b>{{highlight}}</b></p>
</div>
</div>
</div>
```
### process → process-container
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="process-container">
<div class="process-step">
<div class="step-num">{{step.number}}</div>
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
</div>
<div class="arrow">▼</div>
<!-- 반복 -->
</div>
</div>
```
### qa → qa-grid
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="qa-grid">
<div class="qa-item">
<strong>Q. {{question}}</strong><br>
A. {{answer}}
</div>
</div>
</div>
```
## 완전한 CSS (반드시 이대로 사용)
```css
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary-navy: #1a365d;
--secondary-navy: #2c5282;
--accent-navy: #3182ce;
--dark-gray: #2d3748;
--medium-gray: #4a5568;
--light-gray: #e2e8f0;
--bg-light: #f7fafc;
--text-black: #1a202c;
--border-color: #cbd5e0;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #f0f0f0;
color: var(--text-black);
line-height: 1.55;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
gap: 20px;
word-break: keep-all;
}
.sheet {
background-color: white;
width: 210mm;
height: 297mm;
padding: 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media print {
body { background: none; padding: 0; gap: 0; }
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
.sheet:last-child { page-break-after: auto; }
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
font-size: 9pt;
color: var(--medium-gray);
}
.header-title {
font-size: 23pt;
font-weight: 900;
margin-bottom: 8px;
letter-spacing: -1px;
color: var(--primary-navy);
line-height: 1.25;
text-align: center;
}
.title-divider {
height: 3px;
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
width: 100%;
margin-bottom: 20px;
}
.lead-box {
background-color: var(--bg-light);
border-left: 4px solid var(--primary-navy);
padding: 14px 16px;
margin-bottom: 18px;
}
.lead-box div {
font-size: 11.5pt;
font-weight: 500;
color: var(--dark-gray);
line-height: 1.6;
}
.lead-box b { color: var(--primary-navy); font-weight: 700; }
.body-content { flex: 1; display: flex; flex-direction: column; }
.section { margin-bottom: 16px; }
.section-title {
font-size: 12pt;
font-weight: 700;
display: flex;
align-items: center;
margin-bottom: 10px;
color: var(--primary-navy);
}
.section-title::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
background-color: var(--secondary-navy);
margin-right: 10px;
}
.attachment-title {
font-size: 19pt;
font-weight: 700;
text-align: left;
color: var(--primary-navy);
margin-bottom: 8px;
}
ul { list-style: none; padding-left: 10px; }
li {
font-size: 10.5pt;
position: relative;
margin-bottom: 6px;
padding-left: 14px;
color: var(--dark-gray);
line-height: 1.55;
}
li::before {
content: "•";
position: absolute;
left: 0;
color: var(--secondary-navy);
font-size: 10pt;
}
.bottom-box {
border: 1.5px solid var(--border-color);
display: flex;
margin-top: auto;
min-height: 50px;
margin-bottom: 10px;
}
.bottom-left {
width: 18%;
background-color: var(--primary-navy);
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 700;
font-size: 10.5pt;
color: #fff;
line-height: 1.4;
}
.bottom-right {
width: 82%;
background-color: var(--bg-light);
padding: 12px 18px;
font-size: 10.5pt;
line-height: 1.6;
color: var(--dark-gray);
}
.bottom-right b { display: inline; }
.page-footer {
position: absolute;
bottom: 10mm;
left: 20mm;
right: 20mm;
padding-top: 8px;
text-align: center;
font-size: 8.5pt;
color: var(--medium-gray);
border-top: 1px solid var(--light-gray);
}
b { font-weight: 700; color: var(--primary-navy); display: inline; }
.keyword { font-weight: 600; color: var(--text-black); }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 9.5pt;
border-top: 2px solid var(--primary-navy);
border-bottom: 1px solid var(--border-color);
margin-top: 6px;
}
.data-table th {
background-color: var(--primary-navy);
color: #fff;
font-weight: 600;
padding: 8px 6px;
border: 1px solid var(--secondary-navy);
text-align: center;
font-size: 9pt;
}
.data-table td {
border: 1px solid var(--border-color);
padding: 7px 10px;
vertical-align: middle;
color: var(--dark-gray);
line-height: 1.45;
text-align: left;
}
.data-table td:first-child {
background-color: var(--bg-light);
font-weight: 600;
text-align: center;
}
.highlight-red { color: #c53030; font-weight: 600; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-weight: 600;
font-size: 8.5pt;
}
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
.qa-item strong { color: var(--primary-navy); }
.two-col { display: flex; gap: 12px; margin-top: 6px; }
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
.step-content strong { color: var(--primary-navy); font-weight: 600; }
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
```
## 1페이지 본문 구성 논리
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
## 첨부 페이지 구성
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
## 중요 규칙
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
- 위치 재편성, 통합/분할
- 표 ↔ 본문 ↔ 리스트 형식 변환
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
- ❌ "~입니다", "~합니다", "~됩니다"
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
- 예시:
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
## 첨부 페이지 규칙
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
- 제목은 좌측 정렬, 16pt
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""
공통 유틸리티 함수
- Claude API 호출
- JSON/HTML 추출
"""
import os
import re
import json
import anthropic
from api_config import API_KEYS
# Claude API 클라이언트
client = anthropic.Anthropic(
api_key=API_KEYS.get('CLAUDE_API_KEY', '')
)
def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str:
"""Claude API 호출"""
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=max_tokens,
system=system_prompt,
messages=[{"role": "user", "content": user_message}]
)
return response.content[0].text
def extract_json(text: str) -> dict:
"""텍스트에서 JSON 추출"""
# 코드 블록 제거
if '```json' in text:
text = text.split('```json')[1].split('```')[0]
elif '```' in text:
text = text.split('```')[1].split('```')[0]
text = text.strip()
# JSON 파싱 시도
try:
return json.loads(text)
except json.JSONDecodeError:
# JSON 부분만 추출 시도
match = re.search(r'\{[\s\S]*\}', text)
if match:
try:
return json.loads(match.group())
except:
pass
return None
def extract_html(text: str) -> str:
"""텍스트에서 HTML 추출"""
# 코드 블록 제거
if '```html' in text:
text = text.split('```html')[1].split('```')[0]
elif '```' in text:
parts = text.split('```')
if len(parts) >= 2:
text = parts[1]
text = text.strip()
# <!DOCTYPE 또는 <html로 시작하는지 확인
if not text.startswith('<!DOCTYPE') and not text.startswith('<html'):
# HTML 부분만 추출
match = re.search(r'(<!DOCTYPE html[\s\S]*</html>)', text, re.IGNORECASE)
if match:
text = match.group(1)
return text
def load_prompt(prompts_dir: str, filename: str) -> str:
"""프롬프트 파일 로드"""
prompt_path = os.path.join(prompts_dir, filename)
try:
with open(prompt_path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
return None

View File

@@ -0,0 +1,640 @@
# -*- coding: utf-8 -*-
"""
Content Analyzer (Phase 3 — Layer A)
- template_info + semantic_map → content_prompt.json
- 각 placeholder의 의미/유형/예시값/작성 패턴 추출
- Phase 5에서 AI가 새 문서 생성 시 "레시피"로 참조
★ 원칙: 모든 분류는 코드 100% (AI 없음)
purpose_hint / audience_hint / tone_hint는 빈 문자열로 남김
→ 추후 AI enrichment 단계에서 채울 수 있도록 설계
"""
import re
def generate(template_info: dict, semantic_map: dict,
parsed: dict = None) -> dict:
"""
content_prompt.json 생성
Args:
template_info: doc_template_analyzer 추출 결과
semantic_map: semantic_mapper 분류 결과
parsed: HWPX 파싱 원본 (선택)
Returns:
content_prompt.json 구조
"""
placeholders = {}
table_guide = {}
# ① 문서 기본 정보
document = _analyze_document(template_info)
# ② 헤더 placeholders
_analyze_header(template_info, placeholders)
# ③ 푸터 placeholders
_analyze_footer(template_info, placeholders)
# ④ 제목 placeholder
_analyze_title(template_info, semantic_map, placeholders)
# ⑤ 섹션 placeholders
_analyze_sections(semantic_map, placeholders, template_info)
# ⑤-b content_order 기반 문단/이미지 placeholders
_analyze_content_order(template_info, semantic_map, placeholders)
# ⑥ 표 가이드 + placeholders
_analyze_tables(template_info, semantic_map,
placeholders, table_guide)
# ⑦ 작성 패턴
writing_guide = _analyze_writing_patterns(template_info, semantic_map)
return {
"version": "1.0",
"document": document,
"placeholders": placeholders,
"table_guide": table_guide,
"writing_guide": writing_guide
}
# ================================================================
# 문서 기본 정보
# ================================================================
def _analyze_document(template_info: dict) -> dict:
"""문서 레벨 정보 추출"""
page = template_info.get("page", {})
paper = page.get("paper", {})
return {
"paper": paper.get("name", "A4"),
"layout": "landscape" if paper.get("landscape") else "portrait",
"margins": page.get("margins", {}),
"purpose_hint": "", # AI enrichment 예약
"audience_hint": "", # AI enrichment 예약
"tone_hint": "" # AI enrichment 예약
}
# ================================================================
# 텍스트 유형 분류 (코드 100%, AI 없음)
# ================================================================
def _classify_text(text: str) -> dict:
"""텍스트 패턴으로 콘텐츠 유형 분류"""
text = text.strip()
if not text:
return {"type": "empty", "pattern": "빈 셀"}
# 날짜: "2025. 1. 30(금)", "2025-01-30", "2025.01.30"
if re.match(r'\d{4}[\.\-/]\s*\d{1,2}[\.\-/]\s*\d{1,2}', text):
return {"type": "date", "pattern": "날짜 (YYYY. M. D)"}
# ★ 직급+이름 (부서보다 먼저!)
positions = [
'사원', '대리', '과장', '차장', '부장', '이사', '상무', '전무',
'연구원', '선임연구원', '책임연구원', '수석연구원',
'주임', '계장', '팀장', '실장', '부서장', '센터장'
]
for pos in positions:
if pos in text:
return {"type": "author", "pattern": f"이름 + 직급({pos})"}
# 부서 (직급 아닌 것만 여기로)
if re.search(r'(실|부|국|과|원|처|센터|본부)$', text) and len(text) <= 12:
return {"type": "department", "pattern": "조직명"}
# 팀
if re.search(r'팀$', text) and len(text) <= 10:
return {"type": "team", "pattern": "팀명"}
# 페이지 참조: "1p", "2p"
if re.match(r'\d+p$', text):
return {"type": "page_ref", "pattern": "페이지 참조"}
# 문서 제목: ~계획(안), ~보고서, ~제안서 등
if re.search(r'(계획|보고서|제안서|기획서|결과|방안|현황|분석)'
r'\s*(\(안\))?\s*$', text):
return {"type": "doc_title", "pattern": "문서 제목"}
# 슬로건/비전 (길고 추상적 키워드 포함)
if len(text) > 10 and any(k in text for k in
['함께', '세상', '미래', '가치', '만들어']):
return {"type": "slogan", "pattern": "회사 슬로건/비전"}
# 기본
return {"type": "text", "pattern": "자유 텍스트"}
# ================================================================
# 헤더 분석
# ================================================================
def _analyze_header(template_info: dict, placeholders: dict):
"""헤더 영역 placeholder 분석"""
header = template_info.get("header", {})
if not header or not header.get("exists"):
return
if header.get("type") == "table" and header.get("table"):
_analyze_table_area(header["table"], "HEADER", "header",
placeholders)
else:
texts = header.get("texts", [])
for i in range(max(len(texts), 1)):
ph = f"HEADER_TEXT_{i+1}"
example = texts[i] if i < len(texts) else ""
info = _classify_text(example)
info["example"] = example.strip()
info["location"] = "header"
placeholders[ph] = info
# ================================================================
# 푸터 분석
# ================================================================
def _analyze_footer(template_info: dict, placeholders: dict):
"""푸터 영역 placeholder 분석"""
footer = template_info.get("footer", {})
if not footer or not footer.get("exists"):
return
if footer.get("type") == "table" and footer.get("table"):
_analyze_table_area(footer["table"], "FOOTER", "footer",
placeholders)
else:
placeholders["PAGE_NUMBER"] = {
"type": "page_number",
"pattern": "페이지 번호",
"example": "1",
"location": "footer"
}
# ================================================================
# 헤더/푸터 공통: 표 형태 영역 분석
# ================================================================
def _analyze_table_area(tbl: dict, prefix: str, location: str,
placeholders: dict):
"""표 형태의 헤더/푸터 → placeholder 매핑
Args:
tbl: header["table"] 또는 footer["table"]
prefix: "HEADER" 또는 "FOOTER"
location: "header" 또는 "footer"
placeholders: 결과 dict (in-place 수정)
"""
rows = tbl.get("rows", [])
for r_idx, row in enumerate(rows):
for c_idx, cell in enumerate(row):
lines = cell.get("lines", [])
if len(lines) > 1:
for l_idx, line_text in enumerate(lines):
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}_LINE_{l_idx+1}"
info = _classify_text(line_text)
info["example"] = line_text.strip()
info["location"] = location
placeholders[ph] = info
elif lines:
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}"
info = _classify_text(lines[0])
info["example"] = lines[0].strip()
info["location"] = location
placeholders[ph] = info
else:
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}"
placeholders[ph] = {
"type": "empty",
"pattern": "빈 셀 (로고/여백)",
"example": "",
"location": location
}
# ================================================================
# 제목 분석
# ================================================================
def _analyze_title(template_info: dict, semantic_map: dict,
placeholders: dict):
"""제목 블록 placeholder 분석
★ v1.1: template_manager._build_title_block_html()과 동일한
TITLE_R{r}_C{c} 명명 규칙 사용 (범용 매핑)
"""
title_idx = semantic_map.get("title_table")
if title_idx is None:
return
tables = template_info.get("tables", [])
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
if not title_tbl:
return
# 각 셀별로 placeholder 생성 (template과 동일한 이름)
for r_idx, row in enumerate(title_tbl.get("rows", [])):
for c_idx, cell in enumerate(row):
cell_text = cell.get("text", "").strip()
if not cell_text:
continue # 빈 셀은 template에서도 placeholder 없음
ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}"
info = _classify_text(cell_text)
if "title" not in info["type"] and "doc_title" not in info["type"]:
# 제목표 안의 텍스트가 doc_title이 아닐 수도 있음 (부제 등)
# 가장 긴 텍스트만 doc_title로 분류
pass
info["example"] = cell_text
info["location"] = "title_block"
placeholders[ph_name] = info
# 가장 긴 텍스트를 가진 셀을 doc_title로 마킹
longest_ph = None
longest_len = 0
for ph_key in list(placeholders.keys()):
if ph_key.startswith("TITLE_R"):
ex = placeholders[ph_key].get("example", "")
if len(ex) > longest_len:
longest_len = len(ex)
longest_ph = ph_key
if longest_ph:
placeholders[longest_ph]["type"] = "doc_title"
placeholders[longest_ph]["pattern"] = "문서 제목"
# ================================================================
# 섹션 분석
# ================================================================
def _analyze_sections(semantic_map: dict, placeholders: dict,
template_info: dict = None):
"""섹션 placeholder 분석.
content_order에 문단이 있으면 SECTION_n_CONTENT는 생략
(개별 PARA_n이 본문 역할을 대신함).
"""
sections = semantic_map.get("sections", [])
# content_order에 문단이 있으면 개별 PARA_n이 본문 담당 → CONTENT 불필요
has_co_paragraphs = False
if template_info:
co = template_info.get("content_order", [])
has_co_paragraphs = any(c['type'] == 'paragraph' for c in co) if co else False
if not sections:
placeholders["SECTION_1_TITLE"] = {
"type": "section_title", "pattern": "섹션 제목",
"example": "", "location": "body"
}
if not has_co_paragraphs:
placeholders["SECTION_1_CONTENT"] = {
"type": "section_content", "pattern": "섹션 본문",
"example": "", "location": "body"
}
return
for i, sec in enumerate(sections):
s_num = i + 1
title_text = sec if isinstance(sec, str) else sec.get("title", "")
placeholders[f"SECTION_{s_num}_TITLE"] = {
"type": "section_title", "pattern": "섹션 제목",
"example": title_text, "location": "body"
}
if not has_co_paragraphs:
placeholders[f"SECTION_{s_num}_CONTENT"] = {
"type": "section_content", "pattern": "섹션 본문",
"example": "", "location": "body"
}
# ================================================================
# content_order 기반 문단/이미지 분석 (v5.2+)
# ================================================================
def _analyze_content_order(template_info: dict, semantic_map: dict,
placeholders: dict):
"""content_order의 paragraph/image → PARA_n, IMAGE_n placeholder 생성.
content_order가 없거나 문단이 없으면 아무것도 안 함 (legacy 호환).
"""
content_order = template_info.get("content_order")
if not content_order:
return
if not any(c['type'] == 'paragraph' for c in content_order):
return
# 섹션 제목 패턴 (template_manager와 동일)
sec_patterns = [
re.compile(r'^\d+\.\s+\S'),
re.compile(r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.\s*\S'),
re.compile(r'^제\s*\d+\s*[장절항]\s*\S'),
]
para_num = 0
img_num = 0
section_num = 0
for item in content_order:
itype = item['type']
if itype == 'empty':
continue
# ── 표: _analyze_tables에서 처리 → 건너뛰기 ──
if itype == 'table':
continue
# ── 이미지 ──
if itype == 'image':
img_num += 1
placeholders[f"IMAGE_{img_num}"] = {
"type": "image",
"pattern": "이미지",
"example_ref": item.get("binaryItemIDRef", ""),
"location": "body"
}
caption = item.get("text", "")
if caption:
placeholders[f"IMAGE_{img_num}_CAPTION"] = {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": caption,
"location": "body"
}
continue
# ── 문단 ──
if itype == 'paragraph':
text = item.get('text', '')
# 섹션 제목 → SECTION_n_TITLE (이미 _analyze_sections에서 등록됐을 수 있음)
if any(p.match(text) for p in sec_patterns):
section_num += 1
ph = f"SECTION_{section_num}_TITLE"
if ph not in placeholders:
placeholders[ph] = {
"type": "section_title",
"pattern": "섹션 제목",
"example": text,
"location": "body"
}
continue
# 일반 문단
para_num += 1
runs = item.get('runs', [])
if len(runs) > 1:
# 다중 run → 각 run별 placeholder
for r_idx, run in enumerate(runs):
ph = f"PARA_{para_num}_RUN_{r_idx+1}"
run_text = run.get("text", "")
info = _classify_text(run_text)
info["example"] = run_text[:100] if len(run_text) > 100 else run_text
info["location"] = "body"
info["run_index"] = r_idx + 1
placeholders[ph] = info
else:
ph = f"PARA_{para_num}"
info = _classify_text(text)
info["example"] = text[:100] if len(text) > 100 else text
info["location"] = "body"
placeholders[ph] = info
# ================================================================
# 표 분석 → placeholder + 표 가이드
# ================================================================
def _analyze_tables(template_info: dict, semantic_map: dict,
placeholders: dict, table_guide: dict):
"""본문 데이터 표 → placeholder + table_guide"""
tables = template_info.get("tables", [])
body_indices = semantic_map.get("body_tables", [])
table_roles = semantic_map.get("table_roles", {})
for tbl_num_0, tbl_idx in enumerate(body_indices):
tbl_num = tbl_num_0 + 1
tbl = next((t for t in tables if t["index"] == tbl_idx), None)
if not tbl:
continue
role_info = table_roles.get(tbl_idx, table_roles.get(str(tbl_idx), {}))
col_headers = role_info.get("col_headers", [])
col_cnt = len(col_headers) if col_headers else tbl.get("colCnt", 0)
# ── 헤더 placeholder ──
for c_idx, h_text in enumerate(col_headers):
ph = f"TABLE_{tbl_num}_H_C{c_idx+1}"
placeholders[ph] = {
"type": "table_header", "pattern": "표 열 제목",
"example": h_text, "location": f"table_{tbl_num}"
}
# ── BODY placeholder ──
placeholders[f"TABLE_{tbl_num}_BODY"] = {
"type": "table_body",
"pattern": "표 데이터 행들 (HTML <tr> 반복)",
"example": "",
"location": f"table_{tbl_num}"
}
# ── 표 가이드 ──
table_guide[str(tbl_num)] = {
"col_headers": col_headers,
"col_count": col_cnt,
"row_count": tbl.get("rowCnt", 0),
"merge_pattern": _detect_merge_pattern(tbl),
"bullet_chars": _detect_bullet_chars(tbl),
"example_rows": _extract_example_rows(tbl, role_info),
"col_types": _classify_columns(col_headers),
"row_bf_pattern": _extract_row_bf_pattern(tbl, role_info),
}
def _detect_merge_pattern(tbl: dict) -> dict:
"""셀 병합 패턴 감지"""
pattern = {}
for row in tbl.get("rows", []):
for cell in row:
col = cell.get("colAddr", 0)
if cell.get("rowSpan", 1) > 1:
pattern.setdefault(f"col_{col}", "row_group")
if cell.get("colSpan", 1) > 1:
pattern.setdefault(f"col_{col}", "col_span")
return pattern
def _detect_bullet_chars(tbl: dict) -> list:
"""표 셀 텍스트에서 불릿 문자 감지"""
bullets = set()
pats = [
(r'^-\s', '- '), (r'\s', '· '), (r'^•\s', ''),
(r'^▸\s', ''), (r'^▶\s', ''), (r'^※\s', ''),
(r'^◈\s', ''), (r'^○\s', ''), (r'^●\s', ''),
]
for row in tbl.get("rows", []):
for cell in row:
for line in cell.get("lines", []):
for pat, char in pats:
if re.match(pat, line.strip()):
bullets.add(char)
return sorted(bullets)
def _extract_example_rows(tbl: dict, role_info: dict) -> list:
"""데이터 행에서 예시 최대 3행 추출"""
rows = tbl.get("rows", [])
header_row = role_info.get("header_row")
if header_row is None:
header_row = -1
examples = []
for r_idx, row in enumerate(rows):
if r_idx <= header_row:
continue
row_data = []
for cell in row:
text = cell.get("text", "").strip()
if len(text) > 80:
text = text[:77] + "..."
row_data.append(text)
examples.append(row_data)
if len(examples) >= 3:
break
return examples
def _classify_columns(col_headers: list) -> list:
"""열 헤더 키워드로 용도 추론"""
type_map = {
"category": ['구분', '분류', '항목', '카테고리'],
"content": ['내용', '설명', '상세', '세부내용'],
"note": ['비고', '참고', '기타', '메모'],
"date": ['날짜', '일자', '일시', '기간'],
"person": ['담당', '담당자', '작성자', '책임'],
"number": ['수량', '금액', '단가', '합계'],
}
result = []
for c_idx, header in enumerate(col_headers):
h = header.strip()
col_type = "text"
for t, keywords in type_map.items():
if h in keywords:
col_type = t
break
result.append({"col": c_idx, "type": col_type, "header": h})
return result
def _extract_row_bf_pattern(tbl: dict, role_info: dict) -> list:
"""첫 데이터행의 셀별 borderFillIDRef → 열별 bf class 패턴.
AI가 TABLE_BODY <td> 생성 시 class="bf-{id}" 적용하도록 안내.
예: [{"col": 0, "bf_class": "bf-12"}, {"col": 1, "bf_class": "bf-8"}, ...]
"""
rows = tbl.get("rows", [])
header_row = role_info.get("header_row")
if header_row is None:
header_row = -1
# 첫 데이터행 찾기
for r_idx, row in enumerate(rows):
if r_idx <= header_row:
continue
pattern = []
for cell in row:
bf_id = cell.get("borderFillIDRef")
pattern.append({
"col": cell.get("colAddr", len(pattern)),
"bf_class": f"bf-{bf_id}" if bf_id else "",
"colSpan": cell.get("colSpan", 1),
"rowSpan": cell.get("rowSpan", 1),
})
return pattern
return []
# ================================================================
# 작성 패턴 분석
# ================================================================
def _analyze_writing_patterns(template_info: dict,
semantic_map: dict) -> dict:
"""문서 전체의 작성 패턴 분석"""
result = {
"bullet_styles": [],
"numbering_patterns": [],
"avg_line_length": 0,
"font_primary": "",
"font_size_body": ""
}
# ── 불릿 수집 (모든 표 텍스트) ──
all_bullets = set()
tables = template_info.get("tables", [])
for tbl in tables:
for row in tbl.get("rows", []):
for cell in row:
for line in cell.get("lines", []):
if re.match(r'^[-·•▸▶※◈○●]\s', line.strip()):
all_bullets.add(line.strip()[0] + " ")
# ── numbering tools 데이터 ──
numbering = template_info.get("numbering", {})
for num in numbering.get("numberings", []):
levels = num.get("levels", [])
patterns = [lv.get("pattern", "") for lv in levels[:3]]
if patterns:
result["numbering_patterns"].append(patterns)
for b in numbering.get("bullets", []):
char = b.get("char", "")
if char:
all_bullets.add(char + " ")
result["bullet_styles"] = sorted(all_bullets)
# ── 평균 라인 길이 ──
lengths = []
for tbl in tables:
for row in tbl.get("rows", []):
for cell in row:
for line in cell.get("lines", []):
if line.strip():
lengths.append(len(line.strip()))
# content_order 문단 텍스트도 포함
content_order = template_info.get("content_order", [])
for item in content_order:
if item['type'] == 'paragraph':
text = item.get('text', '').strip()
if text:
lengths.append(len(text))
# 불릿 감지도 추가
if re.match(r'^[-·•▸▶※◈○●]\s', text):
all_bullets.add(text[0] + " ")
if lengths:
result["avg_line_length"] = round(sum(lengths) / len(lengths))
# ── 주요 폰트 ──
fonts = template_info.get("fonts", {})
hangul = fonts.get("HANGUL", [])
if hangul and isinstance(hangul, list) and len(hangul) > 0:
result["font_primary"] = hangul[0].get("face", "")
# ── 본문 글자 크기 (char_styles id=0 기본) ──
char_styles = template_info.get("char_styles", [])
if char_styles:
result["font_size_body"] = f"{char_styles[0].get('height_pt', 10)}pt"
return result

View File

@@ -0,0 +1,555 @@
# -*- coding: utf-8 -*-
"""
사용자 정의 문서 유형 프로세서 (v2.1 - 템플릿 기반)
- template.html 로드
- config.json의 구조/가이드 활용
- 사용자 입력 내용을 템플릿에 정리하여 채움
- 창작 X, 정리/재구성 O
★ v2.1 변경사항:
- 한글 포함 placeholder 지원 (TABLE_1_H_구분 등)
- TABLE_*_BODY / TABLE_*_H_* placeholder 구분 처리
- 개조식 항목 <ul class="bullet-list"> 래핑
- 페이지 분량 제한 프롬프트 강화
- 헤더/푸터 다중행 placeholder 설명 추가
"""
import json
import re
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from .template_manager import TemplateManager
from pathlib import Path
from .common import call_claude, extract_html
# ★ 한글 포함 placeholder 정규식 (영문 + 숫자 + 언더스코어 + 한글)
PH_PATTERN = re.compile(r'\{\{([A-Za-z0-9_\uAC00-\uD7AF]+)\}\}')
class CustomDocTypeProcessor:
"""사용자 정의 문서 유형 처리기 (템플릿 기반)"""
def __init__(self):
self.doc_types_user = Path('templates/user/doc_types')
self.template_manager = TemplateManager()
def load_config(self, doc_type_id: str) -> dict:
"""config.json 로드"""
config_path = self.doc_types_user / doc_type_id / 'config.json'
if not config_path.exists():
raise FileNotFoundError(f"문서 유형을 찾을 수 없습니다: {doc_type_id}")
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def load_content_prompt(self, doc_type_id: str, template_id: str = None) -> dict:
"""content_prompt.json 로드 (doc_type 우선 → template fallback)"""
# ① doc_type 폴더
path = self.doc_types_user / doc_type_id / 'content_prompt.json'
if path.exists():
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
# ② template 폴더 fallback
if template_id:
tpl_path = Path('templates/user/templates') / template_id / 'content_prompt.json'
if tpl_path.exists():
with open(tpl_path, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
def load_template(self, doc_type_id: str) -> str:
"""template.html 로드 — template_manager 경유 (분리 구조)"""
# ① config에서 template_id 확인
config = self.load_config(doc_type_id)
tpl_id = config.get("template_id")
if tpl_id:
# ★ 새 구조: template_manager에서 로드
tpl_data = self.template_manager.load_template(tpl_id)
if "html" in tpl_data:
return tpl_data["html"]
# ★ 하위 호환: 레거시 방식 (같은 폴더의 template.html)
template_path = self.doc_types_user / doc_type_id / 'template.html'
if template_path.exists():
with open(template_path, 'r', encoding='utf-8') as f:
return f.read()
return None
def generate(self, content: str, doc_type_id: str, options: dict = None,
image_data: dict = None) -> dict:
"""문서 생성 - 템플릿 + 사용자 입력
Args:
content: 사용자 입력 텍스트
doc_type_id: 문서 유형 ID
options: 추가 옵션 (instruction 등)
image_data: 이미지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}}
None이면 템플릿 폴더에서 자동 로드 시도
"""
try:
config = self.load_config(doc_type_id)
template = self.load_template(doc_type_id)
if template:
# 이미지 데이터 준비
if image_data is None:
image_data = self._load_image_data(config)
result = self._generate_with_template(
content, config, template, options, image_data
)
else:
result = self._generate_with_guide(content, config, options)
return result
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()}
def _generate_with_template(self, content: str, config: dict,
template: str, options: dict,
image_data: dict = None) -> dict:
"""템플릿 기반 생성 — content_prompt.json 활용"""
context = config.get('context', {})
structure = config.get('structure', {})
instruction = options.get('instruction', '') if options else ''
# ★ content_prompt 로드
doc_type_id = config.get('id', '')
template_id = config.get('template_id', '')
cp = self.load_content_prompt(doc_type_id, template_id)
placeholders_info = cp.get('placeholders', {})
table_guide = cp.get('table_guide', {})
writing_guide = cp.get('writing_guide', {})
doc_info = cp.get('document', {})
# ★ placeholder 가이드 생성 (type/pattern/example 포함)
ph_guide_lines = []
for ph_key, ph_info in placeholders_info.items():
ph_type = ph_info.get('type', 'text')
pattern = ph_info.get('pattern', '')
example = ph_info.get('example', '')
location = ph_info.get('location', '')
line = f" {ph_key}:"
line += f"\n type: {ph_type}"
line += f"\n pattern: {pattern}"
if example:
line += f"\n example: \"{example}\""
line += f"\n location: {location}"
ph_guide_lines.append(line)
ph_guide = "\n".join(ph_guide_lines) if ph_guide_lines else "(no guide available)"
# ★ 표 가이드 생성
tbl_guide_lines = []
for tbl_num, tbl_info in table_guide.items():
headers = tbl_info.get('col_headers', [])
col_types = tbl_info.get('col_types', [])
merge = tbl_info.get('merge_pattern', {})
bullets = tbl_info.get('bullet_chars', [])
examples = tbl_info.get('example_rows', [])
tbl_guide_lines.append(f"\n### Table {tbl_num}:")
tbl_guide_lines.append(f" Columns: {json.dumps(headers, ensure_ascii=False)}")
if col_types:
for ct in col_types:
tbl_guide_lines.append(
f" Col {ct['col']} '{ct['header']}': {ct['type']}")
if merge:
tbl_guide_lines.append(f" Merge: {json.dumps(merge, ensure_ascii=False)}")
tbl_guide_lines.append(
f" → row_group means: use rowspan to group rows by that column")
if bullets:
tbl_guide_lines.append(f" Bullet chars: {bullets}")
# ★ row_bf_pattern 추가
bf_pattern = tbl_info.get('row_bf_pattern', [])
if bf_pattern:
tbl_guide_lines.append(f" Row cell classes (apply to each <td>):")
for bp in bf_pattern:
col = bp.get('col', '?')
bf_cls = bp.get('bf_class', '')
cs = bp.get('colSpan', 1)
rs = bp.get('rowSpan', 1)
span_info = ""
if cs > 1: span_info += f" colSpan={cs}"
if rs > 1: span_info += f" rowSpan={rs}"
tbl_guide_lines.append(
f' col_{col}: class="{bf_cls}"{span_info}')
if examples:
tbl_guide_lines.append(f" Example rows:")
for ex in examples[:2]:
tbl_guide_lines.append(
f" {json.dumps(ex, ensure_ascii=False)}")
tbl_guide = "\n".join(tbl_guide_lines) if tbl_guide_lines else "No table guide"
# ★ 페이지 추정
page_estimate = structure.get('pageEstimate', 1)
# ★ placeholder 키 목록 (from template)
placeholders = PH_PATTERN.findall(template)
placeholders = list(dict.fromkeys(placeholders))
prompt = f"""Fill the template placeholders with reorganized content.
## Document Definition
{context.get('documentDefinition', 'structured document')}
## Context
- Type: {context.get('documentType', '')}
- Purpose: {context.get('purpose', '')}
- Audience: {context.get('audience', '')}
- Tone: {context.get('tone', '')}
- Layout: {doc_info.get('layout', 'portrait')}
- Page limit: {page_estimate} page(s). Be CONCISE.
## Writing Style
- Bullet chars: {writing_guide.get('bullet_styles', ['- ', '· '])}
- Primary font: {writing_guide.get('font_primary', '')}
- Keep lines ~{writing_guide.get('avg_line_length', 25)} chars average
## Placeholder Guide (type, pattern, example for each)
{ph_guide}
## Table Structure Guide
{tbl_guide}
## Input Content
{content[:6000] if content else '(empty)'}
## Additional Instructions
{instruction if instruction else 'None'}
## ALL Placeholders to fill (JSON keys):
{json.dumps(placeholders, ensure_ascii=False)}
## ★ Critical Rules
1. Output ONLY valid JSON — every placeholder above as a key
2. HEADER/FOOTER: use the PATTERN and modify the EXAMPLE for new content
- department → user's department or keep example
- author → user's name or keep example
- date → today's date in same format
- slogan → keep exactly as example
3. TITLE: create title matching doc_title pattern from input content
4. TABLE_*_H_*: plain text column headers (use col_headers from guide)
5. TABLE_*_BODY: HTML <tr> rows only (no <table> wrapper)
- Follow merge_pattern: row_group → use rowspan
- Use bullet_chars from guide inside cells
- Match example_rows structure
5b. TABLE_*_BODY <td>: apply class from 'Row cell classes' guide\n
- e.g. <td class=\"bf-12\">content</td>\n
6. SECTION_*_CONTENT: use bullet style from writing guide
7. Empty string "" for inapplicable placeholders
8. Do NOT invent content — reorganize input only
9. PARA_*: reorganize input text for each paragraph placeholder
- Keep the meaning, improve clarity and structure
- PARA_n_RUN_m: if a paragraph has multiple runs, fill each run separately
10. IMAGE_*: output exactly "KEEP_ORIGINAL" (image is auto-inserted from source)
11. IMAGE_*_CAPTION: write a concise caption describing the image context
12. Total volume: {page_estimate} page(s)
Output ONLY valid JSON:"""
try:
response = call_claude(
"You fill document template placeholders with reorganized content. "
"Output valid JSON only. Respect the template structure exactly.",
prompt,
max_tokens=6000
)
fill_data = self._extract_json(response)
if not fill_data:
return {'error': 'JSON extraction failed', 'raw': response[:500]}
html = self._fill_template(template, fill_data, image_data)
return {'success': True, 'html': html}
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()}
def _fill_template(self, template: str, data: dict,
image_data: dict = None) -> str:
"""템플릿에 데이터 채우기
Args:
template: HTML 템플릿
data: AI가 채운 placeholder → value dict
image_data: 이미지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}}
"""
html = template
# ★ content_prompt에서 IMAGE_n → binaryItemIDRef 매핑 빌드
image_ref_map = self._build_image_ref_map(data, image_data)
for key, value in data.items():
placeholder = '{{' + key + '}}'
# ── IMAGE_n: 원본 이미지 삽입 ──
if re.match(r'^IMAGE_\d+$', key):
img_tag = image_ref_map.get(key, '')
html = html.replace(placeholder, img_tag)
continue
if isinstance(value, str) and value.strip():
# ★ 개조식 내용 처리: · 또는 - 로 시작하는 항목
lines = value.strip().split('\n')
is_bullet_list = sum(
1 for l in lines
if l.strip().startswith('·') or l.strip().startswith('-')
) > len(lines) * 0.5
if is_bullet_list and len(lines) > 1:
# ★ v2.2: inline context (<p><span> 안)에서는 <ul> 금지
# PARA_*, SECTION_*_TITLE, HEADER_*, FOOTER_*, TITLE_*, *_RUN_*
# 이들은 <p> 또는 <td> 안에 있어 block 요소 삽입 시 HTML 깨짐
_is_inline = re.match(
r'^(PARA_|SECTION_\d+_TITLE|HEADER_|FOOTER_|TITLE_|.*_RUN_)',
key
)
if _is_inline:
# <br> 줄바꿈으로 구조 보존
clean_lines = []
for item in lines:
item = item.strip()
if item.startswith('·'):
item = item[1:].strip()
elif item.startswith('-'):
item = item[1:].strip()
if item:
clean_lines.append(f'· {item}')
value = '<br>\n'.join(clean_lines)
else:
# <div> 안 (SECTION_*_CONTENT 등) → <ul><li> 허용
li_items = []
for item in lines:
item = item.strip()
if item.startswith('·'):
item = item[1:].strip()
elif item.startswith('-'):
item = item[1:].strip()
if item:
li_items.append(f'<li>{item}</li>')
value = '<ul class="bullet-list">\n' + '\n'.join(li_items) + '\n</ul>'
html = html.replace(placeholder, str(value) if value else '')
# ★ 남은 placeholder 정리 (한글 포함)
html = PH_PATTERN.sub('', html)
return html
def _build_image_ref_map(self, data: dict, image_data: dict = None) -> dict:
"""IMAGE_n placeholder → <img> 태그 매핑 생성.
content_prompt.json의 placeholders에서 IMAGE_n의 example_ref
(= binaryItemIDRef)를 찾고, image_data에서 base64를 가져옴.
"""
ref_map = {}
if not image_data:
return ref_map
# content_prompt placeholders에서 IMAGE_n → ref 매핑
# (generate 호출 시 content_prompt를 아직 안 가지고 있으므로
# template HTML의 data-ref 속성 또는 순서 매칭으로 해결)
# 방법: template에서 IMAGE_1, IMAGE_2... 순서와
# image_data의 키 순서를 매칭
# image_data 키 목록 (BinData 등장 순서)
img_refs = sorted(image_data.keys())
img_num = 0
for ref in img_refs:
img_num += 1
key = f"IMAGE_{img_num}"
img_info = image_data[ref]
b64 = img_info.get("base64", "")
mime = img_info.get("mime", "image/png")
if b64:
ref_map[key] = (
f'<img src="data:{mime};base64,{b64}" '
f'alt="{ref}" style="max-width:100%; height:auto;">'
)
else:
# base64 없으면 파일 경로 참조
file_path = img_info.get("path", "")
if file_path:
ref_map[key] = (
f'<img src="{file_path}" '
f'alt="{ref}" style="max-width:100%; height:auto;">'
)
else:
ref_map[key] = f'<!-- image not found: {ref} -->'
return ref_map
def _load_image_data(self, config: dict) -> dict:
"""템플릿 폴더에서 images.json 로드 (BinData 추출 결과).
images.json 구조:
{
"IMG001": {"base64": "iVBOR...", "mime": "image/png"},
"IMG002": {"base64": "...", "mime": "image/jpeg"}
}
또는 이미지 파일이 직접 저장된 경우 경로를 반환.
"""
tpl_id = config.get("template_id", "")
if not tpl_id:
return {}
tpl_path = Path('templates/user/templates') / tpl_id
# ① images.json (base64 저장 방식)
images_json = tpl_path / 'images.json'
if images_json.exists():
try:
with open(images_json, 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
# ② images/ 폴더 (파일 저장 방식)
images_dir = tpl_path / 'images'
if images_dir.exists():
result = {}
mime_map = {
'.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.bmp': 'image/bmp', '.svg': 'image/svg+xml',
'.wmf': 'image/x-wmf', '.emf': 'image/x-emf',
}
for img_file in sorted(images_dir.iterdir()):
if img_file.suffix.lower() in mime_map:
ref = img_file.stem # 파일명 = binaryItemIDRef
result[ref] = {
"path": str(img_file),
"mime": mime_map.get(img_file.suffix.lower(), "image/png")
}
return result
return {}
def _extract_json(self, response: str) -> dict:
"""응답에서 JSON 추출"""
# ```json ... ``` 블록 찾기
match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
if match:
try:
return json.loads(match.group(1))
except:
pass
# 가장 큰 { } 블록 찾기
brace_depth = 0
start = -1
for i, ch in enumerate(response):
if ch == '{':
if brace_depth == 0:
start = i
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0 and start >= 0:
try:
return json.loads(response[start:i+1])
except:
start = -1
return None
def _generate_with_guide(self, content: str, config: dict, options: dict) -> dict:
"""가이드 기반 생성 (템플릿 없을 때)"""
context = config.get('context', {})
structure = config.get('structure', {})
layout = config.get('layout', {})
style = config.get('style', {})
instruction = options.get('instruction', '') if options else ''
# 섹션 구조 설명
sections = layout.get('sections', [])
sections_desc = ""
for i, sec in enumerate(sections, 1):
sections_desc += f"""
{i}. {sec.get('name', f'섹션{i}')}
- 작성 스타일: {sec.get('writingStyle', '혼합')}
- 불릿: {'있음' if sec.get('hasBulletIcon') else '없음'}
- 표: {'있음' if sec.get('hasTable') else '없음'}
- 내용: {sec.get('contentDescription', '')}
"""
page_estimate = structure.get('pageEstimate', 1)
system_prompt = f"""당신은 "{context.get('documentType', '문서')}" 작성 전문가입니다.
## 문서 특성
- 목적: {context.get('purpose', '')}
- 대상: {context.get('audience', '')}
- 톤: {context.get('tone', '')}
- 전체 스타일: {structure.get('writingStyle', '혼합')}
- 분량: 약 {page_estimate}페이지
## 문서 구조
{sections_desc}
## 작성 원칙
{chr(10).join('- ' + p for p in structure.get('writingPrinciples', []))}
## 주의사항
{chr(10).join('- ' + m for m in structure.get('commonMistakes', []))}
## 핵심!
- 사용자 입력을 **정리/재구성**하세요
- **새로 창작하지 마세요**
- 분석된 문서 구조를 그대로 따르세요
- 개조식 섹션은 "· " 불릿 사용
- 분량을 {page_estimate}페이지 내로 제한하세요"""
user_prompt = f"""다음 내용을 "{context.get('documentType', '문서')}" 양식으로 정리해주세요.
## 입력 내용
{content[:6000] if content else '(내용 없음)'}
## 추가 요청
{instruction if instruction else '없음'}
## 출력 형식
완전한 A4 규격 HTML 문서로 출력하세요.
- <!DOCTYPE html>로 시작
- UTF-8 인코딩
- @page {{ size: A4 }} CSS 포함
- 폰트: {style.get('font', {}).get('name', '맑은 고딕')}
- 머릿말/꼬리말 포함
- 약 {page_estimate}페이지 분량
HTML만 출력하세요."""
try:
response = call_claude(system_prompt, user_prompt, max_tokens=6000)
html = extract_html(response)
if not html:
return {'error': 'HTML 생성 실패'}
return {'success': True, 'html': html}
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()}

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
문서 템플릿 분석기 v5.1 (오케스트레이터)
역할: tools/ 모듈을 조합하여 HWPX → 템플릿 정보 추출
- 직접 파싱 로직 없음 (모두 tools에 위임)
- 디폴트값 생성 없음 (tools가 None 반환하면 결과에서 제외)
- 사용자 추가 사항(config.json) → 템플릿에도 반영
구조:
tools/
page_setup.py §7 용지/여백
font.py §3 글꼴
char_style.py §4 글자 모양
para_style.py §5 문단 모양
border_fill.py §2 테두리/배경
table.py §6 표
header_footer.py §8 머리말/꼬리말
section.py §9 구역 정의
style_def.py 스타일 정의
numbering.py 번호매기기/글머리표
image.py 이미지
"""
import json
from pathlib import Path
from typing import Optional
from .tools import (
page_setup,
font,
char_style,
para_style,
border_fill,
table,
header_footer,
section,
style_def,
numbering,
image,
content_order,
)
class DocTemplateAnalyzer:
"""HWPX → 템플릿 추출 오케스트레이터"""
# ================================================================
# Phase 1: 추출 (모든 tools 호출)
# ================================================================
def analyze(self, parsed: dict) -> dict:
"""HWPX parsed 결과에서 템플릿 구조 추출.
Args:
parsed: processor.py가 HWPX를 파싱한 결과 dict.
raw_xml, section_xml, header_xml, footer_xml,
tables, paragraphs 등 포함.
Returns:
추출된 항목만 포함하는 dict (None인 항목은 제외).
"""
raw_xml = parsed.get("raw_xml", {})
extractors = {
"page": lambda: page_setup.extract(raw_xml, parsed),
"fonts": lambda: font.extract(raw_xml, parsed),
"char_styles": lambda: char_style.extract(raw_xml, parsed),
"para_styles": lambda: para_style.extract(raw_xml, parsed),
"border_fills": lambda: border_fill.extract(raw_xml, parsed),
"tables": lambda: table.extract(raw_xml, parsed),
"header": lambda: header_footer.extract_header(raw_xml, parsed),
"footer": lambda: header_footer.extract_footer(raw_xml, parsed),
"section": lambda: section.extract(raw_xml, parsed),
"styles": lambda: style_def.extract(raw_xml, parsed),
"numbering": lambda: numbering.extract(raw_xml, parsed),
"images": lambda: image.extract(raw_xml, parsed),
"content_order":lambda: content_order.extract(raw_xml, parsed),
}
result = {}
for key, extractor in extractors.items():
try:
value = extractor()
if value is not None:
result[key] = value
except Exception as e:
# 개별 tool 실패 시 로그만, 전체 중단 안 함
result.setdefault("_errors", []).append(
f"{key}: {type(e).__name__}: {e}"
)
return result
# ================================================================
# Phase 2: 사용자 추가 사항 병합
# ================================================================
def merge_user_config(self, template_info: dict,
config: dict) -> dict:
"""config.json의 사용자 요구사항을 template_info에 병합.
사용자가 문서 유형 추가 시 지정한 커스텀 사항을 반영:
- 색상 오버라이드
- 글꼴 오버라이드
- 제목 크기 오버라이드
- 기타 레이아웃 커스텀
이 병합 결과는 style.json에 저장되고,
이후 template.html 생성 시에도 반영됨.
Args:
template_info: analyze()의 결과
config: config.json 내용
Returns:
병합된 template_info (원본 수정됨)
"""
user_overrides = config.get("user_overrides", {})
if not user_overrides:
return template_info
# 모든 사용자 오버라이드를 template_info에 기록
template_info["user_overrides"] = user_overrides
return template_info
# ================================================================
# Phase 3: template_info → style.json 저장
# ================================================================
def save_style(self, template_info: dict,
save_path: Path) -> Path:
"""template_info를 style.json으로 저장.
Args:
template_info: analyze() + merge_user_config() 결과
save_path: 저장 경로 (예: templates/user/{doc_type}/style.json)
Returns:
저장된 파일 경로
"""
save_path = Path(save_path)
save_path.parent.mkdir(parents=True, exist_ok=True)
with open(save_path, 'w', encoding='utf-8') as f:
json.dump(template_info, f, ensure_ascii=False, indent=2)
return save_path

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
"""
보고서(report) 처리 모듈
"""
from .processor import ReportProcessor

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""
보고서(report) 처리 로직
- 다페이지 보고서
- 원본 구조 유지
- RAG 파이프라인 연동 (긴 문서)
"""
import os
import re
from pathlib import Path
from flask import session
from handlers.common import call_claude, extract_html, load_prompt, client
from converters.pipeline.router import process_document, convert_image_paths
class ReportProcessor:
"""보고서 처리 클래스"""
def __init__(self):
self.prompts_dir = Path(__file__).parent / 'prompts'
def _load_prompt(self, filename: str) -> str:
"""프롬프트 로드"""
return load_prompt(str(self.prompts_dir), filename)
def generate(self, content: str, options: dict) -> dict:
"""보고서 생성"""
try:
if not content.strip():
return {'error': '내용이 비어있습니다.'}
# ⭐ 템플릿 스타일 로드
template_id = options.get('template_id')
if template_id:
from handlers.template import TemplateProcessor
template_processor = TemplateProcessor()
style = template_processor.get_style(template_id)
if style and style.get('css'):
options['template_css'] = style['css']
# 이미지 경로 변환
processed_html = convert_image_paths(content)
# router를 통해 분량에 따라 파이프라인 분기
result = process_document(processed_html, options)
if result.get('success'):
session['original_html'] = content
session['current_html'] = result.get('html', '')
return result
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()}
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
"""피드백 반영"""
try:
if not feedback.strip():
return {'error': '피드백 내용을 입력해주세요.'}
if not current_html:
return {'error': '수정할 HTML이 없습니다.'}
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
## 규칙
1. 피드백에서 언급된 부분만 정확히 수정
2. **페이지 구조(sheet, body-content, page-header 등)는 절대 변경하지 마세요**
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
4. 코드 블록(```) 없이 순수 HTML만 출력
## 현재 HTML
{current_html}
## 사용자 피드백
{feedback}
---
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
response = call_claude("", refine_prompt, max_tokens=8000)
new_html = extract_html(response)
session['current_html'] = new_html
return {
'success': True,
'html': new_html
}
except Exception as e:
return {'error': str(e)}
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
"""선택된 부분만 수정 (보고서용 - 페이지 구조 보존)"""
try:
if not current_html or not selected_text or not user_request:
return {'error': '필수 데이터가 없습니다.'}
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=8000,
messages=[{
"role": "user",
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
## 전체 문서 (컨텍스트 파악용)
{current_html[:5000]}
## 수정 대상 텍스트
"{selected_text}"
## 수정 요청
{user_request}
## 규칙
1. **절대로 페이지 구조(sheet, body-content, page-header, page-footer)를 변경하지 마세요**
2. 선택된 텍스트만 수정하고, 주변 HTML 태그는 그대로 유지
3. 요청을 분석하여 수정 유형을 판단:
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가 등)
4. 반드시 다음 형식으로만 출력:
TYPE: (TEXT 또는 STRUCTURE)
CONTENT:
(수정된 내용만 - 선택된 텍스트의 수정본만)
5. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이, 선택된 텍스트의 수정본만)
6. STRUCTURE인 경우: 해당 요소만 출력 (전체 페이지 구조 X)
7. 개조식 문체 유지 (~임, ~함, ~필요)
"""
}]
)
result = message.content[0].text
result = result.replace('```html', '').replace('```', '').strip()
edit_type = 'TEXT'
content = result
if 'TYPE:' in result and 'CONTENT:' in result:
type_line = result.split('CONTENT:')[0]
if 'STRUCTURE' in type_line:
edit_type = 'STRUCTURE'
content = result.split('CONTENT:')[1].strip()
return {
'success': True,
'type': edit_type,
'html': content
}
except Exception as e:
return {'error': str(e)}

View File

@@ -0,0 +1,104 @@
당신은 임원보고용 문서 구성 전문가입니다.
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
## 입력
- step1에서 추출된 JSON 구조 데이터
## 출력
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
---
## 배치 원칙
### 1페이지 (본문) - "왜? 무엇이 문제?"
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
### 2페이지~ (첨부) - "어떻게? 상세 기준"
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
- **bottom-box**: 해당 페이지 내용 요약
---
## 요소 역할 분류 기준
| 역할 | 설명 | 배치 |
|------|------|------|
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
| 프로세스 | 단계별 절차, Step | 첨부 |
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
| 체크리스트 | 항목별 점검사항 | 첨부 |
| 상세가이드 | 세부 설명, 예시 | 첨부 |
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
---
## 출력 JSON 스키마
```json
{
"page_plan": {
"page_1": {
"type": "본문",
"lead": {
"source_section": "원본 섹션명 또는 null",
"text": "lead-box에 들어갈 핵심 명제 문장"
},
"sections": [
{
"source": "원본 섹션 제목",
"role": "논리/근거 | 리스크 | 주의사항",
"new_title": "변환 후 섹션 제목 (필요시 수정)"
}
],
"bottom": {
"label": "핵심 결론",
"source": "원본에서 가져올 문장 또는 조합할 키워드",
"text": "bottom-box에 들어갈 최종 문장"
}
},
"page_2": {
"type": "첨부",
"title": "[첨부] 페이지 제목",
"sections": [
{
"source": "원본 섹션 제목",
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
"new_title": "변환 후 섹션 제목"
}
],
"bottom": {
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
"source": "원본에서 가져올 문장",
"text": "bottom-box에 들어갈 최종 문장"
}
}
},
"page_count": 2
}
```
---
## 판단 규칙
1. **프로세스/Step 있으면** → 무조건 첨부로
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
---
## 주의사항
1. 원본에 없는 내용 추가/추론 금지
2. 원본 문장을 선별/조합만 허용
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
4. JSON만 출력 (설명 없이)

View File

@@ -0,0 +1,382 @@
# -*- coding: utf-8 -*-
"""
Semantic Mapper v1.0
HWPX tools 추출 결과(template_info)에서 각 요소의 "의미"를 판별.
역할:
- 표 분류: 헤더표 / 푸터표 / 제목블록 / 데이터표
- 섹션 감지: 본문 텍스트에서 섹션 패턴 탐색
- 스타일 매핑 준비: charPr→HTML태그, borderFill→CSS클래스 (Phase 2에서 구현)
입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결과)
출력: semantic_map dict → semantic_map.json으로 저장
★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토리
★ 호출: template_manager.extract_and_save() 내에서 analyze() 직후
"""
import re
# ================================================================
# 메인 엔트리포인트
# ================================================================
def generate(template_info: dict, parsed: dict) -> dict:
"""semantic_map 생성 — 모든 판별 로직 조합.
Args:
template_info: DocTemplateAnalyzer.analyze() 결과
parsed: HWPX 파서 결과 (raw_xml, section_xml, paragraphs 등)
Returns:
{
"version": "1.0",
"table_roles": { "0": {"role": "footer_table", ...}, ... },
"body_tables": [3], # 본문에 들어갈 표 index 목록
"title_table": 2, # 제목 블록 index (없으면 None)
"sections": [...], # 감지된 섹션 목록
"style_mappings": {...}, # Phase 2용 스타일 매핑 (현재 빈 구조)
}
"""
tables = template_info.get("tables", [])
header = template_info.get("header")
footer = template_info.get("footer")
# ① 표 역할 분류
table_roles = _classify_tables(tables, header, footer)
# ② 본문 전용 표 / 제목 블록 추출
body_tables = sorted(
idx for idx, info in table_roles.items()
if info["role"] == "data_table"
)
title_table = next(
(idx for idx, info in table_roles.items()
if info["role"] == "title_block"),
None
)
# ③ 섹션 감지
sections = _detect_sections(parsed)
# ④ 스타일 매핑 (Phase 2에서 구현, 현재는 빈 구조)
style_mappings = _prepare_style_mappings(template_info)
return {
"version": "1.0",
"table_roles": table_roles,
"body_tables": body_tables,
"title_table": title_table,
"sections": sections,
"style_mappings": style_mappings,
}
# ================================================================
# 표 분류
# ================================================================
def _classify_tables(tables: list, header: dict | None,
footer: dict | None) -> dict:
"""각 표의 역할 판별: header_table / footer_table / title_block / data_table
판별 순서:
Pass 1 — header/footer 텍스트 매칭
Pass 2 — 제목 블록 패턴 (1행, 좁은+넓은 열 구조)
Pass 3 — 나머지 → 데이터 표
"""
header_texts = _collect_hf_texts(header)
footer_texts = _collect_hf_texts(footer)
roles = {}
classified = set()
# ── Pass 1: header/footer 매칭 ──
for tbl in tables:
idx = tbl["index"]
tbl_texts = _collect_table_texts(tbl)
if not tbl_texts:
continue
# header 매칭
if header_texts:
overlap = len(tbl_texts & header_texts)
if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5:
roles[idx] = {
"role": "header_table",
"match_source": "header",
"matched_texts": list(tbl_texts & header_texts),
}
classified.add(idx)
continue
# footer 매칭
if footer_texts:
overlap = len(tbl_texts & footer_texts)
if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5:
roles[idx] = {
"role": "footer_table",
"match_source": "footer",
"matched_texts": list(tbl_texts & footer_texts),
}
classified.add(idx)
continue
# ── Pass 2: 제목 블록 탐지 ──
for tbl in tables:
idx = tbl["index"]
if idx in classified:
continue
if _is_title_block(tbl):
title_text = _extract_longest_text(tbl)
roles[idx] = {
"role": "title_block",
"title_text": title_text,
}
classified.add(idx)
continue
# ── Pass 3: 나머지 → 데이터 표 ──
for tbl in tables:
idx = tbl["index"]
if idx in classified:
continue
col_headers = _detect_table_headers(tbl)
roles[idx] = {
"role": "data_table",
"header_row": 0 if col_headers else None,
"col_headers": col_headers,
"row_count": tbl.get("rowCnt", 0),
"col_count": tbl.get("colCnt", 0),
}
return roles
# ── 표 분류 보조 함수 ──
def _collect_hf_texts(hf_info: dict | None) -> set:
"""header/footer의 table 셀 텍스트 수집"""
if not hf_info or not hf_info.get("table"):
return set()
texts = set()
for row in hf_info["table"].get("rows", []):
for cell in row:
t = cell.get("text", "").strip()
if t:
texts.add(t)
return texts
def _collect_table_texts(tbl: dict) -> set:
"""표의 모든 셀 텍스트 수집"""
texts = set()
for row in tbl.get("rows", []):
for cell in row:
t = cell.get("text", "").strip()
if t:
texts.add(t)
return texts
def _extract_longest_text(tbl: dict) -> str:
"""표에서 가장 긴 텍스트 추출 (제목 블록용)"""
longest = ""
for row in tbl.get("rows", []):
for cell in row:
t = cell.get("text", "").strip()
if len(t) > len(longest):
longest = t
return longest
def _is_title_block(tbl: dict) -> bool:
"""제목 블록 패턴 판별.
조건 (하나라도 충족):
A) 1행 2열, 왼쪽 열 비율 ≤ 10% (불릿아이콘 + 제목)
B) 1행 1열, 텍스트 길이 5~100자 (제목 단독)
"""
if tbl.get("rowCnt", 0) != 1:
return False
col_cnt = tbl.get("colCnt", 0)
col_pcts = tbl.get("colWidths_pct", [])
# 패턴 A: 좁은 왼쪽 + 넓은 오른쪽
if col_cnt == 2 and len(col_pcts) >= 2:
if col_pcts[0] <= 10:
return True
# 패턴 B: 단일 셀 제목
if col_cnt == 1:
rows = tbl.get("rows", [])
if rows and rows[0]:
text = rows[0][0].get("text", "")
if 5 < len(text) < 100:
return True
return False
def _detect_table_headers(tbl: dict) -> list:
"""표 첫 행의 컬럼 헤더 텍스트 반환.
헤더 판별: 첫 행의 모든 텍스트가 짧음 (20자 이하)
"""
rows = tbl.get("rows", [])
if not rows or len(rows) < 2:
return []
first_row = rows[0]
headers = []
for cell in first_row:
t = cell.get("text", "").strip()
headers.append(t)
# 전부 짧은 텍스트이면 헤더행
if headers and all(len(h) <= 20 for h in headers if h):
non_empty = [h for h in headers if h]
if non_empty: # 최소 1개는 텍스트가 있어야
return headers
return []
# ================================================================
# 섹션 감지
# ================================================================
_SECTION_PATTERNS = [
(r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요"
(r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ][\.\s]+(.+)', "roman"), # ". 개요"
(r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요"
(r'^[▶►▸●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요"
]
def _detect_sections(parsed: dict) -> list:
"""parsed 텍스트에서 섹션 제목 패턴 탐색.
Returns:
[
{"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"},
{"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"},
...
]
"""
paragraphs = _extract_paragraphs(parsed)
sections = []
sec_idx = 0
for text in paragraphs:
text = text.strip()
if not text or len(text) > 100:
# 너무 긴 텍스트는 제목이 아님
continue
for pat, pat_type in _SECTION_PATTERNS:
m = re.match(pat, text)
if m:
# numbered 패턴: 숫자가 100 이상이면 섹션 번호가 아님 (연도 등 제외)
if pat_type == "numbered" and int(m.group(1)) > 99:
continue
sec_idx += 1
sections.append({
"index": sec_idx,
"title": text,
"pattern_type": pat_type,
})
break
return sections
def _extract_paragraphs(parsed: dict) -> list:
"""parsed에서 텍스트 단락 추출.
우선순위:
1. parsed["paragraphs"] (파서가 직접 제공)
2. section_xml의 <hp:t> 태그에서 추출
"""
paragraphs = parsed.get("paragraphs", [])
if paragraphs:
return [
p.get("text", "") if isinstance(p, dict) else str(p)
for p in paragraphs
]
# section_xml에서 <hp:t> 추출
section_xml = ""
raw_xml = parsed.get("raw_xml", {})
for key, val in raw_xml.items():
if "section" in key.lower() and isinstance(val, str):
section_xml = val
break
if not section_xml:
section_xml = parsed.get("section_xml", "")
if section_xml:
return [
t.strip()
for t in re.findall(r'<hp:t>([^<]+)</hp:t>', section_xml)
if t.strip()
]
return []
# ================================================================
# 스타일 매핑 (Phase 2에서 확장)
# ================================================================
def _prepare_style_mappings(template_info: dict) -> dict:
"""스타일 매핑 빈 구조 생성.
Phase 2에서 이 구조를 채움:
- char_styles → CSS font/color rules
- border_fills → CSS border/background rules
- para_styles → CSS margin/alignment rules
"""
mappings = {
"char_pr": {},
"border_fill": {},
"para_pr": {},
}
# border_fills가 있으면 기본 매핑 생성
border_fills = template_info.get("border_fills", {})
for bf_id, bf_data in border_fills.items():
# ★ 실제 키 구조 대응 (bg→background, sides→css/직접키)
bg = bf_data.get("background", bf_data.get("bg", ""))
# borders: css dict 또는 직접 키에서 추출
borders = {}
css_dict = bf_data.get("css", {})
if css_dict:
for prop, val in css_dict.items():
if prop.startswith("border-") and val and val != "none":
borders[prop] = val
else:
# fallback: 직접 side 키
for side in ("top", "bottom", "left", "right"):
si = bf_data.get(side, {})
if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE":
borders[f"border-{side}"] = (
f"{si.get('width','0.1mm')} "
f"{si.get('type','solid').lower()} "
f"{si.get('color','#000')}"
)
mappings["border_fill"][str(bf_id)] = {
"css_class": f"bf-{bf_id}",
"bg": bg,
"borders": borders,
}
return mappings

View File

@@ -0,0 +1,824 @@
# -*- coding: utf-8 -*-
"""
Style Generator v2.1 (Phase 4 — 하드코딩 제거)
template_info의 tools 추출값 → CSS 문자열 생성.
★ v2.1 변경사항:
- 하드코딩 간격 → 추출값 대체:
· .doc-header margin-bottom → page.margins.header에서 계산
· .doc-footer margin-top → page.margins.footer에서 계산
· .title-block margin/padding → title paraPr spacing에서 유도
- .img-wrap, .img-caption CSS 추가 (content_order 이미지 지원)
★ v2.0 변경사항 (v1.0 대비):
- charPr 28개 전체 → .cpr-{id} CSS 클래스 생성
- paraPr 23개 전체 → .ppr-{id} CSS 클래스 생성
- styles 12개 → .sty-{id} CSS 클래스 (charPr + paraPr 조합)
- fontRef → 실제 폰트명 해석 (font_map 빌드)
- 제목 블록: 하드코딩 제거 → 실제 추출 데이터 사용
- 줄간격: paraPr별 line-height 개별 적용
- 여백: @page는 인쇄용, .page는 화면용 (이중 적용 제거)
- bf CSS: NONE-only borderFill도 클래스 생성 (border: none 명시)
- 텍스트 색상: charPr별 color 반영
- 폰트: charPr별 fontRef → 실제 font-family 해석
★ 원칙: hwpx_domain_guide.md §1~§8 매핑 규칙 100% 준수
★ 원칙: 하드코딩 값 0개. 모든 CSS 값은 template_info에서 유래.
"""
HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm
# ================================================================
# 메인 엔트리포인트
# ================================================================
def generate_css(template_info: dict, semantic_map: dict = None) -> str:
"""template_info + semantic_map → CSS 문자열 전체 생성."""
# font_map 빌드 (charPr CSS에서 재사용)
fm = _build_font_map(template_info)
parts = [
_page_css(template_info),
_body_css(template_info, fm),
_layout_css(template_info),
_header_footer_css(template_info),
_title_block_css(template_info, fm, semantic_map),
_section_css(template_info),
_table_base_css(template_info),
_border_fill_css(template_info),
_char_pr_css(template_info, fm),
_para_pr_css(template_info),
_named_style_css(template_info),
_table_detail_css(template_info, semantic_map),
]
return "\n\n".join(p for p in parts if p)
# ================================================================
# @page (인쇄 전용)
# ================================================================
def _page_css(ti: dict) -> str:
page = ti.get("page", {})
paper = page.get("paper", {})
margins = page.get("margins", {})
w = paper.get("width_mm", 210)
h = paper.get("height_mm", 297)
mt = margins.get("top", "20mm")
mb = margins.get("bottom", "20mm")
ml = margins.get("left", "20mm")
mr = margins.get("right", "20mm")
return (
"@page {\n"
f" size: {w}mm {h}mm;\n"
f" margin: {mt} {mr} {mb} {ml};\n"
"}\n"
"@media screen {\n"
" @page { margin: 0; }\n" # 화면에서는 .page padding만 사용
"}"
)
# ================================================================
# body
# ================================================================
def _body_css(ti: dict, fm: dict) -> str:
"""바탕글 스타일 기준 body CSS"""
# '바탕글' 스타일 → charPr → fontRef → 실제 폰트
base_charpr = _resolve_style_charpr(ti, "바탕글")
base_parapr = _resolve_style_parapr(ti, "바탕글")
# 폰트
font_family = _charpr_font_family(base_charpr, fm)
# 크기
size_pt = base_charpr.get("height_pt", 10.0)
# 색상
color = base_charpr.get("textColor", "#000000")
# 줄간격
line_height = _parapr_line_height(base_parapr)
# 정렬
# body에는 정렬 넣지 않음 (paraPr별로)
return (
"body {\n"
f" font-family: {font_family};\n"
f" font-size: {size_pt}pt;\n"
f" line-height: {line_height};\n"
f" color: {color};\n"
" margin: 0; padding: 0;\n"
"}"
)
# ================================================================
# .page 레이아웃 (화면 전용 — 여백은 여기서만)
# ================================================================
def _layout_css(ti: dict) -> str:
page = ti.get("page", {})
paper = page.get("paper", {})
margins = page.get("margins", {})
w = paper.get("width_mm", 210)
ml = _mm(margins.get("left", "20mm"))
mr = _mm(margins.get("right", "20mm"))
body_w = w - ml - mr
mt = margins.get("top", "20mm")
mb = margins.get("bottom", "20mm")
m_left = margins.get("left", "20mm")
m_right = margins.get("right", "20mm")
return (
".page {\n"
f" width: {body_w:.0f}mm;\n"
" margin: 0 auto;\n"
f" padding: {mt} {m_right} {mb} {m_left};\n"
"}"
)
# ================================================================
# 헤더 / 푸터
# ================================================================
def _header_footer_css(ti: dict) -> str:
page = ti.get("page", {})
margins = page.get("margins", {})
# 헤더 margin-bottom: page.margins.header에서 유도
# 푸터 margin-top: page.margins.footer에서 유도
hdr_margin = margins.get("header", "")
ftr_margin = margins.get("footer", "")
hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm"
ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm"
lines = [
"/* 헤더/푸터 */",
f".doc-header {{ margin-bottom: {hdr_mb}; }}",
f".doc-footer {{ margin-top: {ftr_mt}; }}",
".doc-header table, .doc-footer table {",
" width: 100%; border-collapse: collapse;",
"}",
]
hdr_padding = _hf_cell_padding(ti.get("header"))
ftr_padding = _hf_cell_padding(ti.get("footer"))
lines.append(
f".doc-header td {{ {hdr_padding} vertical-align: middle; }}"
)
lines.append(
f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}"
)
return "\n".join(lines)
# ================================================================
# 제목 블록 — ★ 하드코딩 제거, 실제 데이터 사용
# ================================================================
def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str:
"""제목 블록 CSS — title_table의 실제 셀 데이터에서 추출"""
tables = ti.get("tables", [])
# semantic_map에서 title_table 인덱스 가져오기
title_idx = None
if sm:
title_idx = sm.get("title_table")
title_tbl = None
if title_idx is not None:
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
# 못 찾으면 1행 표 중 텍스트 있는 것 검색
if not title_tbl:
for t in tables:
rows = t.get("rows", [])
if rows and len(rows) == 1:
for cell in rows[0]:
if cell.get("text", "").strip():
title_tbl = t
break
if title_tbl:
break
lines = ["/* 제목 블록 */"]
if title_tbl:
# 텍스트 있는 셀에서 charPr, paraPr, bf 추출
title_charpr = None
title_parapr = None
title_bf_id = None
for row in title_tbl.get("rows", []):
for cell in row:
if cell.get("text", "").strip():
# ★ primaryCharPrIDRef 사용 (table_v2 추출)
cpr_id = cell.get("primaryCharPrIDRef")
if cpr_id is not None:
title_charpr = next(
(c for c in ti.get("char_styles", [])
if c.get("id") == cpr_id), None
)
ppr_id = cell.get("primaryParaPrIDRef")
if ppr_id is not None:
title_parapr = next(
(p for p in ti.get("para_styles", [])
if p.get("id") == ppr_id), None
)
title_bf_id = cell.get("borderFillIDRef")
break
if title_charpr:
break
# charPr 못 찾으면 폴백 (charPrIDRef가 없는 구버전 table.py)
if not title_charpr:
title_charpr = _find_title_charpr(ti)
# CSS 생성
font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif"
size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0
bold = title_charpr.get("bold", False) if title_charpr else False
color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000"
# 줄간격
line_height = _parapr_line_height(title_parapr) if title_parapr else "180%"
align = _parapr_align(title_parapr) if title_parapr else "center"
# ★ margin/padding — paraPr 또는 page.margins에서 유도
title_after_mm = "4mm" # 기본값
title_padding = "4mm 0" # 기본값
if title_parapr:
margin_info = title_parapr.get("margin", {})
after_hu = margin_info.get("after_hu", 0)
if after_hu:
title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm"
before_hu = margin_info.get("before_hu", 0)
if before_hu or after_hu:
b_mm = before_hu * HU_TO_MM if before_hu else 4
a_mm = after_hu * HU_TO_MM if after_hu else 0
title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0"
lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}")
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
lines.append(
f".title-block h1 {{\n"
f" font-family: {font_family};\n"
f" font-size: {size_pt}pt;\n"
f" font-weight: {'bold' if bold else 'normal'};\n"
f" color: {color};\n"
f" text-align: {align};\n"
f" line-height: {line_height};\n"
f" margin: 0; padding: {title_padding};\n"
f"}}"
)
# bf 적용 (파란 하단선 등)
if title_bf_id:
bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {})
css_dict = bf_data.get("css", {})
bf_rules = []
for prop, val in css_dict.items():
if val and val.lower() != "none":
bf_rules.append(f" {prop}: {val};")
if bf_rules:
lines.append(
f".title-block {{\n"
+ "\n".join(bf_rules)
+ "\n}"
)
else:
lines.append(".title-block { margin-bottom: 4mm; }")
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
lines.append(
".title-block h1 {\n"
" font-size: 15pt; font-weight: normal;\n"
" text-align: center; margin: 0; padding: 4mm 0;\n"
"}"
)
return "\n".join(lines)
# ================================================================
# 섹션 — 하드코딩 제거
# ================================================================
def _section_css(ti: dict) -> str:
"""섹션 CSS — '#큰아이콘' 또는 '개요1' 스타일에서 추출"""
lines = ["/* 섹션 */"]
# 섹션 제목: '#큰아이콘' 또는 가장 큰 bold charPr
title_charpr = _resolve_style_charpr(ti, "#큰아이콘")
if not title_charpr or title_charpr.get("id") == 0:
title_charpr = _resolve_style_charpr(ti, "개요1")
if not title_charpr or title_charpr.get("id") == 0:
# 폴백: bold인 charPr 중 가장 큰 것
for cs in sorted(ti.get("char_styles", []),
key=lambda x: x.get("height_pt", 0), reverse=True):
if cs.get("bold"):
title_charpr = cs
break
if title_charpr:
size = title_charpr.get("height_pt", 11)
bold = title_charpr.get("bold", True)
color = title_charpr.get("textColor", "#000000")
lines.append(
f".section-title {{\n"
f" font-size: {size}pt;\n"
f" font-weight: {'bold' if bold else 'normal'};\n"
f" color: {color};\n"
f" margin-bottom: 3mm;\n"
f"}}"
)
else:
lines.append(
".section-title { font-weight: bold; margin-bottom: 3mm; }"
)
lines.append(".section { margin-bottom: 6mm; }")
lines.append(".section-content { text-align: justify; }")
# content_order 기반 본문용 스타일
lines.append("/* 이미지/문단 (content_order) */")
lines.append(
".img-wrap { text-align: center; margin: 3mm 0; }"
)
lines.append(
".img-wrap img { max-width: 100%; height: auto; }"
)
lines.append(
".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }"
)
return "\n".join(lines)
# ================================================================
# 데이터 표 기본 CSS
# ================================================================
def _table_base_css(ti: dict) -> str:
"""표 기본 — '표내용' 스타일 charPr에서 추출"""
tbl_charpr = _resolve_style_charpr(ti, "표내용")
tbl_parapr = _resolve_style_parapr(ti, "표내용")
size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0
line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%"
align = _parapr_align(tbl_parapr) if tbl_parapr else "justify"
border_fills = ti.get("border_fills", {})
if border_fills:
# bf-{id} 클래스가 셀별 테두리를 담당 → 기본값은 none
# (하드코딩 border를 넣으면 bf 클래스보다 specificity가 높아 덮어씀)
border_rule = "border: none;"
else:
# border_fills 추출 실패 시에만 폴백
border_rule = "border: 1px solid #000;"
return (
"/* 데이터 표 */\n"
".data-table {\n"
" width: 100%; border-collapse: collapse; margin: 4mm 0;\n"
"}\n"
".data-table th, .data-table td {\n"
f" {border_rule}\n"
f" font-size: {size_pt}pt;\n"
f" line-height: {line_height};\n"
f" text-align: {align};\n"
" vertical-align: middle;\n"
"}\n"
".data-table th {\n"
" font-weight: bold; text-align: center;\n"
"}"
)
# ================================================================
# borderFill → .bf-{id} CSS 클래스
# ================================================================
def _border_fill_css(ti: dict) -> str:
"""★ v2.0: NONE-only bf도 클래스 생성 (border: none 명시)"""
border_fills = ti.get("border_fills", {})
if not border_fills:
return ""
parts = ["/* borderFill → CSS 클래스 */"]
for bf_id, bf in border_fills.items():
rules = []
css_dict = bf.get("css", {})
for prop, val in css_dict.items():
if val:
# NONE도 포함 (border: none 명시)
rules.append(f" {prop}: {val};")
# background
if "background-color" not in css_dict:
bg = bf.get("background", "")
if bg and bg.lower() not in ("", "none", "transparent",
"#ffffff", "#fff"):
rules.append(f" background-color: {bg};")
if rules:
parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: charPr → .cpr-{id} CSS 클래스
# ================================================================
def _char_pr_css(ti: dict, fm: dict) -> str:
"""charPr 전체 → 개별 CSS 클래스 생성.
각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포함.
HTML에서 <span class="cpr-5"> 등으로 참조.
"""
char_styles = ti.get("char_styles", [])
if not char_styles:
return ""
parts = ["/* charPr → CSS 클래스 (글자 모양) */"]
for cs in char_styles:
cid = cs.get("id")
rules = []
# font-family
ff = _charpr_font_family(cs, fm)
if ff:
rules.append(f" font-family: {ff};")
# font-size
pt = cs.get("height_pt")
if pt:
rules.append(f" font-size: {pt}pt;")
# bold
if cs.get("bold"):
rules.append(" font-weight: bold;")
# italic
if cs.get("italic"):
rules.append(" font-style: italic;")
# color
color = cs.get("textColor", "#000000")
if color and color.lower() != "#000000":
rules.append(f" color: {color};")
# underline — type이 NONE이 아닌 실제 밑줄만
underline = cs.get("underline", "NONE")
ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"}
if underline in ACTIVE_UNDERLINE:
rules.append(" text-decoration: underline;")
# strikeout — shape="NONE" 또는 "3D"는 취소선 아님
# 실제 취소선: CONTINUOUS, DASH, DOT 등 선 스타일만
strikeout = cs.get("strikeout", "NONE")
ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT",
"DASH_DOT_DOT", "LONG_DASH", "DOUBLE"}
if strikeout in ACTIVE_STRIKEOUT:
rules.append(" text-decoration: line-through;")
# ── 자간 (letter-spacing) ──
# HWPX spacing은 % 단위: letter-spacing = height_pt × spacing / 100
spacing_pct = cs.get("spacing", {}).get("hangul", 0)
if spacing_pct != 0 and pt:
ls_val = round(pt * spacing_pct / 100, 2)
rules.append(f" letter-spacing: {ls_val}pt;")
# ── 장평 (scaleX) ──
# HWPX ratio는 글자 폭 비율 (100=기본). CSS transform으로 변환
ratio_pct = cs.get("ratio", {}).get("hangul", 100)
if ratio_pct != 100:
rules.append(f" transform: scaleX({ratio_pct / 100});")
rules.append(" display: inline-block;") # scaleX 적용 필수
if rules:
parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: paraPr → .ppr-{id} CSS 클래스
# ================================================================
def _para_pr_css(ti: dict) -> str:
"""paraPr 전체 → 개별 CSS 클래스 생성.
각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포함.
HTML에서 <p class="ppr-3"> 등으로 참조.
"""
para_styles = ti.get("para_styles", [])
if not para_styles:
return ""
parts = ["/* paraPr → CSS 클래스 (문단 모양) */"]
for ps in para_styles:
pid = ps.get("id")
rules = []
# text-align
align = _parapr_align(ps)
if align:
rules.append(f" text-align: {align};")
# line-height
lh = _parapr_line_height(ps)
if lh:
rules.append(f" line-height: {lh};")
# text-indent
margin = ps.get("margin", {})
indent_hu = margin.get("indent_hu", 0)
if indent_hu:
indent_mm = indent_hu * HU_TO_MM
rules.append(f" text-indent: {indent_mm:.1f}mm;")
# margin-left
left_hu = margin.get("left_hu", 0)
if left_hu:
left_mm = left_hu * HU_TO_MM
rules.append(f" margin-left: {left_mm:.1f}mm;")
# margin-right
right_hu = margin.get("right_hu", 0)
if right_hu:
right_mm = right_hu * HU_TO_MM
rules.append(f" margin-right: {right_mm:.1f}mm;")
# spacing before/after
before = margin.get("before_hu", 0)
if before:
rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;")
after = margin.get("after_hu", 0)
if after:
rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;")
if rules:
parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: named style → .sty-{id} CSS 클래스
# ================================================================
def _named_style_css(ti: dict) -> str:
"""styles 목록 → .sty-{id} CSS 클래스.
각 style은 charPrIDRef + paraPrIDRef 조합.
→ .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의미.
HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 참조.
"""
styles = ti.get("styles", [])
if not styles:
return ""
parts = ["/* named styles */"]
for s in styles:
sid = s.get("id")
name = s.get("name", "")
cpr_id = s.get("charPrIDRef")
ppr_id = s.get("paraPrIDRef")
# 주석으로 매핑 기록
parts.append(
f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */"
)
return "\n".join(parts)
# ================================================================
# 표 상세 CSS (열 너비, 셀 패딩)
# ================================================================
def _table_detail_css(ti: dict, sm: dict = None) -> str:
if not sm:
return ""
body_indices = sm.get("body_tables", [])
tables = ti.get("tables", [])
if not body_indices or not tables:
return ""
parts = ["/* 표 상세 (tools 추출값) */"]
for tbl_num, tbl_idx in enumerate(body_indices, 1):
tbl = next((t for t in tables if t["index"] == tbl_idx), None)
if not tbl:
continue
cls = f"tbl-{tbl_num}"
# 열 너비
col_pcts = tbl.get("colWidths_pct", [])
if col_pcts:
for c_idx, pct in enumerate(col_pcts):
parts.append(
f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}"
)
# 셀 패딩
cm = _first_cell_margin(tbl)
if cm:
ct = cm.get("top", 0) * HU_TO_MM
cb = cm.get("bottom", 0) * HU_TO_MM
cl = cm.get("left", 0) * HU_TO_MM
cr = cm.get("right", 0) * HU_TO_MM
parts.append(
f".{cls} td, .{cls} th {{\n"
f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n"
f"}}"
)
# 헤더행 높이
first_row = tbl.get("rows", [[]])[0]
if first_row:
h_hu = first_row[0].get("height_hu", 0)
if h_hu > 0:
h_mm = h_hu * HU_TO_MM
parts.append(
f".{cls} thead th {{ height: {h_mm:.1f}mm; }}"
)
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# 보조 함수
# ================================================================
def _build_font_map(ti: dict) -> dict:
"""fonts → {(lang, id): face_name} 딕셔너리"""
fm = {}
for lang, flist in ti.get("fonts", {}).items():
if isinstance(flist, list):
for f in flist:
fm[(lang, f.get("id", 0))] = f.get("face", "")
return fm
def _charpr_font_family(charpr: dict, fm: dict) -> str:
"""charPr의 fontRef → 실제 font-family CSS 값"""
if not charpr:
return "'맑은 고딕', sans-serif"
fr = charpr.get("fontRef", {})
hangul_id = fr.get("hangul", 0)
latin_id = fr.get("latin", 0)
hangul_face = fm.get(("HANGUL", hangul_id), "")
latin_face = fm.get(("LATIN", latin_id), "")
faces = []
if hangul_face:
faces.append(f"'{hangul_face}'")
if latin_face and latin_face != hangul_face:
faces.append(f"'{latin_face}'")
faces.append("sans-serif")
return ", ".join(faces)
def _resolve_style_charpr(ti: dict, style_name: str) -> dict:
"""스타일 이름 → charPr dict 해석"""
styles = ti.get("styles", [])
char_styles = ti.get("char_styles", [])
for s in styles:
if s.get("name") == style_name:
cpr_id = s.get("charPrIDRef")
for cs in char_styles:
if cs.get("id") == cpr_id:
return cs
# 못 찾으면 charPr[0] (바탕글 기본)
return char_styles[0] if char_styles else {}
def _resolve_style_parapr(ti: dict, style_name: str) -> dict:
"""스타일 이름 → paraPr dict 해석"""
styles = ti.get("styles", [])
para_styles = ti.get("para_styles", [])
for s in styles:
if s.get("name") == style_name:
ppr_id = s.get("paraPrIDRef")
for ps in para_styles:
if ps.get("id") == ppr_id:
return ps
return para_styles[0] if para_styles else {}
def _find_title_charpr(ti: dict) -> dict:
"""제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폴백).
헤드라인 폰트 or 가장 큰 크기 기준.
"""
headline_keywords = ["헤드라인", "headline", "제목", "title"]
fm = _build_font_map(ti)
best = {}
best_pt = 0
for cs in ti.get("char_styles", []):
pt = cs.get("height_pt", 0)
fr = cs.get("fontRef", {})
hangul_id = fr.get("hangul", 0)
face = fm.get(("HANGUL", hangul_id), "").lower()
# 헤드라인 폰트면 우선
if any(kw in face for kw in headline_keywords):
if pt > best_pt:
best_pt = pt
best = cs
# 헤드라인 폰트 못 찾으면 가장 큰 것
if not best:
for cs in ti.get("char_styles", []):
pt = cs.get("height_pt", 0)
if pt > best_pt:
best_pt = pt
best = cs
return best
def _parapr_line_height(parapr: dict) -> str:
"""paraPr → CSS line-height"""
if not parapr:
return "160%"
ls = parapr.get("lineSpacing", {})
ls_type = ls.get("type", "PERCENT")
ls_val = ls.get("value", 160)
if ls_type == "PERCENT":
return f"{ls_val}%"
elif ls_type == "FIXED":
return f"{ls_val / 100:.1f}pt"
else:
return f"{ls_val}%"
def _parapr_align(parapr: dict) -> str:
"""paraPr → CSS text-align"""
if not parapr:
return "justify"
align = parapr.get("align", "JUSTIFY")
return {
"JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right",
"CENTER": "center", "DISTRIBUTE": "justify",
"DISTRIBUTE_SPACE": "justify"
}.get(align, "justify")
def _hf_cell_padding(hf_info: dict | None) -> str:
if not hf_info or not hf_info.get("table"):
return "padding: 2px 4px;"
rows = hf_info["table"].get("rows", [])
if not rows or not rows[0]:
return "padding: 2px 4px;"
cm = rows[0][0].get("cellMargin", {})
if not cm:
return "padding: 2px 4px;"
ct = cm.get("top", 0) * HU_TO_MM
cb = cm.get("bottom", 0) * HU_TO_MM
cl = cm.get("left", 0) * HU_TO_MM
cr = cm.get("right", 0) * HU_TO_MM
return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;"
def _first_cell_margin(tbl: dict) -> dict | None:
for row in tbl.get("rows", []):
for cell in row:
cm = cell.get("cellMargin")
if cm:
return cm
return None
def _mm(val) -> float:
if isinstance(val, (int, float)):
return float(val)
try:
return float(str(val).replace("mm", "").strip())
except (ValueError, TypeError):
return 20.0

View File

@@ -0,0 +1,3 @@
from .processor import TemplateProcessor
__all__ = ['TemplateProcessor']

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,625 @@
# -*- coding: utf-8 -*-
"""
템플릿 처리 로직 (v3 - 실제 구조 정확 분석)
- HWPX 파일의 실제 표 구조, 이미지 배경, 테두리 정확히 추출
- ARGB 8자리 색상 정규화
- NONE 테두리 색상 제외
"""
import os
import json
import uuid
import shutil
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from collections import Counter, defaultdict
# 템플릿 저장 경로
TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store'
TEMPLATES_DIR.mkdir(exist_ok=True)
# HWP 명세서 기반 상수
LINE_TYPES = {
'NONE': '없음',
'SOLID': '실선',
'DASH': '긴 점선',
'DOT': '점선',
'DASH_DOT': '-.-.-.-.',
'DASH_DOT_DOT': '-..-..-..',
'DOUBLE_SLIM': '2중선',
'SLIM_THICK': '가는선+굵은선',
'THICK_SLIM': '굵은선+가는선',
'SLIM_THICK_SLIM': '가는선+굵은선+가는선',
'WAVE': '물결',
'DOUBLE_WAVE': '물결 2중선',
}
class TemplateProcessor:
"""템플릿 처리 클래스 (v3)"""
NS = {
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
'hs': 'http://www.hancom.co.kr/hwpml/2011/section',
}
def __init__(self):
self.templates_dir = TEMPLATES_DIR
self.templates_dir.mkdir(exist_ok=True)
# =========================================================================
# 공개 API
# =========================================================================
def get_list(self) -> Dict[str, Any]:
"""저장된 템플릿 목록"""
templates = []
for item in self.templates_dir.iterdir():
if item.is_dir():
meta_path = item / 'meta.json'
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding='utf-8'))
templates.append({
'id': meta.get('id', item.name),
'name': meta.get('name', item.name),
'features': meta.get('features', []),
'created_at': meta.get('created_at', '')
})
except:
pass
templates.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return {'templates': templates}
def analyze(self, file, name: str) -> Dict[str, Any]:
"""템플릿 파일 분석 및 저장"""
filename = file.filename
ext = Path(filename).suffix.lower()
if ext not in ['.hwpx', '.hwp', '.pdf']:
return {'error': f'지원하지 않는 파일 형식: {ext}'}
template_id = str(uuid.uuid4())[:8]
template_dir = self.templates_dir / template_id
template_dir.mkdir(exist_ok=True)
try:
original_path = template_dir / f'original{ext}'
file.save(str(original_path))
if ext == '.hwpx':
style_data = self._analyze_hwpx(original_path, template_dir)
else:
style_data = self._analyze_fallback(ext)
if 'error' in style_data:
shutil.rmtree(template_dir)
return style_data
# 특징 추출
features = self._extract_features(style_data)
# 메타 저장
meta = {
'id': template_id,
'name': name,
'original_file': filename,
'file_type': ext,
'features': features,
'created_at': datetime.now().isoformat()
}
(template_dir / 'meta.json').write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8'
)
# 스타일 저장
(template_dir / 'style.json').write_text(
json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8'
)
# CSS 저장
css = style_data.get('css', '')
css_dir = template_dir / 'css'
css_dir.mkdir(exist_ok=True)
(css_dir / 'template.css').write_text(css, encoding='utf-8')
return {
'success': True,
'template': {
'id': template_id,
'name': name,
'features': features,
'created_at': meta['created_at']
}
}
except Exception as e:
if template_dir.exists():
shutil.rmtree(template_dir)
raise e
def delete(self, template_id: str) -> Dict[str, Any]:
"""템플릿 삭제"""
template_dir = self.templates_dir / template_id
if not template_dir.exists():
return {'error': '템플릿을 찾을 수 없습니다'}
shutil.rmtree(template_dir)
return {'success': True, 'deleted': template_id}
def get_style(self, template_id: str) -> Optional[Dict[str, Any]]:
"""템플릿 스타일 반환"""
style_path = self.templates_dir / template_id / 'style.json'
if not style_path.exists():
return None
return json.loads(style_path.read_text(encoding='utf-8'))
# =========================================================================
# HWPX 분석 (핵심)
# =========================================================================
def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]:
"""HWPX 분석 - 실제 구조 정확히 추출"""
extract_dir = template_dir / 'extracted'
try:
with zipfile.ZipFile(file_path, 'r') as zf:
zf.extractall(extract_dir)
result = {
'version': 'v3',
'fonts': {},
'colors': {
'background': [],
'border': [],
'text': []
},
'border_fills': {},
'tables': [],
'special_borders': [],
'style_summary': {},
'css': ''
}
# 1. header.xml 분석
header_path = extract_dir / 'Contents' / 'header.xml'
if header_path.exists():
self._parse_header(header_path, result)
# 2. section0.xml 분석
section_path = extract_dir / 'Contents' / 'section0.xml'
if section_path.exists():
self._parse_section(section_path, result)
# 3. 스타일 요약 생성
result['style_summary'] = self._create_style_summary(result)
# 4. CSS 생성
result['css'] = self._generate_css(result)
return result
finally:
if extract_dir.exists():
shutil.rmtree(extract_dir)
def _parse_header(self, header_path: Path, result: Dict):
"""header.xml 파싱 - 폰트, borderFill"""
tree = ET.parse(header_path)
root = tree.getroot()
# 폰트
for fontface in root.findall('.//hh:fontface', self.NS):
if fontface.get('lang') == 'HANGUL':
for font in fontface.findall('hh:font', self.NS):
result['fonts'][font.get('id')] = font.get('face')
# borderFill
for bf in root.findall('.//hh:borderFill', self.NS):
bf_id = bf.get('id')
bf_data = self._parse_border_fill(bf, result)
result['border_fills'][bf_id] = bf_data
def _parse_border_fill(self, bf, result: Dict) -> Dict:
"""개별 borderFill 파싱"""
bf_id = bf.get('id')
data = {
'id': bf_id,
'type': 'empty',
'background': None,
'image': None,
'borders': {}
}
# 이미지 배경
img_brush = bf.find('.//hc:imgBrush', self.NS)
if img_brush is not None:
img = img_brush.find('hc:img', self.NS)
if img is not None:
data['type'] = 'image'
data['image'] = {
'ref': img.get('binaryItemIDRef'),
'effect': img.get('effect')
}
# 단색 배경
win_brush = bf.find('.//hc:winBrush', self.NS)
if win_brush is not None:
face_color = self._normalize_color(win_brush.get('faceColor'))
if face_color and face_color != 'none':
if data['type'] == 'empty':
data['type'] = 'solid'
data['background'] = face_color
if face_color not in result['colors']['background']:
result['colors']['background'].append(face_color)
# 4방향 테두리
for side in ['top', 'bottom', 'left', 'right']:
border = bf.find(f'hh:{side}Border', self.NS)
if border is not None:
border_type = border.get('type', 'NONE')
width = border.get('width', '0.1 mm')
color = self._normalize_color(border.get('color', '#000000'))
data['borders'][side] = {
'type': border_type,
'type_name': LINE_TYPES.get(border_type, border_type),
'width': width,
'width_mm': self._parse_width(width),
'color': color
}
# 보이는 테두리만 색상 수집
if border_type != 'NONE':
if data['type'] == 'empty':
data['type'] = 'border_only'
if color and color not in result['colors']['border']:
result['colors']['border'].append(color)
# 특수 테두리 수집
if border_type not in ['SOLID', 'NONE']:
result['special_borders'].append({
'bf_id': bf_id,
'side': side,
'type': border_type,
'type_name': LINE_TYPES.get(border_type, border_type),
'width': width,
'color': color
})
return data
def _parse_section(self, section_path: Path, result: Dict):
"""section0.xml 파싱 - 표 구조"""
tree = ET.parse(section_path)
root = tree.getroot()
border_fills = result['border_fills']
for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'):
table_data = {
'rows': int(tbl.get('rowCnt', 0)),
'cols': int(tbl.get('colCnt', 0)),
'cells': [],
'structure': {
'header_row_style': None,
'first_col_style': None,
'body_style': None,
'has_image_cells': False
}
}
# 셀별 분석
cell_by_position = {}
for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'):
cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr')
if cell_addr is None:
continue
row = int(cell_addr.get('rowAddr', 0))
col = int(cell_addr.get('colAddr', 0))
bf_id = tc.get('borderFillIDRef')
bf_info = border_fills.get(bf_id, {})
# 텍스트 추출
text = ''
for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'):
if t.text:
text += t.text
cell_data = {
'row': row,
'col': col,
'bf_id': bf_id,
'bf_type': bf_info.get('type'),
'background': bf_info.get('background'),
'image': bf_info.get('image'),
'text_preview': text[:30] if text else ''
}
table_data['cells'].append(cell_data)
cell_by_position[(row, col)] = cell_data
if bf_info.get('type') == 'image':
table_data['structure']['has_image_cells'] = True
# 구조 분석: 헤더행, 첫열 스타일
self._analyze_table_structure(table_data, cell_by_position, border_fills)
result['tables'].append(table_data)
def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict):
"""표 구조 분석 - 헤더행/첫열 스타일 파악"""
rows = table_data['rows']
cols = table_data['cols']
if rows == 0 or cols == 0:
return
# 첫 행 (헤더) 분석
header_styles = []
for c in range(cols):
cell = cells.get((0, c))
if cell:
header_styles.append(cell.get('bf_id'))
if header_styles:
# 가장 많이 쓰인 스타일
most_common = Counter(header_styles).most_common(1)
if most_common:
bf_id = most_common[0][0]
bf = border_fills.get(bf_id)
if bf and bf.get('background'):
table_data['structure']['header_row_style'] = {
'bf_id': bf_id,
'background': bf.get('background'),
'borders': bf.get('borders', {})
}
# 첫 열 분석 (행 1부터)
first_col_styles = []
for r in range(1, rows):
cell = cells.get((r, 0))
if cell:
first_col_styles.append(cell.get('bf_id'))
if first_col_styles:
most_common = Counter(first_col_styles).most_common(1)
if most_common:
bf_id = most_common[0][0]
bf = border_fills.get(bf_id)
if bf and bf.get('background'):
table_data['structure']['first_col_style'] = {
'bf_id': bf_id,
'background': bf.get('background')
}
# 본문 셀 스타일 (첫열 제외)
body_styles = []
for r in range(1, rows):
for c in range(1, cols):
cell = cells.get((r, c))
if cell:
body_styles.append(cell.get('bf_id'))
if body_styles:
most_common = Counter(body_styles).most_common(1)
if most_common:
bf_id = most_common[0][0]
bf = border_fills.get(bf_id)
table_data['structure']['body_style'] = {
'bf_id': bf_id,
'background': bf.get('background') if bf else None
}
def _create_style_summary(self, result: Dict) -> Dict:
"""AI 프롬프트용 스타일 요약"""
summary = {
'폰트': list(result['fonts'].values())[:3],
'색상': {
'배경색': result['colors']['background'],
'테두리색': result['colors']['border']
},
'표_스타일': [],
'특수_테두리': []
}
# 표별 스타일 요약
for i, tbl in enumerate(result['tables']):
tbl_summary = {
'표번호': i + 1,
'크기': f"{tbl['rows']}× {tbl['cols']}",
'이미지셀': tbl['structure']['has_image_cells']
}
header = tbl['structure'].get('header_row_style')
if header:
tbl_summary['헤더행'] = f"배경={header.get('background')}"
first_col = tbl['structure'].get('first_col_style')
if first_col:
tbl_summary['첫열'] = f"배경={first_col.get('background')}"
body = tbl['structure'].get('body_style')
if body:
tbl_summary['본문'] = f"배경={body.get('background') or '없음'}"
summary['표_스타일'].append(tbl_summary)
# 특수 테두리 요약
seen = set()
for sb in result['special_borders']:
key = f"{sb['type_name']} {sb['width']} {sb['color']}"
if key not in seen:
seen.add(key)
summary['특수_테두리'].append(key)
return summary
def _generate_css(self, result: Dict) -> str:
"""CSS 생성 - 실제 구조 반영"""
fonts = list(result['fonts'].values())[:2]
font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'"
bg_colors = result['colors']['background']
header_bg = bg_colors[0] if bg_colors else '#D6D6D6'
# 특수 테두리에서 2중선 찾기
double_border = None
for sb in result['special_borders']:
if 'DOUBLE' in sb['type']:
double_border = sb
break
css = f"""/* 템플릿 스타일 v3 - HWPX 구조 기반 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
:root {{
--font-primary: 'Noto Sans KR', {font_family}, sans-serif;
--color-header-bg: {header_bg};
--color-border: #000000;
}}
body {{
font-family: var(--font-primary);
font-size: 10pt;
line-height: 1.6;
color: #000000;
}}
.sheet {{
width: 210mm;
min-height: 297mm;
padding: 20mm;
margin: 10px auto;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}}
@media print {{
.sheet {{ margin: 0; box-shadow: none; page-break-after: always; }}
}}
/* 표 기본 */
table {{
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 9pt;
}}
th, td {{
border: 0.12mm solid var(--color-border);
padding: 6px 8px;
vertical-align: middle;
}}
/* 헤더 행 */
thead th, tr:first-child th, tr:first-child td {{
background-color: var(--color-header-bg);
font-weight: bold;
text-align: center;
}}
/* 첫 열 (구분 열) - 배경색 */
td:first-child {{
background-color: var(--color-header-bg);
text-align: center;
font-weight: 500;
}}
/* 본문 셀 - 배경 없음 */
td:not(:first-child) {{
background-color: transparent;
}}
/* 2중선 테두리 (헤더 하단) */
thead tr:last-child th,
thead tr:last-child td,
tr:first-child th,
tr:first-child td {{
border-bottom: 0.5mm double var(--color-border);
}}
"""
return css
# =========================================================================
# 유틸리티
# =========================================================================
def _normalize_color(self, color: str) -> str:
"""ARGB 8자리 → RGB 6자리"""
if not color or color == 'none':
return color
color = color.strip()
# #AARRGGBB → #RRGGBB
if color.startswith('#') and len(color) == 9:
return '#' + color[3:]
return color
def _parse_width(self, width_str: str) -> float:
"""너비 문자열 → mm"""
if not width_str:
return 0.1
try:
return float(width_str.split()[0])
except:
return 0.1
def _extract_features(self, data: Dict) -> List[str]:
"""특징 목록"""
features = []
fonts = list(data.get('fonts', {}).values())
if fonts:
features.append(f"폰트: {', '.join(fonts[:2])}")
bg_colors = data.get('colors', {}).get('background', [])
if bg_colors:
features.append(f"배경색: {', '.join(bg_colors[:2])}")
tables = data.get('tables', [])
if tables:
has_img = any(t['structure']['has_image_cells'] for t in tables)
if has_img:
features.append("이미지 배경 셀")
special = data.get('special_borders', [])
if special:
types = set(s['type_name'] for s in special)
features.append(f"특수 테두리: {', '.join(list(types)[:2])}")
return features if features else ['기본 템플릿']
def _analyze_fallback(self, ext: str) -> Dict:
"""HWP, PDF 기본 분석"""
return {
'version': 'v3',
'fonts': {'0': '맑은 고딕'},
'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']},
'border_fills': {},
'tables': [],
'special_borders': [],
'style_summary': {
'폰트': ['맑은 고딕'],
'색상': {'배경색': [], '테두리색': ['#000000']},
'표_스타일': [],
'특수_테두리': []
},
'css': self._get_default_css(),
'note': f'{ext} 파일은 기본 분석만 지원. HWPX 권장.'
}
def _get_default_css(self) -> str:
return """/* 기본 스타일 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; }
.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 0.5pt solid #000; padding: 8px; }
th { background: #D6D6D6; }
"""

View File

@@ -0,0 +1,28 @@
당신은 문서 템플릿 분석 전문가입니다.
주어진 HWPX/HWP/PDF 템플릿의 구조를 분석하여 다음 정보를 추출해주세요:
1. 제목 스타일 (H1~H6)
- 폰트명, 크기(pt), 굵기, 색상
- 정렬 방식
- 번호 체계 (제1장, 1.1, 가. 등)
2. 본문 스타일
- 기본 폰트, 크기, 줄간격
- 들여쓰기
3. 표 스타일
- 헤더 배경색
- 테두리 스타일 (선 두께, 색상)
- 이중선 사용 여부
4. 그림/캡션 스타일
- 캡션 위치 (상/하)
- 캡션 형식
5. 페이지 구성
- 표지 유무
- 목차 유무
- 머리말/꼬리말
분석 결과를 JSON 형식으로 출력해주세요.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""
HWPX 템플릿 추출 도구 모음
각 모듈은 HWPX XML에서 특정 항목을 코드 기반으로 추출한다.
- 추출 실패 시 None 반환 (디폴트값 절대 생성 안 함)
- 모든 단위 변환은 hwpx_utils 사용
- hwpx_domain_guide.md 기준 준수
모듈 목록:
page_setup : §7 용지/여백 (pagePr + margin)
font : §3 글꼴 (fontface → font)
char_style : §4 글자 모양 (charPr)
para_style : §5 문단 모양 (paraPr)
border_fill : §2 테두리/배경 (borderFill)
table : §6 표 (tbl, tc)
header_footer: §8 머리말/꼬리말 (headerFooter)
section : §9 구역 정의 (secPr)
style_def : 스타일 정의 (styles)
numbering : 번호매기기/글머리표
image : 이미지/그리기 객체
content_order: 본문 콘텐츠 순서 (section*.xml)
"""
from . import page_setup
from . import font
from . import char_style
from . import para_style
from . import border_fill
from . import table
from . import header_footer
from . import section
from . import style_def
from . import numbering
from . import image
from . import content_order
__all__ = [
"page_setup",
"font",
"char_style",
"para_style",
"border_fill",
"table",
"header_footer",
"section",
"style_def",
"numbering",
"image",
"content_order"
]

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""
§2 테두리/배경(BorderFill) 추출
HWPX 실제 태그 (header.xml):
<hh:borderFill id="3" threeD="0" shadow="0" centerLine="NONE" ...>
<hh:leftBorder type="SOLID" width="0.12 mm" color="#000000"/>
<hh:rightBorder type="SOLID" width="0.12 mm" color="#000000"/>
<hh:topBorder type="SOLID" width="0.12 mm" color="#000000"/>
<hh:bottomBorder type="SOLID" width="0.12 mm" color="#000000"/>
<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>
<hc:fillBrush>
<hc:winBrush faceColor="#EDEDED" hatchColor="#FFE7E7E7" alpha="0"/>
</hc:fillBrush>
</hh:borderFill>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import BORDER_TYPE_TO_CSS, hwpx_border_to_css
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
"""§2 borderFill 전체 추출 → id별 dict.
Returns:
{
3: {
"id": 3,
"left": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
"right": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
"top": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
"bottom": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
"diagonal": {"type": "SOLID", "width": "0.1 mm", "color": "#000000"},
"background": "#EDEDED", # fillBrush faceColor
"css": { # 편의: 미리 변환된 CSS
"border-left": "0.12mm solid #000000",
...
"background-color": "#EDEDED",
}
},
...
}
또는 추출 실패 시 None
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
blocks = re.findall(
r'<hh:borderFill\b([^>]*)>(.*?)</hh:borderFill>',
header_xml, re.DOTALL
)
if not blocks:
return None
result = {}
for attrs_str, inner in blocks:
id_m = re.search(r'\bid="(\d+)"', attrs_str)
if not id_m:
continue
bf_id = int(id_m.group(1))
item = {"id": bf_id}
# 4방향 + diagonal
for side, tag in [
("left", "leftBorder"),
("right", "rightBorder"),
("top", "topBorder"),
("bottom", "bottomBorder"),
("diagonal", "diagonal"),
]:
# 태그 전체를 먼저 찾고, 속성을 개별 추출 (순서 무관)
tag_m = re.search(rf'<hh:{tag}\b([^/]*?)/?>', inner)
if tag_m:
tag_attrs = tag_m.group(1)
t = re.search(r'\btype="([^"]+)"', tag_attrs)
w = re.search(r'\bwidth="([^"]+)"', tag_attrs)
c = re.search(r'\bcolor="([^"]+)"', tag_attrs)
item[side] = {
"type": t.group(1) if t else "NONE",
"width": w.group(1).replace(" ", "") if w else "0.12mm",
"color": c.group(1) if c else "#000000",
}
# 배경 (fillBrush > winBrush faceColor)
bg_m = re.search(
r'<hc:winBrush\b[^>]*\bfaceColor="([^"]+)"', inner
)
if bg_m:
face = bg_m.group(1)
if face and face.lower() != "none":
item["background"] = face
# CSS 편의 변환
css = {}
for side in ["left", "right", "top", "bottom"]:
border_data = item.get(side)
if border_data:
css[f"border-{side}"] = hwpx_border_to_css(border_data)
else:
css[f"border-{side}"] = "none"
# border_data가 없으면 CSS에도 넣지 않음
if "background" in item:
css["background-color"] = item["background"]
if css:
item["css"] = css
result[bf_id] = item
return result if result else None
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""
§4 글자 모양(CharShape) 추출
HWPX 실제 태그 (header.xml):
<hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none"
useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">
<hh:fontRef hangul="7" latin="6" hanja="6" .../>
<hh:ratio hangul="100" latin="100" .../>
<hh:spacing hangul="0" latin="0" .../>
<hh:relSz hangul="100" latin="100" .../>
<hh:offset hangul="0" latin="0" .../>
<hh:bold/> <!-- 존재하면 bold -->
<hh:italic/> <!-- 존재하면 italic -->
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
<hh:strikeout shape="NONE" color="#000000"/>
</hh:charPr>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import charsize_to_pt
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
"""§4 charPr 전체 목록 추출.
Returns:
[
{
"id": 0,
"height_pt": 10.0,
"textColor": "#000000",
"bold": False,
"italic": False,
"underline": "NONE",
"strikeout": "NONE",
"fontRef": {"hangul": 7, "latin": 6, ...},
"ratio": {"hangul": 100, "latin": 100, ...},
"spacing": {"hangul": 0, "latin": 0, ...},
"borderFillIDRef": 2,
},
...
]
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
# charPr 블록 추출 (self-closing이 아닌 블록)
blocks = re.findall(
r'<hh:charPr\b([^>]*)>(.*?)</hh:charPr>',
header_xml, re.DOTALL
)
if not blocks:
return None
result = []
for attrs_str, inner in blocks:
item = {}
# 속성 파싱
id_m = re.search(r'\bid="(\d+)"', attrs_str)
if id_m:
item["id"] = int(id_m.group(1))
height_m = re.search(r'\bheight="(\d+)"', attrs_str)
if height_m:
item["height_pt"] = charsize_to_pt(int(height_m.group(1)))
color_m = re.search(r'\btextColor="([^"]+)"', attrs_str)
if color_m:
item["textColor"] = color_m.group(1)
shade_m = re.search(r'\bshadeColor="([^"]+)"', attrs_str)
if shade_m and shade_m.group(1) != "none":
item["shadeColor"] = shade_m.group(1)
bf_m = re.search(r'\bborderFillIDRef="(\d+)"', attrs_str)
if bf_m:
item["borderFillIDRef"] = int(bf_m.group(1))
# bold / italic (태그 존재 여부로 판단)
item["bold"] = bool(re.search(r'<hh:bold\s*/?>', inner))
item["italic"] = bool(re.search(r'<hh:italic\s*/?>', inner))
# fontRef
fr = re.search(r'<hh:fontRef\b([^/]*)/>', inner)
if fr:
item["fontRef"] = _parse_lang_attrs(fr.group(1))
# ratio
ra = re.search(r'<hh:ratio\b([^/]*)/>', inner)
if ra:
item["ratio"] = _parse_lang_attrs(ra.group(1))
# spacing
sp = re.search(r'<hh:spacing\b([^/]*)/>', inner)
if sp:
item["spacing"] = _parse_lang_attrs(sp.group(1))
# underline
ul = re.search(r'<hh:underline\b[^>]*\btype="([^"]+)"', inner)
if ul:
item["underline"] = ul.group(1)
# strikeout
so = re.search(r'<hh:strikeout\b[^>]*\bshape="([^"]+)"', inner)
if so:
item["strikeout"] = so.group(1)
result.append(item)
return result if result else None
def _parse_lang_attrs(attrs_str: str) -> dict:
"""hangul="7" latin="6" ... → {"hangul": 7, "latin": 6, ...}"""
pairs = re.findall(r'(\w+)="(-?\d+)"', attrs_str)
return {k: int(v) for k, v in pairs}
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,529 @@
# -*- coding: utf-8 -*-
"""
content_order.py — HWPX section*.xml 본문 콘텐츠 순서 추출
기존 12개 tool이 header.xml의 "정의(definition)"를 추출하는 반면,
이 tool은 section0.xml의 "본문(content)" 순서를 추출한다.
추출 결과는 template_manager._build_body_html()이
원본 순서 그대로 HTML을 조립하는 데 사용된다.
콘텐츠 유형:
- paragraph : 일반 텍스트 문단
- table : 표 (<hp:tbl>)
- image : 이미지 (<hp:pic>)
- empty : 빈 문단 (줄바꿈 역할)
참조: hwpx_domain_guide.md §6(표), §7(본문 구조)
"""
import re
import logging
logger = logging.getLogger(__name__)
# ================================================================
# 네임스페이스
# ================================================================
# HWPX는 여러 네임스페이스를 사용한다.
# section*.xml: hp: (본문), ha: (속성)
# header.xml: hh: (헤더 정의)
# 실제 파일에서 네임스페이스 URI가 다를 수 있으므로 로컬명 기반 탐색도 병행한다.
DEFAULT_NS = {
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes',
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
}
# ================================================================
# 공개 API
# ================================================================
def extract(raw_xml, parsed, ns=None):
"""section*.xml에서 본문 콘텐츠 순서를 추출한다.
Args:
raw_xml (dict): 원본 XML 문자열 딕셔너리.
raw_xml.get("section0") 등으로 section XML에 접근.
parsed (dict): processor.py가 HWPX를 파싱한 전체 결과 dict.
parsed.get("section_xml") 등으로 parsed Element에 접근.
ns (dict, optional): 네임스페이스 매핑. None이면 자동 감지.
Returns:
list[dict]: 콘텐츠 순서 리스트. 각 항목은 다음 키를 포함:
- type: "paragraph" | "table" | "image" | "empty"
- index: 전체 순서 내 인덱스 (0부터)
- paraPrIDRef: 문단모양 참조 ID (str or None)
- styleIDRef: 스타일 참조 ID (str or None)
+ type별 추가 키 (아래 참조)
추출 실패 시 None 반환 (analyzer가 결과에서 제외함).
"""
# ── section XML 찾기 ──
# raw_xml dict에서 section 원본 문자열 추출
section_raw = None
if isinstance(raw_xml, dict):
# 키 이름은 프로젝트마다 다를 수 있음: section0, section_xml 등
for key in ['section0', 'section_xml', 'section0.xml']:
if key in raw_xml:
section_raw = raw_xml[key]
break
# 못 찾으면 "section"으로 시작하는 첫 번째 키
if section_raw is None:
for key, val in raw_xml.items():
if key.startswith('section') and isinstance(val, str):
section_raw = val
break
elif isinstance(raw_xml, str):
section_raw = raw_xml
# parsed dict에서 section Element 또는 문자열 추출
section_parsed = None
if isinstance(parsed, dict):
for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']:
val = parsed.get(key)
if val is None:
continue
if isinstance(val, str):
# 문자열이면 section_raw로 활용 (table.py와 동일)
if section_raw is None:
section_raw = val
elif not isinstance(val, dict):
# Element 객체로 추정
section_parsed = val
break
# fallback: raw_xml 문자열을 직접 파싱
if section_parsed is None and section_raw:
import xml.etree.ElementTree as ET
try:
section_parsed = ET.fromstring(section_raw)
except ET.ParseError:
logger.warning("section XML 파싱 실패")
return None
else:
# parsed 자체가 Element일 수 있음 (직접 호출 시)
section_parsed = parsed
if section_parsed is None:
logger.warning("section XML을 찾을 수 없음 — content_order 추출 생략")
return None
if ns is None:
ns = _detect_namespaces(section_raw or '', section_parsed)
# <hp:p> 엘리먼트 수집 — secPr 내부는 제외
paragraphs = _collect_body_paragraphs(section_parsed, ns)
content_order = []
table_idx = 0
image_idx = 0
for p_elem in paragraphs:
para_pr_id = _get_attr(p_elem, 'paraPrIDRef')
style_id = _get_attr(p_elem, 'styleIDRef')
base = {
'index': len(content_order),
'paraPrIDRef': para_pr_id,
'styleIDRef': style_id,
}
# ── (1) 표 확인 ──
tbl = _find_element(p_elem, 'tbl', ns)
if tbl is not None:
tbl_info = _extract_table_info(tbl, ns)
content_order.append({
**base,
'type': 'table',
'table_idx': table_idx,
**tbl_info,
})
table_idx += 1
continue
# ── (2) 이미지 확인 ──
pic = _find_element(p_elem, 'pic', ns)
if pic is not None:
img_info = _extract_image_info(pic, p_elem, ns)
content_order.append({
**base,
'type': 'image',
'image_idx': image_idx,
**img_info,
})
image_idx += 1
continue
# ── (3) 텍스트 문단 / 빈 문단 ──
text = _collect_text(p_elem, ns)
runs_info = _extract_runs_info(p_elem, ns)
if not text.strip():
content_order.append({
**base,
'type': 'empty',
})
else:
content_order.append({
**base,
'type': 'paragraph',
'text': text,
'charPrIDRef': runs_info.get('first_charPrIDRef'),
'runs': runs_info.get('runs', []),
})
logger.info(
"content_order 추출 완료: %d items "
"(paragraphs=%d, tables=%d, images=%d, empty=%d)",
len(content_order),
sum(1 for c in content_order if c['type'] == 'paragraph'),
table_idx,
image_idx,
sum(1 for c in content_order if c['type'] == 'empty'),
)
return content_order
# ================================================================
# 본문 <hp:p> 수집 — secPr 내부 제외
# ================================================================
def _collect_body_paragraphs(root, ns):
"""<hp:sec> 직계 <hp:p> 만 수집한다.
secPr, headerFooter 내부의 <hp:p>는 본문이 아니므로 제외.
subList 내부(셀 안 문단)도 제외 — 표는 통째로 하나의 항목.
"""
paragraphs = []
# 방법 1: sec 직계 자식 중 p 태그만
sec = _find_element(root, 'sec', ns)
if sec is None:
# 루트 자체가 sec일 수 있음
sec = root
for child in sec:
tag = _local_tag(child)
if tag == 'p':
paragraphs.append(child)
# 직계 자식에서 못 찾았으면 fallback: 전체 탐색 (but secPr/subList 제외)
if not paragraphs:
paragraphs = _collect_paragraphs_fallback(root, ns)
return paragraphs
def _collect_paragraphs_fallback(root, ns):
"""fallback: 전체에서 <hp:p>를 찾되, secPr/headerFooter/subList 내부는 제외"""
skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'}
result = []
def _walk(elem, skip=False):
if skip:
return
tag = _local_tag(elem)
if tag in skip_tags:
return
if tag == 'p':
# 부모가 sec이거나 루트 직계인 경우만
result.append(elem)
return # p 내부의 하위 p는 수집하지 않음
for child in elem:
_walk(child)
_walk(root)
return result
# ================================================================
# 표 정보 추출
# ================================================================
def _extract_table_info(tbl, ns):
"""<hp:tbl> 에서 기본 메타 정보 추출"""
info = {
'rowCnt': _get_attr(tbl, 'rowCnt'),
'colCnt': _get_attr(tbl, 'colCnt'),
'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'),
}
# 열 너비
col_sz = _find_element(tbl, 'colSz', ns)
if col_sz is not None:
width_list_elem = _find_element(col_sz, 'widthList', ns)
if width_list_elem is not None and width_list_elem.text:
info['colWidths'] = width_list_elem.text.strip().split()
return info
# ================================================================
# 이미지 정보 추출
# ================================================================
def _extract_image_info(pic, p_elem, ns):
"""<hp:pic> 에서 이미지 참조 정보 추출"""
info = {
'binaryItemIDRef': None,
'text': '', # 이미지와 같은 문단에 있는 텍스트 (캡션 등)
}
# img 태그에서 binaryItemIDRef
img = _find_element(pic, 'img', ns)
if img is not None:
info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef')
# imgRect에서 크기 정보
img_rect = _find_element(pic, 'imgRect', ns)
if img_rect is not None:
info['imgRect'] = {
'x': _get_attr(img_rect, 'x'),
'y': _get_attr(img_rect, 'y'),
'w': _get_attr(img_rect, 'w'),
'h': _get_attr(img_rect, 'h'),
}
# 같은 문단 내 텍스트 (pic 바깥의 run들)
info['text'] = _collect_text_outside(p_elem, pic, ns)
return info
# ================================================================
# 텍스트 수집
# ================================================================
def _collect_text(p_elem, ns):
"""<hp:p> 내 모든 <hp:t> 텍스트를 순서대로 합침
주의: t.tail은 XML 들여쓰기 공백이므로 수집하지 않는다.
HWPX에서 실제 텍스트는 항상 <hp:t>...</hp:t> 안에 있다.
"""
parts = []
for t in _find_all_elements(p_elem, 't', ns):
if t.text:
parts.append(t.text)
return ''.join(parts)
def _collect_text_outside(p_elem, exclude_elem, ns):
"""p_elem 내에서 exclude_elem(예: pic) 바깥의 텍스트만 수집"""
parts = []
def _walk(elem):
if elem is exclude_elem:
return
tag = _local_tag(elem)
if tag == 't' and elem.text:
parts.append(elem.text)
for child in elem:
_walk(child)
_walk(p_elem)
return ''.join(parts)
# ================================================================
# Run 정보 추출
# ================================================================
def _extract_runs_info(p_elem, ns):
"""<hp:p> 내 <hp:run> 들의 charPrIDRef와 텍스트 추출
Returns:
{
'first_charPrIDRef': str or None,
'runs': [
{'charPrIDRef': '8', 'text': '1. SamanPro...'},
{'charPrIDRef': '24', 'text': '포장설계...'},
]
}
"""
runs = []
first_char_pr = None
for run_elem in _find_direct_runs(p_elem, ns):
char_pr = _get_attr(run_elem, 'charPrIDRef')
if first_char_pr is None and char_pr is not None:
first_char_pr = char_pr
text_parts = []
for t in _find_all_elements(run_elem, 't', ns):
if t.text:
text_parts.append(t.text)
if text_parts:
runs.append({
'charPrIDRef': char_pr,
'text': ''.join(text_parts),
})
return {
'first_charPrIDRef': first_char_pr,
'runs': runs,
}
def _find_direct_runs(p_elem, ns):
"""<hp:p> 직계 <hp:run>만 찾음 (subList 내부 제외)"""
results = []
for child in p_elem:
tag = _local_tag(child)
if tag == 'run':
results.append(child)
return results
# ================================================================
# 네임스페이스 감지
# ================================================================
def _detect_namespaces(raw_xml, parsed):
"""XML에서 실제 사용된 네임스페이스 URI를 감지한다.
HWPX 버전에 따라 네임스페이스 URI가 다를 수 있다:
- 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph
- 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (일부)
"""
ns = dict(DEFAULT_NS)
if raw_xml:
# xmlns:hp="..." 패턴으로 실제 URI 추출
for prefix in ['hp', 'ha', 'hh', 'hc']:
pattern = rf'xmlns:{prefix}="([^"]+)"'
match = re.search(pattern, raw_xml)
if match:
ns[prefix] = match.group(1)
return ns
# ================================================================
# XML 유틸리티 — 네임스페이스 불가지론적 탐색
# ================================================================
def _local_tag(elem):
"""'{namespace}localname''localname'"""
tag = elem.tag
if '}' in tag:
return tag.split('}', 1)[1]
return tag
def _get_attr(elem, attr_name):
"""속성값 가져오기. 네임스페이스 유무 모두 시도."""
# 직접 속성명
val = elem.get(attr_name)
if val is not None:
return val
# 네임스페이스 접두사가 붙은 속성 시도
for full_attr in elem.attrib:
if full_attr.endswith(attr_name):
return elem.attrib[full_attr]
return None
def _find_element(parent, local_name, ns):
"""자식 중 로컬명이 일치하는 첫 번째 엘리먼트를 찾는다.
네임스페이스 prefix 시도 후, 실패하면 로컬명 직접 비교.
"""
# 1차: 네임스페이스 prefix로 탐색
for prefix in ['hp', 'hh', 'hc', 'ha']:
uri = ns.get(prefix, '')
found = parent.find(f'{{{uri}}}{local_name}')
if found is not None:
return found
# 2차: 직계 자식 로컬명 비교
for child in parent:
if _local_tag(child) == local_name:
return child
# 3차: 재귀 탐색 (1단계만)
for child in parent:
for grandchild in child:
if _local_tag(grandchild) == local_name:
return grandchild
return None
def _find_all_elements(parent, local_name, ns):
"""하위 전체에서 로컬명이 일치하는 모든 엘리먼트를 찾는다."""
results = []
def _walk(elem):
if _local_tag(elem) == local_name:
results.append(elem)
for child in elem:
_walk(child)
_walk(parent)
return results
# ================================================================
# 편의 함수
# ================================================================
def summarize(content_order):
"""content_order 리스트를 사람이 읽기 쉬운 요약으로 변환"""
lines = []
for item in content_order:
idx = item['index']
t = item['type']
if t == 'paragraph':
text_preview = item['text'][:50]
if len(item['text']) > 50:
text_preview += '...'
lines.append(
f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} "
f"charPr={item.get('charPrIDRef', '-'):<4s} "
f"\"{text_preview}\""
)
elif t == 'table':
lines.append(
f"[{idx:3d}] T table_idx={item['table_idx']} "
f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})"
)
elif t == 'image':
ref = item.get('binaryItemIDRef', '?')
caption = item.get('text', '')[:30]
lines.append(
f"[{idx:3d}] I image_idx={item['image_idx']} "
f"ref={ref} \"{caption}\""
)
elif t == 'empty':
lines.append(f"[{idx:3d}] _ (empty)")
return '\n'.join(lines)
def get_stats(content_order):
"""content_order 통계 반환"""
type_map = {
'paragraph': 'paragraphs',
'table': 'tables',
'image': 'images',
'empty': 'empty',
}
stats = {
'total': len(content_order),
'paragraphs': 0,
'tables': 0,
'images': 0,
'empty': 0,
}
for item in content_order:
key = type_map.get(item['type'])
if key:
stats[key] += 1
return stats

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""
§3 글꼴(FaceName) 추출
HWPX 실제 태그 (header.xml):
<hh:fontface lang="HANGUL" fontCnt="9">
<hh:font id="0" face="돋움" type="TTF" isEmbedded="0">
<hh:font id="1" face="맑은 고딕" type="TTF" isEmbedded="0">
</hh:fontface>
<hh:fontface lang="LATIN" fontCnt="9">
<hh:font id="0" face="돋움" type="TTF" isEmbedded="0">
</hh:fontface>
디폴트값 생성 안 함. 추출 실패 시 None 반환.
"""
import re
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
"""§3 fontface에서 언어별 글꼴 정의 추출.
Returns:
{
"HANGUL": [{"id": 0, "face": "돋움", "type": "TTF"}, ...],
"LATIN": [{"id": 0, "face": "돋움", "type": "TTF"}, ...],
"HANJA": [...],
...
}
또는 추출 실패 시 None
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
result = {}
# fontface 블록을 lang별로 추출
fontface_blocks = re.findall(
r'<hh:fontface\b[^>]*\blang="([^"]+)"[^>]*>(.*?)</hh:fontface>',
header_xml, re.DOTALL
)
if not fontface_blocks:
return None
for lang, block_content in fontface_blocks:
fonts = []
font_matches = re.finditer(
r'<hh:font\b[^>]*'
r'\bid="(\d+)"[^>]*'
r'\bface="([^"]+)"[^>]*'
r'\btype="([^"]+)"',
block_content
)
for fm in font_matches:
fonts.append({
"id": int(fm.group(1)),
"face": fm.group(2),
"type": fm.group(3),
})
if fonts:
result[lang] = fonts
return result if result else None
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
"""header.xml 문자열을 가져온다."""
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
if isinstance(raw_xml, str):
return raw_xml
return None

View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
"""
§8 머리말/꼬리말(HeaderFooter) 추출
HWPX 실제 태그 (section0.xml):
<hp:headerFooter ...>
<!-- 내용은 section XML 내 또는 별도 header/footer 영역 -->
</hp:headerFooter>
머리말/꼬리말 안에 표가 있는 경우:
- 표의 셀에 다중행 텍스트가 포함될 수 있음
- 각 셀의 colSpan, rowSpan, width, borderFillIDRef 등 추출 필요
secPr 내 속성:
<hp:visibility hideFirstHeader="0" hideFirstFooter="0" .../>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import hwpunit_to_mm
def extract_header(raw_xml: dict, parsed: dict = None) -> dict | None:
"""머리말 구조 추출.
Returns:
{
"exists": True,
"type": "table" | "text",
"hidden": False,
"table": { ... } | None, # 표가 있는 경우
"texts": ["부서명", ...],
}
"""
return _extract_hf(raw_xml, parsed, "header")
def extract_footer(raw_xml: dict, parsed: dict = None) -> dict | None:
"""꼬리말 구조 추출."""
return _extract_hf(raw_xml, parsed, "footer")
def _extract_hf(raw_xml: dict, parsed: dict, hf_type: str) -> dict | None:
"""header 또는 footer 추출 공통 로직"""
# 1) parsed에서 직접 제공된 header/footer XML
hf_xml = None
if parsed:
key = f"page_{hf_type}_xml"
hf_xml = parsed.get(key, "")
# 2) section XML에서 headerFooter 블록 탐색
section_xml = _get_section_xml(raw_xml, parsed)
if not hf_xml and section_xml:
# headerFooter 태그에서 header/footer 구분
hf_blocks = re.findall(
r'<hp:headerFooter\b([^>]*)>(.*?)</hp:headerFooter>',
section_xml, re.DOTALL
)
for attrs, inner in hf_blocks:
# type 속성으로 구분 (HEADER / FOOTER)
type_m = re.search(r'\btype="([^"]+)"', attrs)
if type_m:
if type_m.group(1).upper() == hf_type.upper():
hf_xml = inner
break
if not hf_xml or not hf_xml.strip():
return None # 해당 머리말/꼬리말 없음
result = {"exists": True}
# hidden 여부
if section_xml:
hide_key = f"hideFirst{'Header' if hf_type == 'header' else 'Footer'}"
hide_m = re.search(rf'\b{hide_key}="(\d+)"', section_xml)
if hide_m:
result["hidden"] = bool(int(hide_m.group(1)))
# 텍스트 추출
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', hf_xml)
clean_texts = [t.strip() for t in texts if t.strip()]
if clean_texts:
result["texts"] = clean_texts
# 표 존재 여부
tbl_match = re.search(
r'<hp:tbl\b([^>]*)>(.*?)</hp:tbl>',
hf_xml, re.DOTALL
)
if tbl_match:
result["type"] = "table"
result["table"] = _parse_hf_table(tbl_match.group(1), tbl_match.group(2))
else:
result["type"] = "text"
return result
def _parse_hf_table(tbl_attrs: str, tbl_inner: str) -> dict:
"""머리말/꼬리말 내 표 파싱"""
table = {}
# rowCnt, colCnt
for attr in ["rowCnt", "colCnt"]:
m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs)
if m:
table[attr] = int(m.group(1))
# 열 너비
wl = re.search(r'<hp:widthList>([^<]+)</hp:widthList>', tbl_inner)
if wl:
try:
widths = [int(w) for w in wl.group(1).strip().split()]
table["colWidths_hu"] = widths
total = sum(widths) or 1
table["colWidths_pct"] = [round(w / total * 100) for w in widths]
except ValueError:
pass
# 행/셀
rows = []
tr_blocks = re.findall(r'<hp:tr\b[^>]*>(.*?)</hp:tr>', tbl_inner, re.DOTALL)
for tr in tr_blocks:
cells = []
tc_blocks = re.finditer(
r'<hp:tc\b([^>]*)>(.*?)</hp:tc>', tr, re.DOTALL
)
for tc in tc_blocks:
cell = _parse_hf_cell(tc.group(1), tc.group(2))
cells.append(cell)
rows.append(cells)
if rows:
table["rows"] = rows
return table
def _parse_hf_cell(tc_attrs: str, tc_inner: str) -> dict:
"""머리말/꼬리말 셀 파싱"""
cell = {}
# borderFillIDRef
bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs)
if bf:
cell["borderFillIDRef"] = int(bf.group(1))
# cellAddr
addr = re.search(
r'<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"',
tc_inner
)
if addr:
cell["colAddr"] = int(addr.group(1))
cell["rowAddr"] = int(addr.group(2))
# cellSpan
span = re.search(r'<hp:cellSpan\b([^/]*)/?>', tc_inner)
if span:
cs = re.search(r'\bcolSpan="(\d+)"', span.group(1))
rs = re.search(r'\browSpan="(\d+)"', span.group(1))
if cs:
cell["colSpan"] = int(cs.group(1))
if rs:
cell["rowSpan"] = int(rs.group(1))
# cellSz
sz = re.search(r'<hp:cellSz\b([^/]*)/?>', tc_inner)
if sz:
w = re.search(r'\bwidth="(\d+)"', sz.group(1))
if w:
cell["width_hu"] = int(w.group(1))
# 셀 텍스트 (다중행)
paras = re.findall(r'<hp:p\b[^>]*>(.*?)</hp:p>', tc_inner, re.DOTALL)
lines = []
for p in paras:
p_texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p)
line = " ".join(t.strip() for t in p_texts if t.strip())
if line:
lines.append(line)
if lines:
cell["text"] = " ".join(lines)
cell["lines"] = lines
return cell
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("section_xml"):
return parsed["section_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "section" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""
이미지/그리기 객체(ShapeObject) 추출
HWPX 실제 태그 (section0.xml):
<hp:pic id="..." zOrder="..." ...>
<hp:offset x="0" y="0"/>
<hp:orgSz width="..." height="..."/>
<hp:curSz width="..." height="..."/>
<hp:imgRect>
<hp:pt x="..." y="..."/> <!-- 4개 꼭짓점 -->
</hp:imgRect>
<hp:imgClip .../>
<hp:img binaryItemIDRef="image1.JPG" .../>
</hp:pic>
또는 그리기 객체:
<hp:container id="..." ...>
<hp:offset x="..." y="..."/>
...
</hp:container>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import hwpunit_to_mm
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
"""이미지/그리기 객체 추출.
Returns:
[
{
"type": "image",
"binaryItemRef": "image1.JPG",
"width_hu": 28346, "height_hu": 14173,
"width_mm": 100.0, "height_mm": 50.0,
"offset": {"x": 0, "y": 0},
},
...
]
"""
section_xml = _get_section_xml(raw_xml, parsed)
if not section_xml:
return None
result = []
# <hp:pic> 블록
pic_blocks = re.finditer(
r'<hp:pic\b([^>]*)>(.*?)</hp:pic>',
section_xml, re.DOTALL
)
for pm in pic_blocks:
pic_inner = pm.group(2)
item = {"type": "image"}
# binaryItemRef
img = re.search(r'<hp:img\b[^>]*\bbinaryItemIDRef="([^"]+)"', pic_inner)
if img:
item["binaryItemRef"] = img.group(1)
# curSz (현재 크기)
csz = re.search(
r'<hp:curSz\b[^>]*\bwidth="(\d+)"[^>]*\bheight="(\d+)"',
pic_inner
)
if csz:
w, h = int(csz.group(1)), int(csz.group(2))
item["width_hu"] = w
item["height_hu"] = h
item["width_mm"] = round(hwpunit_to_mm(w), 1)
item["height_mm"] = round(hwpunit_to_mm(h), 1)
# offset
off = re.search(
r'<hp:offset\b[^>]*\bx="(-?\d+)"[^>]*\by="(-?\d+)"',
pic_inner
)
if off:
item["offset"] = {"x": int(off.group(1)), "y": int(off.group(2))}
result.append(item)
return result if result else None
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("section_xml"):
return parsed["section_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "section" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""
번호매기기(Numbering) / 글머리표(Bullet) 추출
HWPX 실제 태그 (header.xml):
<hh:numbering id="1" start="0">
<hh:paraHead start="1" level="1" align="LEFT" useInstWidth="1"
autoIndent="1" widthAdjust="0" textOffsetType="PERCENT"
textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295"
checkable="0">^1.</hh:paraHead>
<hh:paraHead start="1" level="2" ... numFormat="HANGUL_SYLLABLE">^2.</hh:paraHead>
</hh:numbering>
<hh:bullet id="1" char="-" useImage="0">
<hh:paraHead level="0" align="LEFT" .../>
</hh:bullet>
디폴트값 생성 안 함.
"""
import re
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
"""번호매기기 + 글머리표 정의 추출.
Returns:
{
"numberings": [
{
"id": 1, "start": 0,
"levels": [
{"level": 1, "numFormat": "DIGIT", "pattern": "^1.",
"align": "LEFT"},
{"level": 2, "numFormat": "HANGUL_SYLLABLE", "pattern": "^2."},
...
]
}
],
"bullets": [
{"id": 1, "char": "-", "useImage": False}
]
}
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
result = {}
# ── 번호매기기 ──
numbering_blocks = re.findall(
r'<hh:numbering\b([^>]*)>(.*?)</hh:numbering>',
header_xml, re.DOTALL
)
if numbering_blocks:
nums = []
for attrs, inner in numbering_blocks:
num = {}
id_m = re.search(r'\bid="(\d+)"', attrs)
if id_m:
num["id"] = int(id_m.group(1))
start_m = re.search(r'\bstart="(\d+)"', attrs)
if start_m:
num["start"] = int(start_m.group(1))
# paraHead 레벨들
levels = []
heads = re.finditer(
r'<hh:paraHead\b([^>]*)>([^<]*)</hh:paraHead>',
inner
)
for h in heads:
h_attrs = h.group(1)
h_pattern = h.group(2).strip()
level = {}
lv = re.search(r'\blevel="(\d+)"', h_attrs)
if lv:
level["level"] = int(lv.group(1))
fmt = re.search(r'\bnumFormat="([^"]+)"', h_attrs)
if fmt:
level["numFormat"] = fmt.group(1)
al = re.search(r'\balign="([^"]+)"', h_attrs)
if al:
level["align"] = al.group(1)
if h_pattern:
level["pattern"] = h_pattern
if level:
levels.append(level)
if levels:
num["levels"] = levels
nums.append(num)
if nums:
result["numberings"] = nums
# ── 글머리표 ──
bullet_blocks = re.findall(
r'<hh:bullet\b([^>]*)>(.*?)</hh:bullet>',
header_xml, re.DOTALL
)
if bullet_blocks:
bullets = []
for attrs, inner in bullet_blocks:
bullet = {}
id_m = re.search(r'\bid="(\d+)"', attrs)
if id_m:
bullet["id"] = int(id_m.group(1))
char_m = re.search(r'\bchar="([^"]*)"', attrs)
if char_m:
bullet["char"] = char_m.group(1)
img_m = re.search(r'\buseImage="(\d+)"', attrs)
if img_m:
bullet["useImage"] = bool(int(img_m.group(1)))
bullets.append(bullet)
if bullets:
result["bullets"] = bullets
return result if result else None
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
"""
§7 용지 설정 추출 (pagePr + margin)
HWPX 실제 태그:
<hp:pagePr landscape="WIDELY" width="59528" height="84188" gutterType="LEFT_ONLY">
<hp:margin header="4251" footer="4251" gutter="0"
left="5669" right="5669" top="2834" bottom="2834"/>
디폴트값 생성 안 함. 추출 실패 시 None 반환.
"""
import re
from domain.hwpx.hwpx_utils import hwpunit_to_mm, mm_format, detect_paper_size
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
"""§7 pagePr + margin에서 용지/여백 정보 추출.
Returns:
{
"paper": {"name": "A4", "width_mm": 210.0, "height_mm": 297.0,
"landscape": True/False},
"margins": {"top": "10.0mm", "bottom": "10.0mm",
"left": "20.0mm", "right": "20.0mm",
"header": "15.0mm", "footer": "15.0mm",
"gutter": "0.0mm"}
}
또는 추출 실패 시 None
"""
section_xml = _get_section_xml(raw_xml, parsed)
if not section_xml:
return None
result = {}
# ── 용지 크기 ─────────────────────────────────
page_match = re.search(
r'<hp:pagePr\b[^>]*'
r'\bwidth="(\d+)"[^>]*'
r'\bheight="(\d+)"',
section_xml
)
if not page_match:
# 속성 순서가 다를 수 있음
page_match = re.search(
r'<hp:pagePr\b[^>]*'
r'\bheight="(\d+)"[^>]*'
r'\bwidth="(\d+)"',
section_xml
)
if page_match:
h_hu, w_hu = int(page_match.group(1)), int(page_match.group(2))
else:
return None
else:
w_hu, h_hu = int(page_match.group(1)), int(page_match.group(2))
landscape_match = re.search(
r'<hp:pagePr\b[^>]*\blandscape="([^"]+)"', section_xml
)
is_landscape = False
if landscape_match:
is_landscape = landscape_match.group(1) == "WIDELY"
paper_name = detect_paper_size(w_hu, h_hu)
result["paper"] = {
"name": paper_name,
"width_mm": round(hwpunit_to_mm(w_hu), 1),
"height_mm": round(hwpunit_to_mm(h_hu), 1),
"landscape": is_landscape,
}
# ── 여백 ──────────────────────────────────────
margin_match = re.search(r'<hp:margin\b([^/]*)/>', section_xml)
if not margin_match:
return result # 용지 크기는 있으나 여백은 없을 수 있음
attrs_str = margin_match.group(1)
margins = {}
for key in ["top", "bottom", "left", "right", "header", "footer", "gutter"]:
m = re.search(rf'\b{key}="(\d+)"', attrs_str)
if m:
margins[key] = mm_format(int(m.group(1)))
if margins:
result["margins"] = margins
return result
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
"""section XML 문자열을 가져온다."""
# parsed에서 직접 제공
if parsed and parsed.get("section_xml"):
return parsed["section_xml"]
# raw_xml dict에서 section 파일 찾기
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "section" in name.lower() and isinstance(content, str):
return content
# raw_xml이 문자열이면 그대로
if isinstance(raw_xml, str):
return raw_xml
return None

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""
§5 문단 모양(ParaShape) 추출
HWPX 실제 태그 (header.xml):
<hh:paraPr id="0" tabPrIDRef="1" condense="0" ...>
<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>
<hh:heading type="NONE" idRef="0" level="0"/>
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD"
widowOrphan="0" keepWithNext="0" keepLines="0"
pageBreakBefore="0" lineWrap="BREAK"/>
<hp:case ...>
<hh:margin>
<hc:intent value="-1310" unit="HWPUNIT"/>
<hc:left value="0" unit="HWPUNIT"/>
<hc:right value="0" unit="HWPUNIT"/>
<hc:prev value="0" unit="HWPUNIT"/>
<hc:next value="0" unit="HWPUNIT"/>
</hh:margin>
<hh:lineSpacing type="PERCENT" value="130" unit="HWPUNIT"/>
</hp:case>
<hh:border borderFillIDRef="2" .../>
</hh:paraPr>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import hwpunit_to_mm
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
"""§5 paraPr 전체 목록 추출.
Returns:
[
{
"id": 0,
"align": "JUSTIFY",
"verticalAlign": "BASELINE",
"heading": {"type": "NONE", "idRef": 0, "level": 0},
"breakSetting": {
"widowOrphan": False, "keepWithNext": False,
"keepLines": False, "pageBreakBefore": False,
"lineWrap": "BREAK",
"breakLatinWord": "KEEP_WORD",
"breakNonLatinWord": "KEEP_WORD"
},
"margin": {
"indent_hu": -1310, "left_hu": 0, "right_hu": 0,
"before_hu": 0, "after_hu": 0,
},
"lineSpacing": {"type": "PERCENT", "value": 130},
"borderFillIDRef": 2,
"tabPrIDRef": 1,
},
...
]
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
blocks = re.findall(
r'<hh:paraPr\b([^>]*)>(.*?)</hh:paraPr>',
header_xml, re.DOTALL
)
if not blocks:
return None
result = []
for attrs_str, inner in blocks:
item = {}
# id
id_m = re.search(r'\bid="(\d+)"', attrs_str)
if id_m:
item["id"] = int(id_m.group(1))
# tabPrIDRef
tab_m = re.search(r'\btabPrIDRef="(\d+)"', attrs_str)
if tab_m:
item["tabPrIDRef"] = int(tab_m.group(1))
# align
al = re.search(r'<hh:align\b[^>]*\bhorizontal="([^"]+)"', inner)
if al:
item["align"] = al.group(1)
val = re.search(r'<hh:align\b[^>]*\bvertical="([^"]+)"', inner)
if val:
item["verticalAlign"] = val.group(1)
# heading
hd = re.search(
r'<hh:heading\b[^>]*\btype="([^"]+)"[^>]*'
r'\bidRef="(\d+)"[^>]*\blevel="(\d+)"', inner
)
if hd:
item["heading"] = {
"type": hd.group(1),
"idRef": int(hd.group(2)),
"level": int(hd.group(3)),
}
# breakSetting
bs = re.search(r'<hh:breakSetting\b([^/]*)/?>', inner)
if bs:
bstr = bs.group(1)
item["breakSetting"] = {
"widowOrphan": _bool_attr(bstr, "widowOrphan"),
"keepWithNext": _bool_attr(bstr, "keepWithNext"),
"keepLines": _bool_attr(bstr, "keepLines"),
"pageBreakBefore": _bool_attr(bstr, "pageBreakBefore"),
"lineWrap": _str_attr(bstr, "lineWrap"),
"breakLatinWord": _str_attr(bstr, "breakLatinWord"),
"breakNonLatinWord": _str_attr(bstr, "breakNonLatinWord"),
}
# margin (hp:case 블록 내 첫 번째 사용 — HwpUnitChar case 우선)
case_block = re.search(
r'<hp:case\b[^>]*required-namespace="[^"]*HwpUnitChar[^"]*"[^>]*>'
r'(.*?)</hp:case>',
inner, re.DOTALL
)
margin_src = case_block.group(1) if case_block else inner
margin = {}
for tag, key in [
("intent", "indent_hu"),
("left", "left_hu"),
("right", "right_hu"),
("prev", "before_hu"),
("next", "after_hu"),
]:
m = re.search(
rf'<hc:{tag}\b[^>]*\bvalue="(-?\d+)"', margin_src
)
if m:
margin[key] = int(m.group(1))
if margin:
item["margin"] = margin
# lineSpacing
ls = re.search(
r'<hh:lineSpacing\b[^>]*\btype="([^"]+)"[^>]*\bvalue="(\d+)"',
margin_src
)
if ls:
item["lineSpacing"] = {
"type": ls.group(1),
"value": int(ls.group(2)),
}
# borderFillIDRef
bf = re.search(r'<hh:border\b[^>]*\bborderFillIDRef="(\d+)"', inner)
if bf:
item["borderFillIDRef"] = int(bf.group(1))
result.append(item)
return result if result else None
def _bool_attr(s: str, name: str) -> bool | None:
m = re.search(rf'\b{name}="(\d+)"', s)
return bool(int(m.group(1))) if m else None
def _str_attr(s: str, name: str) -> str | None:
m = re.search(rf'\b{name}="([^"]+)"', s)
return m.group(1) if m else None
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""
§9 구역 정의(Section) 추출
HWPX 실제 태그 (section0.xml):
<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134"
tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT"
outlineShapeIDRef="1" ...>
<hp:grid lineGrid="0" charGrid="0" .../>
<hp:startNum pageStartsOn="BOTH" page="0" .../>
<hp:visibility hideFirstHeader="0" hideFirstFooter="0" .../>
<hp:pagePr landscape="WIDELY" width="59528" height="84188" ...>
<hp:margin header="4251" footer="4251" left="5669" right="5669"
top="2834" bottom="2834"/>
<hp:pageNum pos="BOTTOM_CENTER" formatType="DIGIT" sideChar="-"/>
</hp:secPr>
디폴트값 생성 안 함.
"""
import re
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
"""§9 구역 속성 추출.
Returns:
{
"textDirection": "HORIZONTAL",
"hideFirstHeader": False,
"hideFirstFooter": False,
"pageNum": {"pos": "BOTTOM_CENTER", "formatType": "DIGIT",
"sideChar": "-"},
"startNum": {"page": 0},
"colDef": None,
}
"""
section_xml = _get_section_xml(raw_xml, parsed)
if not section_xml:
return None
sec_match = re.search(
r'<hp:secPr\b([^>]*)>(.*?)</hp:secPr>',
section_xml, re.DOTALL
)
if not sec_match:
return None
attrs_str = sec_match.group(1)
inner = sec_match.group(2)
result = {}
# textDirection
td = re.search(r'\btextDirection="([^"]+)"', attrs_str)
if td:
result["textDirection"] = td.group(1)
# visibility
vis = re.search(r'<hp:visibility\b([^/]*)/?>', inner)
if vis:
v = vis.group(1)
for attr in ["hideFirstHeader", "hideFirstFooter",
"hideFirstMasterPage", "hideFirstPageNum",
"hideFirstEmptyLine"]:
m = re.search(rf'\b{attr}="(\d+)"', v)
if m:
result[attr] = bool(int(m.group(1)))
# startNum
sn = re.search(r'<hp:startNum\b([^/]*)/?>', inner)
if sn:
sns = sn.group(1)
start = {}
pso = re.search(r'\bpageStartsOn="([^"]+)"', sns)
if pso:
start["pageStartsOn"] = pso.group(1)
pg = re.search(r'\bpage="(\d+)"', sns)
if pg:
start["page"] = int(pg.group(1))
if start:
result["startNum"] = start
# pageNum
pn = re.search(r'<hp:pageNum\b([^/]*)/?>', inner)
if pn:
pns = pn.group(1)
pagenum = {}
for attr in ["pos", "formatType", "sideChar"]:
m = re.search(rf'\b{attr}="([^"]*)"', pns)
if m:
pagenum[attr] = m.group(1)
if pagenum:
result["pageNum"] = pagenum
# colDef (단 설정)
cd = re.search(r'<hp:colDef\b([^>]*)>(.*?)</hp:colDef>', inner, re.DOTALL)
if cd:
cds = cd.group(1)
coldef = {}
cnt = re.search(r'\bcount="(\d+)"', cds)
if cnt:
coldef["count"] = int(cnt.group(1))
layout = re.search(r'\blayout="([^"]+)"', cds)
if layout:
coldef["layout"] = layout.group(1)
if coldef:
result["colDef"] = coldef
return result if result else None
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("section_xml"):
return parsed["section_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "section" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""
스타일 정의(Style) 추출
HWPX 실제 태그 (header.xml):
<hh:styles itemCnt="12">
<hh:style id="0" type="PARA" name="바탕글" engName="Normal"
paraPrIDRef="3" charPrIDRef="0" nextStyleIDRef="0"
langID="1042" lockForm="0"/>
<hh:style id="1" type="PARA" name="머리말" engName="Header"
paraPrIDRef="2" charPrIDRef="3" nextStyleIDRef="1" .../>
</hh:styles>
charPrIDRef → charPr(글자모양), paraPrIDRef → paraPr(문단모양) 연결.
디폴트값 생성 안 함.
"""
import re
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
"""스타일 정의 추출.
Returns:
[
{
"id": 0, "type": "PARA",
"name": "바탕글", "engName": "Normal",
"paraPrIDRef": 3, "charPrIDRef": 0,
"nextStyleIDRef": 0,
},
...
]
"""
header_xml = _get_header_xml(raw_xml, parsed)
if not header_xml:
return None
styles = re.findall(r'<hh:style\b([^/]*)/>', header_xml)
if not styles:
return None
result = []
for s in styles:
item = {}
for attr in ["id", "paraPrIDRef", "charPrIDRef", "nextStyleIDRef"]:
m = re.search(rf'\b{attr}="(\d+)"', s)
if m:
item[attr] = int(m.group(1))
for attr in ["type", "name", "engName"]:
m = re.search(rf'\b{attr}="([^"]*)"', s)
if m:
item[attr] = m.group(1)
result.append(item)
return result if result else None
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("header_xml"):
return parsed["header_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "header" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

View File

@@ -0,0 +1,328 @@
# -*- coding: utf-8 -*-
"""
§6 표(Table) 구조 추출
HWPX 실제 태그 (section0.xml):
<hp:tbl id="..." rowCnt="5" colCnt="3" cellSpacing="0"
repeatHeader="1" pageBreak="CELL" ...>
<hp:colSz><hp:widthList>8504 8504 8504</hp:widthList></hp:colSz>
또는 열 수에 맞는 hp:colSz 형태
<hp:tr>
<hp:tc name="" header="0" borderFillIDRef="5" ...>
<hp:cellAddr colAddr="0" rowAddr="0"/>
<hp:cellSpan colSpan="2" rowSpan="1"/>
<hp:cellSz width="17008" height="2400"/>
<hp:cellMargin left="510" right="510" top="142" bottom="142"/>
<hp:subList>
<hp:p ...><hp:run ...><hp:t>셀 텍스트</hp:t></hp:run></hp:p>
</hp:subList>
</hp:tc>
</hp:tr>
</hp:tbl>
디폴트값 생성 안 함.
"""
import re
from domain.hwpx.hwpx_utils import hwpunit_to_mm
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
"""§6 모든 표 추출.
Returns:
[
{
"index": 0,
"rowCnt": 5, "colCnt": 3,
"repeatHeader": True,
"pageBreak": "CELL",
"colWidths_hu": [8504, 8504, 8504],
"colWidths_pct": [33, 34, 33],
"rows": [
[ # row 0
{
"colAddr": 0, "rowAddr": 0,
"colSpan": 2, "rowSpan": 1,
"width_hu": 17008, "height_hu": 2400,
"borderFillIDRef": 5,
"cellMargin": {"left": 510, "right": 510,
"top": 142, "bottom": 142},
"text": "셀 텍스트",
"lines": ["셀 텍스트"],
},
...
],
...
],
},
...
]
"""
section_xml = _get_section_xml(raw_xml, parsed)
if not section_xml:
return None
# tbl 블록 전체 추출
tbl_blocks = _find_tbl_blocks(section_xml)
if not tbl_blocks:
return None
result = []
for idx, (tbl_attrs, tbl_inner) in enumerate(tbl_blocks):
tbl = {"index": idx}
# 표 속성
for attr in ["rowCnt", "colCnt"]:
m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs)
if m:
tbl[attr] = int(m.group(1))
rh = re.search(r'\brepeatHeader="(\d+)"', tbl_attrs)
if rh:
tbl["repeatHeader"] = bool(int(rh.group(1)))
pb = re.search(r'\bpageBreak="([^"]+)"', tbl_attrs)
if pb:
tbl["pageBreak"] = pb.group(1)
# 행/셀 (열 너비보다 먼저 — 첫 행에서 열 너비 추출 가능)
rows = _extract_rows(tbl_inner)
if rows:
tbl["rows"] = rows
# 열 너비
col_widths = _extract_col_widths(tbl_inner)
if not col_widths and rows:
# colSz 없으면 행 데이터에서 추출 (colspan 고려)
col_cnt = tbl.get("colCnt", 0)
col_widths = _col_widths_from_rows(rows, col_cnt)
if not col_widths:
col_widths = _col_widths_from_first_row(rows[0])
if col_widths:
tbl["colWidths_hu"] = col_widths
total = sum(col_widths) or 1
tbl["colWidths_pct"] = [round(w / total * 100) for w in col_widths]
result.append(tbl)
return result if result else None
def _find_tbl_blocks(xml: str) -> list:
"""중첩 표를 고려하여 최상위 tbl 블록 추출"""
blocks = []
start = 0
while True:
# <hp:tbl 시작 찾기
m = re.search(r'<hp:tbl\b([^>]*)>', xml[start:])
if not m:
break
attrs = m.group(1)
tag_start = start + m.start()
content_start = start + m.end()
# 중첩 카운트로 닫는 태그 찾기
depth = 1
pos = content_start
while depth > 0 and pos < len(xml):
open_m = re.search(r'<hp:tbl\b', xml[pos:])
close_m = re.search(r'</hp:tbl>', xml[pos:])
if close_m is None:
break
if open_m and open_m.start() < close_m.start():
depth += 1
pos += open_m.end()
else:
depth -= 1
if depth == 0:
inner = xml[content_start:pos + close_m.start()]
blocks.append((attrs, inner))
pos += close_m.end()
start = pos
return blocks
def _extract_col_widths(tbl_inner: str) -> list | None:
"""열 너비 HWPUNIT 추출"""
# 패턴 1: <hp:colSz><hp:widthList>8504 8504 8504</hp:widthList>
wl = re.search(r'<hp:widthList>([^<]+)</hp:widthList>', tbl_inner)
if wl:
try:
return [int(w) for w in wl.group(1).strip().split()]
except ValueError:
pass
# 패턴 2: 개별 colSz 태그
cols = re.findall(r'<hp:colSz\b[^>]*\bwidth="(\d+)"', tbl_inner)
if cols:
return [int(c) for c in cols]
return None
def _extract_rows(tbl_inner: str) -> list:
"""tr/tc 파싱하여 2D 셀 배열 반환"""
rows = []
tr_blocks = re.findall(
r'<hp:tr\b[^>]*>(.*?)</hp:tr>', tbl_inner, re.DOTALL
)
for tr_inner in tr_blocks:
cells = []
tc_blocks = re.finditer(
r'<hp:tc\b([^>]*)>(.*?)</hp:tc>', tr_inner, re.DOTALL
)
for tc_match in tc_blocks:
tc_attrs = tc_match.group(1)
tc_inner = tc_match.group(2)
cell = _parse_cell(tc_attrs, tc_inner)
cells.append(cell)
rows.append(cells)
return rows
def _parse_cell(tc_attrs: str, tc_inner: str) -> dict:
"""개별 셀 파싱"""
cell = {}
# borderFillIDRef on tc tag
bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs)
if bf:
cell["borderFillIDRef"] = int(bf.group(1))
# header flag
hd = re.search(r'\bheader="(\d+)"', tc_attrs)
if hd:
cell["isHeader"] = bool(int(hd.group(1)))
# cellAddr
addr = re.search(
r'<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"',
tc_inner
)
if addr:
cell["colAddr"] = int(addr.group(1))
cell["rowAddr"] = int(addr.group(2))
# cellSpan
span = re.search(r'<hp:cellSpan\b([^/]*)/?>', tc_inner)
if span:
cs = re.search(r'\bcolSpan="(\d+)"', span.group(1))
rs = re.search(r'\browSpan="(\d+)"', span.group(1))
if cs:
cell["colSpan"] = int(cs.group(1))
if rs:
cell["rowSpan"] = int(rs.group(1))
# cellSz
sz = re.search(r'<hp:cellSz\b([^/]*)/?>', tc_inner)
if sz:
w = re.search(r'\bwidth="(\d+)"', sz.group(1))
h = re.search(r'\bheight="(\d+)"', sz.group(1))
if w:
cell["width_hu"] = int(w.group(1))
if h:
cell["height_hu"] = int(h.group(1))
# cellMargin
cm = re.search(r'<hp:cellMargin\b([^/]*)/?>', tc_inner)
if cm:
margin = {}
for side in ["left", "right", "top", "bottom"]:
m = re.search(rf'\b{side}="(\d+)"', cm.group(1))
if m:
margin[side] = int(m.group(1))
if margin:
cell["cellMargin"] = margin
# 셀 텍스트
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', tc_inner)
all_text = " ".join(t.strip() for t in texts if t.strip())
if all_text:
cell["text"] = all_text
# ★ v2: 셀 내 run의 charPrIDRef 추출 (스타일 연결용)
run_cprs = re.findall(r'<hp:run\b[^>]*\bcharPrIDRef="(\d+)"', tc_inner)
if run_cprs:
cell["charPrIDRefs"] = [int(c) for c in run_cprs]
cell["primaryCharPrIDRef"] = int(run_cprs[0])
# ★ v2: 셀 내 p의 paraPrIDRef, styleIDRef 추출
para_pprs = re.findall(r'<hp:p\b[^>]*\bparaPrIDRef="(\d+)"', tc_inner)
if para_pprs:
cell["paraPrIDRefs"] = [int(p) for p in para_pprs]
cell["primaryParaPrIDRef"] = int(para_pprs[0])
para_stys = re.findall(r'<hp:p\b[^>]*\bstyleIDRef="(\d+)"', tc_inner)
if para_stys:
cell["styleIDRefs"] = [int(s) for s in para_stys]
# 다중행 (p 태그 기준)
paras = re.findall(r'<hp:p\b[^>]*>(.*?)</hp:p>', tc_inner, re.DOTALL)
lines = []
for p in paras:
p_texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p)
line = " ".join(t.strip() for t in p_texts if t.strip())
if line:
lines.append(line)
if lines:
cell["lines"] = lines
return cell
def _col_widths_from_first_row(first_row: list) -> list | None:
"""첫 행 셀의 width_hu에서 열 너비 추출 (colSz 없을 때 대체)"""
widths = []
for cell in first_row:
w = cell.get("width_hu")
if w:
widths.append(w)
return widths if widths else None
def _col_widths_from_rows(rows: list, col_cnt: int) -> list | None:
"""★ v2: 모든 행을 순회하여 colspan=1인 행에서 정확한 열 너비 추출.
첫 행에 colspan이 있으면 열 너비가 부정확하므로,
모든 열이 colspan=1인 행을 찾아 사용.
"""
if not rows or not col_cnt:
return None
# colspan=1인 셀만 있는 행 찾기 (모든 열 존재)
for row in rows:
# 이 행의 모든 셀이 colspan=1이고, 셀 수 == col_cnt인지
all_single = all(cell.get("colSpan", 1) == 1 for cell in row)
if all_single and len(row) == col_cnt:
widths = []
for cell in sorted(row, key=lambda c: c.get("colAddr", 0)):
w = cell.get("width_hu")
if w:
widths.append(w)
if len(widths) == col_cnt:
return widths
# 못 찾으면 첫 행 폴백
return _col_widths_from_first_row(rows[0]) if rows else None
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
if parsed and parsed.get("section_xml"):
return parsed["section_xml"]
if isinstance(raw_xml, dict):
for name, content in raw_xml.items():
if "section" in name.lower() and isinstance(content, str):
return content
return raw_xml if isinstance(raw_xml, str) else None

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@@ -0,0 +1,104 @@
당신은 임원보고용 문서 구성 전문가입니다.
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
## 입력
- step1에서 추출된 JSON 구조 데이터
## 출력
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
---
## 배치 원칙
### 1페이지 (본문) - "왜? 무엇이 문제?"
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
### 2페이지~ (첨부) - "어떻게? 상세 기준"
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
- **bottom-box**: 해당 페이지 내용 요약
---
## 요소 역할 분류 기준
| 역할 | 설명 | 배치 |
|------|------|------|
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
| 프로세스 | 단계별 절차, Step | 첨부 |
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
| 체크리스트 | 항목별 점검사항 | 첨부 |
| 상세가이드 | 세부 설명, 예시 | 첨부 |
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
---
## 출력 JSON 스키마
```json
{
"page_plan": {
"page_1": {
"type": "본문",
"lead": {
"source_section": "원본 섹션명 또는 null",
"text": "lead-box에 들어갈 핵심 명제 문장"
},
"sections": [
{
"source": "원본 섹션 제목",
"role": "논리/근거 | 리스크 | 주의사항",
"new_title": "변환 후 섹션 제목 (필요시 수정)"
}
],
"bottom": {
"label": "핵심 결론",
"source": "원본에서 가져올 문장 또는 조합할 키워드",
"text": "bottom-box에 들어갈 최종 문장"
}
},
"page_2": {
"type": "첨부",
"title": "[첨부] 페이지 제목",
"sections": [
{
"source": "원본 섹션 제목",
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
"new_title": "변환 후 섹션 제목"
}
],
"bottom": {
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
"source": "원본에서 가져올 문장",
"text": "bottom-box에 들어갈 최종 문장"
}
}
},
"page_count": 2
}
```
---
## 판단 규칙
1. **프로세스/Step 있으면** → 무조건 첨부로
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
---
## 주의사항
1. 원본에 없는 내용 추가/추론 금지
2. 원본 문장을 선별/조합만 허용
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
4. JSON만 출력 (설명 없이)

View File

@@ -0,0 +1,122 @@
당신은 HTML 문서 구조 분석 전문가입니다.
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
## 규칙
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
2. 문서의 논리적 구조를 정확히 파악
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
## 출력 JSON 스키마
```json
{
"title": "문서 제목 (원문 그대로)",
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
"lead": {
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
"highlight_keywords": ["강조할 키워드1", "키워드2"]
},
"sections": [
{
"number": 1,
"title": "섹션 제목 (원문 그대로)",
"type": "list | table | grid | process | qa | text",
"content": {
// type에 따라 다름 (아래 참조)
}
}
],
"conclusion": {
"label": "라벨 (예: 핵심 결론, 요약 등)",
"text": "결론 텍스트 (원문 그대로, 한 문장)"
}
}
```
## 섹션 type별 content 구조
### type: "list"
```json
{
"items": [
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
]
}
```
### type: "table"
```json
{
"columns": ["컬럼1", "컬럼2", "컬럼3"],
"rows": [
{
"cells": [
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
]
}
],
"footnote": "표 하단 주석 (있으면)"
}
```
- badge 값: "safe" | "caution" | "risk" | null
- highlight: true면 빨간색 강조
### type: "grid"
```json
{
"columns": 2,
"items": [
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
{"title": "② 항목 제목", "text": "설명", "highlight": []}
]
}
```
### type: "two-column"
```json
{
"items": [
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
{"title": "② 제목", "text": "내용", "highlight": []}
]
}
```
### type: "process"
```json
{
"steps": [
{"number": 1, "title": "단계명", "text": "설명"},
{"number": 2, "title": "단계명", "text": "설명"}
]
}
```
### type: "qa"
```json
{
"items": [
{"question": "질문?", "answer": "답변"},
{"question": "질문?", "answer": "답변"}
]
}
```
### type: "text"
```json
{
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
}
```
## 중요
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
3. **JSON만 출력** - 설명 없이 순수 JSON만
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑

View File

@@ -0,0 +1,440 @@
당신은 HTML 보고서 생성 전문가입니다.
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
## 출력 규칙
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
2. 코드 블록(```) 없이 **순수 HTML만** 출력
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
4. 아래 CSS를 **정확히** 사용
## 페이지 옵션
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
## HTML 템플릿 구조
```html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{{title}}</title>
<style>
/* 아래 CSS 전체 포함 */
</style>
</head>
<body>
<div class="sheet">
<header class="page-header">
<div class="header-left">{{department}}</div>
<div class="header-right">{{title_en}}</div>
</header>
<div class="title-block">
<h1 class="header-title">{{title}}</h1>
<div class="title-divider"></div>
</div>
<div class="body-content">
<div class="lead-box">
<div>{{lead.text}} - <b>키워드</b> 강조</div>
</div>
<!-- sections -->
<div class="bottom-box">
<div class="bottom-left">{{conclusion.label}}</div>
<div class="bottom-right">{{conclusion.text}}</div>
</div>
</div>
<footer class="page-footer">- 1 -</footer>
</div>
</body>
</html>
```
## 섹션 type별 HTML 변환
### list → ul/li
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<ul>
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
</ul>
</div>
```
### table → data-table
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<table class="data-table">
<thead>
<tr>
<th width="25%">{{col1}}</th>
<th width="25%">{{col2}}</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="2"><strong>{{text}}</strong></td>
<td class="highlight-red">{{text}}</td>
</tr>
</tbody>
</table>
</div>
```
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
- highlight가 true면: `class="highlight-red"`
### grid → strategy-grid
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="strategy-grid">
<div class="strategy-item">
<div class="strategy-title">{{item.title}}</div>
<p>{{item.text}} <b>{{highlight}}</b></p>
</div>
</div>
</div>
```
### two-column → two-col
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="two-col">
<div class="info-box">
<div class="info-box-title">{{item.title}}</div>
<p>{{item.text}} <b>{{highlight}}</b></p>
</div>
</div>
</div>
```
### process → process-container
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="process-container">
<div class="process-step">
<div class="step-num">{{step.number}}</div>
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
</div>
<div class="arrow">▼</div>
<!-- 반복 -->
</div>
</div>
```
### qa → qa-grid
```html
<div class="section">
<div class="section-title">{{section.title}}</div>
<div class="qa-grid">
<div class="qa-item">
<strong>Q. {{question}}</strong><br>
A. {{answer}}
</div>
</div>
</div>
```
## 완전한 CSS (반드시 이대로 사용)
```css
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary-navy: #1a365d;
--secondary-navy: #2c5282;
--accent-navy: #3182ce;
--dark-gray: #2d3748;
--medium-gray: #4a5568;
--light-gray: #e2e8f0;
--bg-light: #f7fafc;
--text-black: #1a202c;
--border-color: #cbd5e0;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #f0f0f0;
color: var(--text-black);
line-height: 1.55;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
gap: 20px;
word-break: keep-all;
}
.sheet {
background-color: white;
width: 210mm;
height: 297mm;
padding: 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media print {
body { background: none; padding: 0; gap: 0; }
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
.sheet:last-child { page-break-after: auto; }
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
font-size: 9pt;
color: var(--medium-gray);
}
.header-title {
font-size: 23pt;
font-weight: 900;
margin-bottom: 8px;
letter-spacing: -1px;
color: var(--primary-navy);
line-height: 1.25;
text-align: center;
}
.title-divider {
height: 3px;
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
width: 100%;
margin-bottom: 20px;
}
.lead-box {
background-color: var(--bg-light);
border-left: 4px solid var(--primary-navy);
padding: 14px 16px;
margin-bottom: 18px;
}
.lead-box div {
font-size: 11.5pt;
font-weight: 500;
color: var(--dark-gray);
line-height: 1.6;
}
.lead-box b { color: var(--primary-navy); font-weight: 700; }
.body-content { flex: 1; display: flex; flex-direction: column; }
.section { margin-bottom: 16px; }
.section-title {
font-size: 12pt;
font-weight: 700;
display: flex;
align-items: center;
margin-bottom: 10px;
color: var(--primary-navy);
}
.section-title::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
background-color: var(--secondary-navy);
margin-right: 10px;
}
.attachment-title {
font-size: 19pt;
font-weight: 700;
text-align: left;
color: var(--primary-navy);
margin-bottom: 8px;
}
ul { list-style: none; padding-left: 10px; }
li {
font-size: 10.5pt;
position: relative;
margin-bottom: 6px;
padding-left: 14px;
color: var(--dark-gray);
line-height: 1.55;
}
li::before {
content: "•";
position: absolute;
left: 0;
color: var(--secondary-navy);
font-size: 10pt;
}
.bottom-box {
border: 1.5px solid var(--border-color);
display: flex;
margin-top: auto;
min-height: 50px;
margin-bottom: 10px;
}
.bottom-left {
width: 18%;
background-color: var(--primary-navy);
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 700;
font-size: 10.5pt;
color: #fff;
line-height: 1.4;
}
.bottom-right {
width: 82%;
background-color: var(--bg-light);
padding: 12px 18px;
font-size: 10.5pt;
line-height: 1.6;
color: var(--dark-gray);
}
.bottom-right b { display: inline; }
.page-footer {
position: absolute;
bottom: 10mm;
left: 20mm;
right: 20mm;
padding-top: 8px;
text-align: center;
font-size: 8.5pt;
color: var(--medium-gray);
border-top: 1px solid var(--light-gray);
}
b { font-weight: 700; color: var(--primary-navy); display: inline; }
.keyword { font-weight: 600; color: var(--text-black); }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 9.5pt;
border-top: 2px solid var(--primary-navy);
border-bottom: 1px solid var(--border-color);
margin-top: 6px;
}
.data-table th {
background-color: var(--primary-navy);
color: #fff;
font-weight: 600;
padding: 8px 6px;
border: 1px solid var(--secondary-navy);
text-align: center;
font-size: 9pt;
}
.data-table td {
border: 1px solid var(--border-color);
padding: 7px 10px;
vertical-align: middle;
color: var(--dark-gray);
line-height: 1.45;
text-align: left;
}
.data-table td:first-child {
background-color: var(--bg-light);
font-weight: 600;
text-align: center;
}
.highlight-red { color: #c53030; font-weight: 600; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-weight: 600;
font-size: 8.5pt;
}
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
.qa-item strong { color: var(--primary-navy); }
.two-col { display: flex; gap: 12px; margin-top: 6px; }
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
.step-content strong { color: var(--primary-navy); font-weight: 600; }
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
```
## 1페이지 본문 구성 논리
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
## 첨부 페이지 구성
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
## 중요 규칙
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
- 위치 재편성, 통합/분할
- 표 ↔ 본문 ↔ 리스트 형식 변환
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
- ❌ "~입니다", "~합니다", "~됩니다"
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
- 예시:
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
## 첨부 페이지 규칙
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
- 제목은 좌측 정렬, 16pt
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함

View File

@@ -0,0 +1,5 @@
flask==3.0.0
anthropic==0.39.0
gunicorn==21.2.0
python-dotenv==1.0.0
weasyprint==60.1

View File

@@ -0,0 +1,297 @@
/* ===== 편집 바 스타일 ===== */
.format-bar {
display: none;
align-items: center;
padding: 8px 12px;
background: var(--ui-panel);
border-bottom: 1px solid var(--ui-border);
gap: 6px;
flex-wrap: wrap;
}
.format-bar.active { display: flex; }
/* 편집 바 2줄 구조 */
.format-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.format-row:first-child {
border-bottom: 1px solid var(--ui-border);
padding-bottom: 8px;
}
.format-btn {
padding: 6px 10px;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: var(--ui-text);
font-size: 14px;
position: relative;
}
.format-btn:hover { background: var(--ui-hover); }
.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); }
.format-select {
padding: 5px 8px;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
font-size: 12px;
}
.format-divider {
width: 1px;
height: 24px;
background: var(--ui-border);
margin: 0 6px;
}
/* 툴팁 */
.format-btn .tooltip {
position: absolute;
bottom: -28px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 100;
}
.format-btn:hover .tooltip { opacity: 1; }
/* 페이지 버튼 스타일 */
.format-btn.page-btn {
padding: 6px 12px;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
min-width: fit-content;
}
/* 페이지 브레이크 표시 */
.page-break-forced {
border-top: 3px solid #e65100 !important;
margin-top: 10px;
}
.move-to-prev-page {
border-top: 3px dashed #1976d2 !important;
margin-top: 10px;
}
/* 색상 선택기 */
.color-picker-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.color-picker-btn input[type="color"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
/* 편집 모드 활성 블록 */
.active-block {
outline: 2px dashed var(--ui-accent) !important;
outline-offset: 2px;
}
/* 표 삽입 모달 */
.table-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2000;
align-items: center;
justify-content: center;
}
.table-modal.active { display: flex; }
.table-modal-content {
background: var(--ui-panel);
border-radius: 12px;
padding: 24px;
width: 320px;
border: 1px solid var(--ui-border);
}
.table-modal-title {
font-size: 16px;
font-weight: 700;
color: var(--ui-text);
margin-bottom: 20px;
}
.table-modal-row {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.table-modal-row label {
flex: 1;
font-size: 13px;
color: var(--ui-dim);
}
.table-modal-row input[type="number"] {
width: 60px;
padding: 6px 8px;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-bg);
color: var(--ui-text);
text-align: center;
}
.table-modal-row input[type="checkbox"] {
width: 18px;
height: 18px;
}
.table-modal-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.table-modal-btn {
flex: 1;
padding: 10px;
border-radius: 6px;
border: none;
font-size: 13px;
cursor: pointer;
}
.table-modal-btn.primary {
background: var(--ui-accent);
color: #003300;
font-weight: 600;
}
.table-modal-btn.secondary {
background: var(--ui-border);
color: var(--ui-text);
}
/* 토스트 메시지 */
.toast-container {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 3000;
}
.toast {
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
}
.resizable-container { position: relative; display: inline-block; max-width: 100%; }
.resizable-container.block-type { display: block; }
.resize-handle {
position: absolute;
right: -2px;
bottom: -2px;
width: 18px;
height: 18px;
background: #00C853;
cursor: se-resize;
opacity: 0;
transition: opacity 0.2s;
z-index: 100;
border-radius: 3px 0 3px 0;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '⤡';
color: white;
font-size: 12px;
font-weight: bold;
}
.resizable-container:hover .resize-handle { opacity: 0.8; }
.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); }
.resizable-container.resizing { outline: 2px dashed #00C853 !important; }
.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; }
/* 표 전용 */
.resizable-container.table-resize .resize-handle { background: #2196F3; }
.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; }
/* 이미지 전용 */
.resizable-container.figure-resize img { display: block; }
/* 크기 표시 툴팁 */
.size-tooltip {
position: absolute;
bottom: 100%;
right: 0;
background: rgba(0,0,0,0.8);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.resizable-container:hover .size-tooltip,
.resizable-container.resizing .size-tooltip { opacity: 1; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* 인쇄 시 숨김 */
@media print {
.format-bar,
.table-modal,
.toast-container {
display: none !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"id": "briefing",
"name": "기획서",
"icon": "📋",
"description": "1~2페이지 분량의 임원 보고용 문서",
"features": [
{"icon": "📌", "text": "헤더 + 제목 블록"},
{"icon": "💡", "text": "핵심 요약 (Lead Box)"},
{"icon": "📊", "text": "본문 섹션 + 첨부"}
],
"thumbnailType": "briefing",
"enabled": true,
"isDefault": true,
"order": 1,
"options": {
"pageConfig": {
"type": "radio-with-input",
"choices": [
{"value": "body-only", "label": "(본문) 1p"},
{"value": "body-attach", "label": "(본문) 1p + (첨부)", "hasInput": true, "inputSuffix": "p", "inputDefault": 1, "inputMin": 1, "inputMax": 10, "default": true}
]
}
},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2025-01-30T00:00:00Z"
}

View File

@@ -0,0 +1,27 @@
{
"id": "presentation",
"name": "발표자료",
"icon": "📊",
"description": "슬라이드 형식의 프레젠테이션",
"features": [
{"icon": "🎯", "text": "슬라이드 레이아웃"},
{"icon": "📈", "text": "차트/다이어그램"},
{"icon": "🎨", "text": "비주얼 중심 구성"}
],
"thumbnailType": "ppt",
"enabled": false,
"isDefault": true,
"order": 3,
"badge": "준비중",
"options": {
"slideCount": [
{"value": "auto", "label": "자동 (내용 기반)", "default": true},
{"value": "5", "label": "5장 이내"},
{"value": "10", "label": "10장 이내"},
{"value": "20", "label": "20장 이내"}
]
},
"generateFlow": "draft-first",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2025-01-30T00:00:00Z"
}

View File

@@ -0,0 +1,26 @@
{
"id": "report",
"name": "보고서",
"icon": "📄",
"description": "다페이지 분량의 상세 보고서",
"features": [
{"icon": "📘", "text": "표지 (선택)"},
{"icon": "📑", "text": "목차 자동 생성"},
{"icon": "📝", "text": "챕터별 내지"}
],
"thumbnailType": "report",
"enabled": true,
"isDefault": true,
"order": 2,
"options": {
"components": [
{"id": "cover", "label": "표지", "icon": "📘", "default": true},
{"id": "toc", "label": "목차", "icon": "📑", "default": true},
{"id": "divider", "label": "간지", "icon": "📄", "default": false},
{"id": "content", "label": "내지 (필수)", "icon": "📝", "default": true, "required": true}
]
},
"generateFlow": "draft-first",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2025-01-30T00:00:00Z"
}

View File

@@ -0,0 +1,302 @@
# A4 HTML 문서 레이아웃 가이드
> 이 가이드는 글벗 doc_template_analyzer가 HWPX에서 추출한 구조를
> A4 규격 HTML template.html로 변환할 때 참조하는 레이아웃 규격입니다.
>
> ★ 이 파일의 값은 코드에 하드코딩하지 않습니다.
> ★ doc_template_analyzer._build_css(), _build_full_html() 등에서 이 파일을 읽어 적용합니다.
> ★ 색상, 폰트 등 스타일은 HWPX에서 추출한 값을 우선 사용하고, 없으면 이 가이드의 기본값을 사용합니다.
---
## 1. 페이지 규격 (Page Dimensions)
```yaml
page:
width: 210mm # A4 가로
height: 297mm # A4 세로
background: white
boxSizing: border-box
margins:
top: 20mm # 상단 여백 (머릿말 + 본문 시작)
bottom: 20mm # 하단 여백 (꼬릿말 + 본문 끝)
left: 20mm # 좌측 여백
right: 20mm # 우측 여백
# 본문 가용 높이 = 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px
bodyMaxHeight: 970px
```
## 2. HTML 골격 구조 (Page Structure)
각 페이지는 `.sheet` 클래스로 감싸며, 내부에 header/body/footer를 absolute로 배치합니다.
```html
<!-- 페이지 단위 -->
<div class="sheet">
<!-- 머릿말: 상단 여백(20mm) 안, 위에서 10mm 지점 -->
<div class="page-header">
<!-- HWPX 머릿말 테이블 내용 -->
</div>
<!-- 본문: 상하좌우 20mm 안쪽 -->
<div class="body-content">
<!-- 섹션 제목, 표, 개조식 등 -->
</div>
<!-- 꼬릿말: 하단 여백(20mm) 안, 아래에서 10mm 지점 -->
<div class="page-footer">
<!-- HWPX 꼬릿말 테이블 내용 -->
</div>
</div>
```
## 3. 핵심 CSS 레이아웃 (Layout CSS)
### 3.1 용지 (.sheet)
```css
.sheet {
width: 210mm;
height: 297mm;
background: white;
margin: 20px auto; /* 화면 미리보기용 */
position: relative;
overflow: hidden;
box-sizing: border-box;
box-shadow: 0 0 15px rgba(0,0,0,0.1);
}
```
### 3.2 인쇄 대응
```css
@media print {
.sheet { margin: 0; break-after: page; box-shadow: none; }
body { background: white; }
}
```
### 3.3 머릿말 (.page-header)
```css
.page-header {
position: absolute;
top: 10mm; /* 상단 여백(20mm)의 중간 */
left: 20mm;
right: 20mm;
font-size: 9pt;
padding-bottom: 5px;
}
```
- 머릿말이 **테이블 형태**인 경우: `<table>` 사용, 테두리 없음
- HWPX에서 추출한 열 수와 셀 내용을 placeholder로 배치
- 다중행 셀은 `<br>`로 줄바꿈
### 3.4 꼬릿말 (.page-footer)
```css
.page-footer {
position: absolute;
bottom: 10mm; /* 하단 여백(20mm)의 중간 */
left: 20mm;
right: 20mm;
font-size: 9pt;
color: #555;
border-top: 1px solid #eee;
padding-top: 5px;
}
```
- 꼬릿말이 **테이블 형태**인 경우: `<table>` 사용, 테두리 없음
- 2열 이상일 때 `display: flex; justify-content: space-between` 패턴도 가능
- 페이지 번호는 별도 `<span class="pg-num">` 으로
### 3.5 본문 영역 (.body-content)
```css
.body-content {
position: absolute;
top: 20mm;
left: 20mm;
right: 20mm;
bottom: 20mm; /* 또는 auto + JS 제어 */
}
```
## 4. 타이포그래피 기본값 (Typography Defaults)
> HWPX에서 폰트/크기를 추출했으면 그 값을 사용합니다.
> 추출 실패 시 아래 기본값을 적용합니다.
```yaml
typography:
fontFamily: "'Noto Sans KR', sans-serif"
fontImport: "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap"
body:
fontSize: 12pt
lineHeight: 1.6
textAlign: justify
wordBreak: keep-all # 한글 단어 중간 끊김 방지
heading:
h1: { fontSize: 20pt, fontWeight: 900 }
h2: { fontSize: 18pt, fontWeight: 700 }
h3: { fontSize: 14pt, fontWeight: 700 }
headerFooter:
fontSize: 9pt
table:
fontSize: 9.5pt
thFontSize: 9pt
```
## 5. 표 스타일 기본값 (Table Defaults)
```yaml
table:
width: "100%"
borderCollapse: collapse
tableLayout: fixed # colgroup 비율 적용 시 fixed 필수
borderTop: "2px solid" # 상단 굵은 선 (색상은 HWPX 추출)
th:
fontWeight: 900
textAlign: center
verticalAlign: middle
whiteSpace: nowrap # 헤더 셀은 한 줄 유지
wordBreak: keep-all
padding: "6px 5px"
td:
textAlign: center
verticalAlign: middle
wordBreak: keep-all
wordWrap: break-word
padding: "6px 5px"
border: "1px solid #ddd"
```
## 6. 머릿말/꼬릿말 테이블 (Header/Footer Table)
머릿말/꼬릿말이 HWPX에서 테이블로 구성된 경우:
```yaml
headerFooterTable:
border: none # 테두리 없음
width: "100%"
fontSize: 9pt
# 열 역할 패턴 (HWPX에서 추출)
# 보통 3열: [소속정보 | 빈칸/로고 | 작성자/날짜]
# 또는 2열: [제목 | 페이지번호]
cellStyle:
padding: "2px 5px"
verticalAlign: middle
border: none
```
## 7. 개조식 (Bullet Style)
```yaml
bulletList:
marker: "·" # 한국 문서 기본 불릿
className: "bullet-list"
css: |
.bullet-list {
list-style: none;
padding-left: 15px;
margin: 5px 0;
}
.bullet-list li::before {
content: "· ";
font-weight: bold;
}
.bullet-list li {
margin-bottom: 3px;
line-height: 1.5;
}
```
## 8. 색상 (Color Scheme)
> HWPX에서 추출한 색상을 CSS 변수로 주입합니다.
> 추출 실패 시 아래 기본값을 사용합니다.
```yaml
colors:
# Navy 계열 (기본)
primary: "#1a365d"
accent: "#2c5282"
lightBg: "#EBF4FF"
# 문서별 오버라이드 (HWPX 추출값)
# doc_template_analyzer가 HWPX의 글자색/배경색을 분석하여
# 이 값을 덮어씁니다.
css: |
:root {
--primary: #1a365d;
--accent: #2c5282;
--light-bg: #EBF4FF;
--bg: #f5f5f5;
}
```
## 9. 페이지 분할 규칙 (Page Break Rules)
```yaml
pageBreak:
# H1(대제목)에서만 강제 페이지 분할
h1Break: true
# H2/H3이 페이지 하단에 홀로 남지 않도록
orphanControl: true
orphanMinSpace: 90px # 이 공간 미만이면 다음 페이지로
# 표/그림은 분할하지 않음
atomicBlocks:
- table
- figure
- ".highlight-box"
# break-inside: avoid 적용 대상
avoidBreakInside:
- table
- figure
- ".atomic-block"
```
## 10. 배경 (Preview Background)
```yaml
preview:
bodyBackground: "#525659" # 회색 배경 위에 흰색 용지
# 인쇄 시 배경 제거 (@media print)
```
---
## ★ 사용 방법 (How doc_template_analyzer uses this guide)
1. `doc_template_analyzer._build_full_html()` 호출 시:
- 이 가이드 파일을 읽음
- HWPX에서 추출한 스타일(색상, 폰트, 크기)이 있으면 오버라이드
- 없으면 가이드 기본값 사용
2. CSS 생성 순서:
```
가이드 기본값 → HWPX 추출 스타일 오버라이드 → CSS 변수로 통합
```
3. HTML 구조:
```
가이드의 골격(.sheet > .page-header + .body-content + .page-footer)
+ HWPX에서 추출한 placeholder 배치
= template.html
```
4. 색상 결정:
```
HWPX headerTextColor → --primary
HWPX headerBgColor → --light-bg
없으면 → 가이드 기본값(Navy 계열)
```

View File

@@ -0,0 +1,116 @@
{
"_comment": "A4 HTML 문서 레이아웃 기본값 - hwp_html_guide.md 참조. HWPX 추출값이 있으면 오버라이드됨",
"page": {
"width": "210mm",
"height": "297mm",
"background": "white"
},
"margins": {
"top": "20mm",
"bottom": "20mm",
"left": "20mm",
"right": "20mm"
},
"headerPosition": {
"top": "10mm",
"left": "20mm",
"right": "20mm"
},
"footerPosition": {
"bottom": "10mm",
"left": "20mm",
"right": "20mm"
},
"bodyContent": {
"top": "20mm",
"left": "20mm",
"right": "20mm",
"bottom": "20mm"
},
"bodyMaxHeight": "970px",
"preview": {
"bodyBackground": "#f5f5f5",
"sheetMargin": "20px auto",
"sheetShadow": "0 0 15px rgba(0,0,0,0.1)"
},
"typography": {
"fontFamily": "'Noto Sans KR', sans-serif",
"fontImport": "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap",
"body": {
"fontSize": "10pt",
"lineHeight": "1.6",
"textAlign": "justify",
"wordBreak": "keep-all"
},
"heading": {
"h1": { "fontSize": "20pt", "fontWeight": "900" },
"h2": { "fontSize": "16pt", "fontWeight": "700" },
"h3": { "fontSize": "13pt", "fontWeight": "700" }
},
"headerFooter": {
"fontSize": "9pt"
}
},
"colors": {
"primary": "#1a365d",
"accent": "#2c5282",
"lightBg": "#EBF4FF",
"text": "#000",
"headerText": "#000",
"footerText": "#555",
"footerBorder": "#eee",
"tableBorderTop": "#1a365d",
"tableBorder": "#ddd",
"tableHeaderBg": "#EBF4FF"
},
"table": {
"width": "100%",
"borderCollapse": "collapse",
"tableLayout": "fixed",
"fontSize": "9.5pt",
"th": {
"fontSize": "9pt",
"fontWeight": "900",
"textAlign": "center",
"verticalAlign": "middle",
"whiteSpace": "nowrap",
"padding": "6px 5px"
},
"td": {
"textAlign": "center",
"verticalAlign": "middle",
"wordBreak": "keep-all",
"padding": "6px 5px"
}
},
"headerFooterTable": {
"border": "none",
"width": "100%",
"fontSize": "9pt",
"cellPadding": "2px 5px"
},
"bulletList": {
"marker": "·",
"className": "bullet-list",
"paddingLeft": "15px",
"itemMargin": "3px 0"
},
"pageBreak": {
"h1Break": true,
"orphanControl": true,
"orphanMinSpace": "90px"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
{
"id": "user_1770300969",
"name": "3",
"icon": "📄",
"description": "3",
"features": [
{
"icon": "📋",
"text": "발표 기획서"
},
{
"icon": "🎯",
"text": "특정 주제에 대한 발표 내용..."
},
{
"icon": "👥",
"text": "상위 결재자 또는 발표 승인권자"
},
{
"icon": "📄",
"text": "약 2p"
}
],
"thumbnailType": "custom",
"enabled": true,
"isDefault": false,
"order": 100,
"template_id": "tpl_1770300969",
"context": {
"documentDefinition": "발표를 하기 위한 기획서",
"documentType": "발표 기획서",
"purpose": "특정 주제에 대한 발표 내용과 구성을 사전에 계획하고 승인받기 위함",
"perspective": "발표할 내용을 체계적으로 구조화하여 청중에게 효과적으로 전달할 수 있도록 기획하는 관점",
"audience": "상위 결재자 또는 발표 승인권자",
"tone": "제안형"
},
"layout": {
"hasHeader": true,
"headerLayout": {
"structure": "테이블",
"colCount": 3,
"rowCount": 1,
"cellTexts": [
"총괄기획실 기술기획팀",
"",
"2025. 2. 5(목)"
],
"cellLines": [
[
"총괄기획실",
"기술기획팀"
],
[],
[
"2025. 2. 5(목)"
]
]
},
"hasFooter": true,
"footerLayout": {
"structure": "테이블",
"colCount": 3,
"rowCount": 1,
"cellTexts": [
"기술 로 사람 과 자연 이 함께하는 세상을 만들어 갑니다.",
"",
""
],
"cellLines": [
[
"기술 로 사람 과 자연 이",
"함께하는 세상을 만들어 갑니다."
],
[],
[]
]
},
"titleBlock": {
"type": "테이블",
"colCount": 2,
"text": "AI 업무 활용 적용 사례 발표 계획(안)"
},
"sections": [
{
"name": "개요",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "발표 구성(안)",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "발표 내용",
"hasBulletIcon": true,
"hasTable": true,
"tableIndex": 0
}
],
"overallStyle": {
"writingStyle": "개조식",
"bulletType": "-",
"tableUsage": "보통"
}
},
"structure": {
"sectionGuides": [
{
"name": "개요",
"role": "발표의 목적과 배경을 명확히 제시하여 청중의 이해를 돕는 섹션",
"writingStyle": "개조식",
"contentGuide": "발표 주제, 발표 목적, 대상 청중, 핵심 메시지를 간결하게 나열. 불릿 포인트로 구성하여 핵심 내용을 한눈에 파악할 수 있도록 작성",
"hasTable": false
},
{
"name": "발표 구성(안)",
"role": "발표의 전체 흐름과 구조를 미리 보여주어 발표 진행 방향을 안내하는 섹션",
"writingStyle": "개조식",
"contentGuide": "발표 제목과 부제목을 명시하고, 발표의 전체적인 틀을 제시. 청중이 발표 흐름을 예측할 수 있도록 구성",
"hasTable": false
},
{
"name": "발표 내용",
"role": "실제 발표에서 다룰 구체적인 내용을 체계적으로 정리하여 발표 준비를 완성하는 핵심 섹션",
"writingStyle": "개조식",
"contentGuide": "발표 순서에 따라 각 단계별 내용을 상세히 기술. 표를 활용하여 구조화된 정보 제공",
"hasTable": true,
"tableStructure": {
"columns": 3,
"columnDefs": [
{
"name": "구분",
"role": "발표 단계나 주제를 구분하는 분류 기준",
"style": "간결한 키워드나 단계명으로 작성"
},
{
"name": "내용",
"role": "각 구분별 구체적인 발표 내용과 세부사항",
"style": "상세한 설명과 하위 항목을 포함한 개조식 나열"
},
{
"name": "비고",
"role": "추가 정보나 참고사항, 시간 배분 등 부가적인 안내",
"style": "간략한 메모나 시간, 페이지 수 등의 보조 정보"
}
],
"rowGuide": "각 행은 발표의 논리적 흐름에 따라 순차적으로 배열되며, 하나의 발표 단계나 주요 주제를 나타냄"
}
}
],
"writingPrinciples": [
"발표자와 청중 모두가 이해하기 쉽도록 개조식으로 간결하게 작성",
"발표의 논리적 흐름을 고려하여 구조화된 정보 제공",
"표를 활용하여 복잡한 내용을 체계적으로 정리",
"각 섹션은 발표 준비와 실행에 필요한 실용적 정보 중심으로 구성"
],
"pageEstimate": 2
},
"options": {},
"createdAt": "2026-02-05T23:16:09Z",
"updatedAt": "2026-02-05T23:16:09Z"
}

View File

@@ -0,0 +1,267 @@
{
"version": "1.0",
"document": {
"paper": "A4",
"layout": "landscape",
"margins": {
"top": "10.0mm",
"bottom": "10.0mm",
"left": "20.0mm",
"right": "20.0mm",
"header": "15.0mm",
"footer": "15.0mm",
"gutter": "0.0mm"
},
"purpose_hint": "",
"audience_hint": "",
"tone_hint": ""
},
"placeholders": {
"HEADER_R1_C1_LINE_1": {
"type": "department",
"pattern": "조직명",
"example": "총괄기획실",
"location": "header"
},
"HEADER_R1_C1_LINE_2": {
"type": "team",
"pattern": "팀명",
"example": "기술기획팀",
"location": "header"
},
"HEADER_R1_C2": {
"type": "empty",
"pattern": "빈 셀 (로고/여백)",
"example": "",
"location": "header"
},
"HEADER_R1_C3": {
"type": "date",
"pattern": "날짜 (YYYY. M. D)",
"example": "2025. 2. 5(목)",
"location": "header"
},
"FOOTER_R1_C1_LINE_1": {
"type": "text",
"pattern": "자유 텍스트",
"example": "기술 로 사람 과 자연 이",
"location": "footer"
},
"FOOTER_R1_C1_LINE_2": {
"type": "slogan",
"pattern": "회사 슬로건/비전",
"example": "함께하는 세상을 만들어 갑니다.",
"location": "footer"
},
"FOOTER_R1_C2": {
"type": "empty",
"pattern": "빈 셀 (로고/여백)",
"example": "",
"location": "footer"
},
"FOOTER_R1_C3": {
"type": "empty",
"pattern": "빈 셀 (로고/여백)",
"example": "",
"location": "footer"
},
"TITLE_R1_C2": {
"type": "doc_title",
"pattern": "문서 제목",
"example": "AI 업무 활용 적용 사례 발표 계획(안)",
"location": "title_block"
},
"SECTION_1_TITLE": {
"type": "section_title",
"pattern": "섹션 제목",
"example": "",
"location": "body"
},
"IMAGE_1": {
"type": "image",
"pattern": "이미지",
"example_ref": "image1",
"location": "body"
},
"IMAGE_1_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 개요",
"location": "body"
},
"IMAGE_2": {
"type": "image",
"pattern": "이미지",
"example_ref": "image2",
"location": "body"
},
"IMAGE_2_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " AI를 활용한 “업무 효율성 개선 사례”와 이를 구현한 방식에 대한 공유",
"location": "body"
},
"PARA_1": {
"type": "text",
"pattern": "자유 텍스트",
"example": "삼안의 임원 대상 「글벗」 소개와 이를 구현한 방식에 대한 예시 시연",
"location": "body"
},
"IMAGE_3": {
"type": "image",
"pattern": "이미지",
"example_ref": "image1",
"location": "body"
},
"IMAGE_3_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 발표 구성(안)",
"location": "body"
},
"IMAGE_4": {
"type": "image",
"pattern": "이미지",
"example_ref": "image2",
"location": "body"
},
"IMAGE_4_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 제목 : AI 활용 문서 업무 개선 사례 -「글벗」(사용자의 글쓰기를 돕는 친구) -",
"location": "body"
},
"IMAGE_5": {
"type": "image",
"pattern": "이미지",
"example_ref": "image2",
"location": "body"
},
"IMAGE_5_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 발표 내용 ",
"location": "body"
},
"TABLE_1_H_C1": {
"type": "table_header",
"pattern": "표 열 제목",
"example": "구분",
"location": "table_1"
},
"TABLE_1_H_C2": {
"type": "table_header",
"pattern": "표 열 제목",
"example": "내용",
"location": "table_1"
},
"TABLE_1_H_C3": {
"type": "table_header",
"pattern": "표 열 제목",
"example": "비고",
"location": "table_1"
},
"TABLE_1_BODY": {
"type": "table_body",
"pattern": "표 데이터 행들 (HTML <tr> 반복)",
"example": "",
"location": "table_1"
}
},
"table_guide": {
"1": {
"col_headers": [
"구분",
"내용",
"비고"
],
"col_count": 3,
"row_count": 5,
"merge_pattern": {
"col_0": "col_span",
"col_3": "row_group"
},
"bullet_chars": [
"- ",
"· "
],
"example_rows": [
[
"소개",
"개요",
"- 현황 및 문제점 : 인적 오류와 추가적 리소스(인력, 시간) 투입 · 동일한 원천데이터로 산출물 형식만 달라짐 (제안서, 보고서 등) ...",
"1p"
],
[
"글벗 소개",
"- 글벗 기능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목차 구성 및 문서 작성 / (Edit) 편집기 ·..."
],
[
"시연",
"글벗 시연",
"- (기능 1) (Input) 업로드한 문서 기반 목차 정리 / 작성 - (기능 2) (Process) 웹 편집기 - (기능 3) (Exp...",
"글벗 &amp; Visual Studio"
]
],
"col_types": [
{
"col": 0,
"type": "category",
"header": "구분"
},
{
"col": 1,
"type": "content",
"header": "내용"
},
{
"col": 2,
"type": "note",
"header": "비고"
}
],
"row_bf_pattern": [
{
"col": 0,
"bf_class": "bf-12",
"colSpan": 1,
"rowSpan": 2
},
{
"col": 1,
"bf_class": "bf-8",
"colSpan": 1,
"rowSpan": 1
},
{
"col": 2,
"bf_class": "bf-7",
"colSpan": 1,
"rowSpan": 1
},
{
"col": 3,
"bf_class": "bf-19",
"colSpan": 1,
"rowSpan": 2
}
]
}
},
"writing_guide": {
"bullet_styles": [
"- ",
"· "
],
"numbering_patterns": [
[
"^1.",
"^2.",
"^3)"
]
],
"avg_line_length": 16,
"font_primary": "돋움",
"font_size_body": "10.0pt"
}
}

View File

@@ -0,0 +1,184 @@
{
"id": "user_1770301063",
"name": "55",
"icon": "📄",
"description": "55",
"features": [
{
"icon": "📋",
"text": "평가보고서"
},
{
"icon": "🎯",
"text": "완성된 제품/솔루션의 현황을..."
},
{
"icon": "👥",
"text": "개발팀, 관리자, 의사결정권자"
},
{
"icon": "📄",
"text": "약 2p"
}
],
"thumbnailType": "custom",
"enabled": true,
"isDefault": false,
"order": 100,
"template_id": "tpl_1770301063",
"context": {
"documentDefinition": "개발된 솔루션을 검토하고 개선방향을 제시하기 위한 평가보고서",
"documentType": "평가보고서",
"purpose": "완성된 제품/솔루션의 현황을 정리하고, 장단점을 분석하여 향후 개선방향을 제시",
"perspective": "객관적 평가와 건설적 개선안 도출 관점으로 재구성",
"audience": "개발팀, 관리자, 의사결정권자",
"tone": "분석형/제안형"
},
"layout": {
"hasHeader": false,
"headerLayout": {
"structure": "없음"
},
"hasFooter": false,
"footerLayout": {
"structure": "없음"
},
"titleBlock": {
"type": "없음"
},
"sections": [
{
"name": "1. (스마트설계팀) SamanPro(V3.0)",
"hasBulletIcon": false,
"hasTable": false,
"tableIndex": null
},
{
"name": "내용 요약",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "주요 기능",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "관련 의견",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "장점",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "확인 필요 지점",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
},
{
"name": "개선 방향 제안",
"hasBulletIcon": true,
"hasTable": false,
"tableIndex": null
}
],
"overallStyle": {
"writingStyle": "혼합",
"bulletType": "·",
"tableUsage": "보통"
}
},
"structure": {
"sectionGuides": [
{
"name": "1. (스마트설계팀) SamanPro(V3.0)",
"role": "평가 대상 솔루션의 제목과 개발팀 정보를 명시하는 헤더 섹션",
"writingStyle": "제목식",
"contentGuide": "순번, 개발팀명(괄호), 솔루션명, 버전 정보를 포함한 간결한 제목 형식",
"hasTable": false
},
{
"name": "내용 요약",
"role": "솔루션의 핵심 목적과 AI 활용 방식을 간략히 개괄하는 섹션",
"writingStyle": "서술식",
"contentGuide": "솔루션의 주요 목적, AI 기술 활용 방법, 전체적인 접근 방식을 2-3문장으로 요약",
"hasTable": false
},
{
"name": "주요 기능",
"role": "솔루션에서 제공하는 구체적인 기능들을 카테고리별로 나열하는 섹션",
"writingStyle": "개조식",
"contentGuide": "기능 카테고리별로 구분하여 나열하며, 각 기능의 세부 사항과 활용 방법을 불릿 포인트로 정리. 부가 설명은 각주나 괄호로 표기",
"hasTable": false
},
{
"name": "관련 의견",
"role": "솔루션에 대한 전반적인 평가와 특징을 제시하는 섹션",
"writingStyle": "개조식",
"hasTable": true,
"contentGuide": "솔루션의 핵심 특징과 접근 방식을 평가자 관점에서 서술. 표를 통해 구체적 내용을 보완",
"tableStructure": {
"columns": 3,
"columnDefs": [
{
"name": "좌측 여백",
"role": "여백 또는 표시자",
"style": "빈 칸"
},
{
"name": "주요 내용",
"role": "평가 의견의 핵심 내용",
"style": "서술식 문장"
},
{
"name": "우측 여백",
"role": "여백 또는 추가 정보",
"style": "빈 칸 또는 보조 정보"
}
],
"rowGuide": "평가자의 관점에서 본 솔루션의 특징과 의의를 중앙 열에 기술"
}
},
{
"name": "장점",
"role": "솔루션의 긍정적인 측면들을 구체적으로 나열하는 섹션",
"writingStyle": "개조식",
"contentGuide": "솔루션의 우수한 점들을 불릿 포인트로 나열하며, 정량적 성과나 구체적 사례를 포함하여 설득력 있게 작성",
"hasTable": false
},
{
"name": "확인 필요 지점",
"role": "솔루션 운영 시 검토가 필요한 사항들을 질문 형태로 제시하는 섹션",
"writingStyle": "개조식",
"contentGuide": "운영상 고려해야 할 이슈들을 질문 형태로 제기하고, 화살표(→)를 사용하여 해결 방향이나 고려사항을 제안",
"hasTable": false
},
{
"name": "개선 방향 제안",
"role": "솔루션의 향후 발전 방향과 개선 사항들을 구체적으로 제안하는 섹션",
"writingStyle": "개조식",
"contentGuide": "개선 영역별로 구분하여 제안사항을 나열하며, 번호나 괄호를 사용하여 세부 항목을 정리. 예시나 구체적 방안을 포함하여 실행 가능한 제안으로 작성",
"hasTable": false
}
],
"writingPrinciples": [
"기술적 세부사항과 업무 프로세스를 정확히 이해하고 전문적으로 평가",
"장점과 개선점을 균형있게 제시하여 객관적 평가 유지",
"구체적 사례와 정량적 데이터를 활용하여 설득력 있는 평가 작성",
"실무진이 실제 적용할 수 있는 구체적이고 실행 가능한 개선 방안 제시",
"기술 발전과 업무 효율성 측면에서 솔루션의 가치를 다각도로 분석"
],
"pageEstimate": 2
},
"options": {},
"createdAt": "2026-02-05T23:17:43Z",
"updatedAt": "2026-02-05T23:17:43Z"
}

View File

@@ -0,0 +1,295 @@
{
"version": "1.0",
"document": {
"paper": "A4",
"layout": "landscape",
"margins": {
"top": "10.0mm",
"bottom": "10.0mm",
"left": "20.0mm",
"right": "20.0mm",
"header": "15.0mm",
"footer": "15.0mm",
"gutter": "0.0mm"
},
"purpose_hint": "",
"audience_hint": "",
"tone_hint": ""
},
"placeholders": {
"SECTION_1_TITLE": {
"type": "section_title",
"pattern": "섹션 제목",
"example": "1. (스마트설계팀) SamanPro(V3.0)",
"location": "body"
},
"IMAGE_1": {
"type": "image",
"pattern": "이미지",
"example_ref": "image9",
"location": "body"
},
"IMAGE_1_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 내용 요약",
"location": "body"
},
"IMAGE_2": {
"type": "image",
"pattern": "이미지",
"example_ref": "image10",
"location": "body"
},
"IMAGE_2_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 반복적 도로 설계 계산 작업과 행정의 자동화를 위한 통합 설계 플랫폼 구축",
"location": "body"
},
"PARA_1": {
"type": "text",
"pattern": "자유 텍스트",
"example": "AI는 앱 개발 과정에서 코드 생성과 에러 해결을 위한 방식으로 활용",
"location": "body"
},
"IMAGE_3": {
"type": "image",
"pattern": "이미지",
"example_ref": "image10",
"location": "body"
},
"IMAGE_3_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 주요 기능",
"location": "body"
},
"PARA_2": {
"type": "text",
"pattern": "자유 텍스트",
"example": "계산, 외부 데이터 확인(API) 및 제안서 작성 시 활용할 수 있는 프롬프트 등",
"location": "body"
},
"PARA_3": {
"type": "text",
"pattern": "자유 텍스트",
"example": " · 포장설계, 동결심도, 수리계산, 확폭계산, 편경사계산 등 설계 관련 계산 기능과 착수 일자와 과업기간 입력 시, 중공일자 표출되는 준공계 보조 계산 기능 등",
"location": "body"
},
"PARA_4": {
"type": "text",
"pattern": "자유 텍스트",
"example": " · 한국은행 API를 활용하여 (연도별/분기별) 건설투자 GDP 제공",
"location": "body"
},
"PARA_5": {
"type": "text",
"pattern": "자유 텍스트",
"example": " · 제안서 작성 시 AI에게 입력할 발주처(도로공사, 국토관리청, 지자체)별 기초 프롬프트*",
"location": "body"
},
"PARA_6": {
"type": "text",
"pattern": "자유 텍스트",
"example": " ※ 프롬프트 구성 : 역학과 목표, 입력 정의, 산출물 요구사항, 작업절차 단계, 출력 형식 등",
"location": "body"
},
"PARA_7_RUN_1": {
"type": "text",
"pattern": "자유 텍스트",
"example": "야근일자 계산기, 모니터 끄기, 자동종료 및 재시작, 계산기, pc 클리너 기능",
"location": "body",
"run_index": 1
},
"PARA_7_RUN_2": {
"type": "text",
"pattern": "자유 텍스트",
"example": "*",
"location": "body",
"run_index": 2
},
"PARA_8": {
"type": "text",
"pattern": "자유 텍스트",
"example": " ※ GUI 기반의 앱으로 시간에 맞추어 자동종료, 재시작, PC 클린 등 수행",
"location": "body"
},
"IMAGE_4": {
"type": "image",
"pattern": "이미지",
"example_ref": "image9",
"location": "body"
},
"IMAGE_4_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 관련 의견",
"location": "body"
},
"PARA_9": {
"type": "text",
"pattern": "자유 텍스트",
"example": "※ 도로배수시설 설계 및 유지관리지침, 설계유량(합리식(Rational formula), 흐름해석(Manning 공식) 등 반영",
"location": "body"
},
"IMAGE_5": {
"type": "image",
"pattern": "이미지",
"example_ref": "image10",
"location": "body"
},
"IMAGE_5_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 장점",
"location": "body"
},
"PARA_10": {
"type": "text",
"pattern": "자유 텍스트",
"example": "비개발자가 AI를 통해 개발 및 사내에 공유 (경영진 92회 포함 총 712회 사용 등)",
"location": "body"
},
"PARA_11": {
"type": "text",
"pattern": "자유 텍스트",
"example": "(제안서 프롬프트) 질문-수집-생성 파이프라인까지 체계적으로 구축",
"location": "body"
},
"IMAGE_6": {
"type": "image",
"pattern": "이미지",
"example_ref": "image10",
"location": "body"
},
"IMAGE_6_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 확인 필요 지점 ",
"location": "body"
},
"PARA_12": {
"type": "text",
"pattern": "자유 텍스트",
"example": "지침 개정 시, 계산 로직 또는 기준값 등을 사람이 확인, 반영하는 것?",
"location": "body"
},
"PARA_13": {
"type": "text",
"pattern": "자유 텍스트",
"example": " → 개정 반영 표준화 또는 파이프라인 등을 통하여 운영 체계를 구축하는 것에 대한 고려",
"location": "body"
},
"IMAGE_7": {
"type": "image",
"pattern": "이미지",
"example_ref": "image10",
"location": "body"
},
"IMAGE_7_CAPTION": {
"type": "image_caption",
"pattern": "이미지 캡션",
"example": " 개선 방향 제안",
"location": "body"
},
"PARA_14": {
"type": "text",
"pattern": "자유 텍스트",
"example": "(제안서 프롬프트) ① 상용 AI 모델의 업데이트 상황에 따른 품질 변동, ② 특정 모델에 최적화, ③ 단일 프롬프트에 모든 단계를 포함하여 중간 결과물의 유실될 가능성(단계를 ",
"location": "body"
},
"PARA_15": {
"type": "slogan",
"pattern": "회사 슬로건/비전",
"example": "(수리 계산 기준 표출) 기준과 버전 사항들도 함께 계산기 내에서 표출될 필요",
"location": "body"
},
"PARA_16": {
"type": "text",
"pattern": "자유 텍스트",
"example": " (예) 수리계산(Box/Pipe) : 도로배수시설 설계 및 유지관리지침(2025) 반영 ",
"location": "body"
},
"PARA_17": {
"type": "text",
"pattern": "자유 텍스트",
"example": "(계산 결과 출력) 기준, 입력 변수, 산식, 출력 결과값 등이 바로 활용될 수 있도록 한글(HWP), 엑셀이나 특정 템플릿 등으로 출력을 고려",
"location": "body"
},
"PARA_18": {
"type": "text",
"pattern": "자유 텍스트",
"example": "(향후 로드맵) AI 기반 설계 검토와 BIM 연동 등은 지금 기술 대비 난이도가 크게 상승(AI API 적용, 파이프라인 구축 등), 단계별 검증과 구체적 마일스톤 수립이 필요",
"location": "body"
},
"TABLE_1_BODY": {
"type": "table_body",
"pattern": "표 데이터 행들 (HTML <tr> 반복)",
"example": "",
"location": "table_1"
}
},
"table_guide": {
"1": {
"col_headers": [],
"col_count": 3,
"row_count": 3,
"merge_pattern": {},
"bullet_chars": [],
"example_rows": [
[
"",
"",
""
],
[
"",
"지침 기반의 정형 계산 * 과 행정 보조를 GUI 앱으로 통합해 반복 업무를 자동화한 실무 도구",
""
],
[
"",
"",
""
]
],
"col_types": [],
"row_bf_pattern": [
{
"col": 0,
"bf_class": "bf-4",
"colSpan": 1,
"rowSpan": 1
},
{
"col": 1,
"bf_class": "bf-5",
"colSpan": 1,
"rowSpan": 1
},
{
"col": 2,
"bf_class": "bf-6",
"colSpan": 1,
"rowSpan": 1
}
]
}
},
"writing_guide": {
"bullet_styles": [
"- "
],
"numbering_patterns": [
[
"^1.",
"^2.",
"^3)"
]
],
"avg_line_length": 57,
"font_primary": "돋움",
"font_size_body": "10.0pt"
}
}

View File

@@ -0,0 +1,15 @@
{
"id": "tpl_1770300969",
"name": "3 양식",
"original_file": "sample.hwpx",
"file_type": ".hwpx",
"description": "3에서 추출한 문서 양식",
"features": [
"폰트: 돋움",
"머릿말: 3열",
"꼬릿말: 3열",
"표: 5x4"
],
"created_at": "2026-02-05T23:16:09Z",
"source": "doc_template_analyzer"
}

View File

@@ -0,0 +1,222 @@
{
"version": "1.0",
"table_roles": {
"0": {
"role": "footer_table",
"match_source": "footer",
"matched_texts": [
"기술 로 사람 과 자연 이 함께하는 세상을 만들어 갑니다."
]
},
"1": {
"role": "header_table",
"match_source": "header",
"matched_texts": [
"2025. 2. 5(목)",
"총괄기획실 기술기획팀"
]
},
"2": {
"role": "title_block",
"title_text": "AI 업무 활용 적용 사례 발표 계획(안)"
},
"3": {
"role": "data_table",
"header_row": 0,
"col_headers": [
"구분",
"내용",
"비고"
],
"row_count": 5,
"col_count": 4
}
},
"body_tables": [
3
],
"title_table": 2,
"sections": [],
"style_mappings": {
"char_pr": {},
"border_fill": {
"1": {
"css_class": "bf-1",
"bg": "",
"borders": {}
},
"2": {
"css_class": "bf-2",
"bg": "",
"borders": {}
},
"3": {
"css_class": "bf-3",
"bg": "",
"borders": {
"border-left": "0.12mm solid #000000",
"border-right": "0.12mm solid #000000",
"border-top": "0.12mm solid #000000",
"border-bottom": "0.12mm solid #000000"
}
},
"4": {
"css_class": "bf-4",
"bg": "",
"borders": {
"border-bottom": "0.7mm solid #3057B9"
}
},
"5": {
"css_class": "bf-5",
"bg": "",
"borders": {}
},
"6": {
"css_class": "bf-6",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.3mm solid #BBBBBB"
}
},
"7": {
"css_class": "bf-7",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"8": {
"css_class": "bf-8",
"bg": "#EDEDED",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"9": {
"css_class": "bf-9",
"bg": "#EDEDED",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.3mm solid #BBBBBB"
}
},
"10": {
"css_class": "bf-10",
"bg": "#DCDCDC",
"borders": {
"border-right": "0.12mm solid #999999",
"border-top": "0.3mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"11": {
"css_class": "bf-11",
"bg": "#EDEDED",
"borders": {
"border-right": "0.12mm solid #999999",
"border-top": "0.5mm solid #BBBBBB",
"border-bottom": "0.3mm solid #BBBBBB"
}
},
"12": {
"css_class": "bf-12",
"bg": "#EDEDED",
"borders": {
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.5mm solid #BBBBBB"
}
},
"13": {
"css_class": "bf-13",
"bg": "#DCDCDC",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.3mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"14": {
"css_class": "bf-14",
"bg": "#DCDCDC",
"borders": {
"border-left": "0.12mm solid #999999",
"border-top": "0.3mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"15": {
"css_class": "bf-15",
"bg": "#EDEDED",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.5mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"16": {
"css_class": "bf-16",
"bg": "#EDEDED",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.5mm solid #BBBBBB"
}
},
"17": {
"css_class": "bf-17",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.5mm solid #BBBBBB",
"border-bottom": "0.12mm solid #BBBBBB"
}
},
"18": {
"css_class": "bf-18",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-right": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.5mm solid #BBBBBB"
}
},
"19": {
"css_class": "bf-19",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-top": "0.12mm solid #BBBBBB",
"border-bottom": "0.5mm solid #BBBBBB"
}
},
"20": {
"css_class": "bf-20",
"bg": "",
"borders": {
"border-left": "0.12mm solid #999999",
"border-top": "0.5mm solid #BBBBBB",
"border-bottom": "0.3mm solid #BBBBBB"
}
}
},
"para_pr": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Template</title>
<style>
@page {
size: 210.0mm 297.0mm;
margin: 10.0mm 20.0mm 10.0mm 20.0mm;
}
@media screen {
@page { margin: 0; }
}
body {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
line-height: 180%;
color: #000000;
margin: 0; padding: 0;
}
.page {
width: 170mm;
margin: 0 auto;
padding: 10.0mm 20.0mm 10.0mm 20.0mm;
}
/* 헤더/푸터 */
.doc-header { margin-bottom: 4.5mm; }
.doc-footer { margin-top: 6.0mm; }
.doc-header table, .doc-footer table {
width: 100%; border-collapse: collapse;
}
.doc-header td { padding: 2px 4px; vertical-align: middle; }
.doc-footer td { padding: 2px 4px; vertical-align: middle; }
/* 제목 블록 */
.title-block { margin-bottom: 4mm; }
.title-table { width: 100%; border-collapse: collapse; }
.title-block h1 {
font-family: 'HY헤드라인M', sans-serif;
font-size: 15.0pt;
font-weight: normal;
color: #000000;
text-align: justify;
line-height: 180%;
margin: 0; padding: 4mm 0;
}
/* 섹션 */
.section-title {
font-size: 13.0pt;
font-weight: normal;
color: #000000;
margin-bottom: 3mm;
}
.section { margin-bottom: 6mm; }
.section-content { text-align: justify; }
/* 이미지/문단 (content_order) */
.img-wrap { text-align: center; margin: 3mm 0; }
.img-wrap img { max-width: 100%; height: auto; }
.img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }
/* 데이터 표 */
.data-table {
width: 100%; border-collapse: collapse; margin: 4mm 0;
}
.data-table th, .data-table td {
border: none;
font-size: 10.0pt;
line-height: 180%;
text-align: justify;
vertical-align: middle;
}
.data-table th {
font-weight: bold; text-align: center;
}
/* borderFill → CSS 클래스 */
.bf-1 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-2 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-3 {
border-left: 0.12mm solid #000000;
border-right: 0.12mm solid #000000;
border-top: 0.12mm solid #000000;
border-bottom: 0.12mm solid #000000;
}
.bf-4 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: 0.7mm solid #3057B9;
}
.bf-5 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-6 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.3mm solid #BBBBBB;
}
.bf-7 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
}
.bf-8 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-9 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.3mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-10 {
border-left: none;
border-right: 0.12mm solid #999999;
border-top: 0.3mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
background-color: #DCDCDC;
}
.bf-11 {
border-left: none;
border-right: 0.12mm solid #999999;
border-top: 0.5mm solid #BBBBBB;
border-bottom: 0.3mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-12 {
border-left: none;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.5mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-13 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.3mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
background-color: #DCDCDC;
}
.bf-14 {
border-left: 0.12mm solid #999999;
border-right: none;
border-top: 0.3mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
background-color: #DCDCDC;
}
.bf-15 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.5mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-16 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.5mm solid #BBBBBB;
background-color: #EDEDED;
}
.bf-17 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.5mm solid #BBBBBB;
border-bottom: 0.12mm solid #BBBBBB;
}
.bf-18 {
border-left: 0.12mm solid #999999;
border-right: 0.12mm solid #999999;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.5mm solid #BBBBBB;
}
.bf-19 {
border-left: 0.12mm solid #999999;
border-right: none;
border-top: 0.12mm solid #BBBBBB;
border-bottom: 0.5mm solid #BBBBBB;
}
.bf-20 {
border-left: 0.12mm solid #999999;
border-right: none;
border-top: 0.5mm solid #BBBBBB;
border-bottom: 0.3mm solid #BBBBBB;
}
/* charPr → CSS 클래스 (글자 모양) */
.cpr-0 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
}
.cpr-1 {
font-family: '맑은 고딕', sans-serif;
font-size: 9.0pt;
letter-spacing: -0.45pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-2 {
font-family: '맑은 고딕', sans-serif;
font-size: 8.0pt;
}
.cpr-3 {
font-family: '맑은 고딕', sans-serif;
font-size: 9.0pt;
}
.cpr-4 {
font-family: 'HY헤드라인M', sans-serif;
font-size: 15.0pt;
}
.cpr-5 {
font-family: '한양중고딕', sans-serif;
font-size: 8.0pt;
font-weight: bold;
}
.cpr-6 {
font-family: 'HY헤드라인M', sans-serif;
font-size: 10.0pt;
}
.cpr-7 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
font-weight: bold;
}
.cpr-8 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
}
.cpr-9 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
font-weight: bold;
color: #0000FF;
}
.cpr-10 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
font-weight: bold;
color: #FF0000;
}
.cpr-11 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
font-weight: bold;
color: #008000;
}
.cpr-12 {
font-family: '휴먼고딕', sans-serif;
font-size: 8.0pt;
letter-spacing: -0.4pt;
transform: scaleX(0.9);
display: inline-block;
}
.cpr-13 {
font-family: '나눔명조', sans-serif;
font-size: 9.0pt;
}
.cpr-14 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
}
.cpr-15 {
font-family: 'HY헤드라인M', sans-serif;
font-size: 15.0pt;
}
.cpr-16 {
font-family: '휴먼명조', sans-serif;
font-size: 9.0pt;
letter-spacing: -0.18pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-17 {
font-family: '휴먼명조', sans-serif;
font-size: 10.0pt;
letter-spacing: -0.2pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-18 {
font-family: '-윤고딕130', sans-serif;
font-size: 13.0pt;
letter-spacing: -0.65pt;
transform: scaleX(0.98);
display: inline-block;
}
.cpr-19 {
font-family: '나눔명조', sans-serif;
font-size: 13.0pt;
font-weight: bold;
}
.cpr-20 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
font-weight: bold;
}
.cpr-21 {
font-family: '나눔명조', sans-serif;
font-size: 11.0pt;
}
.cpr-22 {
font-family: '휴먼명조', sans-serif;
font-size: 11.0pt;
letter-spacing: -0.22pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-23 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
letter-spacing: -1.0pt;
}
.cpr-24 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
letter-spacing: -1.7pt;
}
.cpr-25 {
font-family: '한양견명조', sans-serif;
font-size: 16.0pt;
}
.cpr-26 {
font-family: '돋움', sans-serif;
font-size: 11.0pt;
}
/* paraPr → CSS 클래스 (문단 모양) */
.ppr-0 {
text-align: justify;
line-height: 130%;
text-indent: -4.6mm;
}
.ppr-1 {
text-align: justify;
line-height: 160%;
}
.ppr-2 {
text-align: justify;
line-height: 150%;
}
.ppr-3 {
text-align: justify;
line-height: 180%;
}
.ppr-4 {
text-align: center;
line-height: 180%;
}
.ppr-5 {
text-align: justify;
line-height: 110%;
}
.ppr-6 {
text-align: right;
line-height: 110%;
}
.ppr-7 {
text-align: justify;
line-height: 100%;
}
.ppr-8 {
text-align: justify;
line-height: 180%;
margin-left: 1.8mm;
margin-right: 1.8mm;
}
.ppr-9 {
text-align: justify;
line-height: 180%;
}
.ppr-10 {
text-align: justify;
line-height: 180%;
margin-left: 1.8mm;
}
.ppr-11 {
text-align: left;
line-height: 180%;
}
.ppr-12 {
text-align: justify;
line-height: 170%;
text-indent: -4.3mm;
margin-left: 1.8mm;
margin-bottom: 1.8mm;
}
.ppr-13 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-top: 0.7mm;
margin-bottom: 1.1mm;
}
.ppr-14 {
text-align: justify;
line-height: 155%;
margin-top: 2.1mm;
}
.ppr-15 {
text-align: justify;
line-height: 160%;
margin-top: 4.2mm;
margin-bottom: 1.8mm;
}
.ppr-16 {
text-align: left;
line-height: 180%;
}
.ppr-17 {
text-align: justify;
line-height: 140%;
text-indent: -4.9mm;
margin-left: 2.8mm;
margin-bottom: 1.1mm;
}
.ppr-18 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-top: 1.8mm;
margin-bottom: 1.1mm;
}
.ppr-19 {
text-align: justify;
line-height: 160%;
margin-top: 3.5mm;
margin-bottom: 1.8mm;
}
.ppr-20 {
text-align: center;
line-height: 160%;
}
.ppr-21 {
text-align: justify;
line-height: 180%;
margin-bottom: 3.0mm;
}
.ppr-22 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-top: 1.8mm;
margin-bottom: 1.1mm;
}
/* named styles */
/* .sty-0 '바탕글' = cpr-0 + ppr-3 */
/* .sty-1 '머리말' = cpr-3 + ppr-2 */
/* .sty-2 '쪽 번호' = cpr-2 + ppr-1 */
/* .sty-3 '각주' = cpr-1 + ppr-0 */
/* .sty-4 '미주' = cpr-1 + ppr-0 */
/* .sty-5 '표위' = cpr-6 + ppr-4 */
/* .sty-6 '표옆' = cpr-0 + ppr-8 */
/* .sty-7 '표내용' = cpr-0 + ppr-10 */
/* .sty-8 '주)' = cpr-13 + ppr-9 */
/* .sty-9 '#큰아이콘' = cpr-18 + ppr-14 */
/* .sty-10 '개요1' = cpr-25 + ppr-21 */
/* .sty-11 'xl63' = cpr-26 + ppr-20 */
/* 표 상세 (tools 추출값) */
.tbl-1 col:nth-child(1) { width: 11%; }
.tbl-1 col:nth-child(2) { width: 20%; }
.tbl-1 col:nth-child(3) { width: 58%; }
.tbl-1 col:nth-child(4) { width: 11%; }
.tbl-1 td, .tbl-1 th {
padding: 1.5mm 0.5mm 1.5mm 0.5mm;
}
.tbl-1 thead th { height: 6.5mm; }
</style>
</head>
<body>
<div class="page">
<div class="doc-header">
<table>
<colgroup>
<col style="width:25%">
<col style="width:17%">
<col style="width:58%">
</colgroup>
<tr>
<td class="bf-5">{{HEADER_R1_C1_LINE_1}}<br>{{HEADER_R1_C1_LINE_2}}</td>
<td class="bf-5"></td>
<td class="bf-5">{{HEADER_R1_C3}}</td>
</tr>
</table>
</div>
<div class="title-block">
<table class="title-table">
<colgroup>
<col style="width:2%">
<col style="width:98%">
</colgroup>
<tr>
<td class="bf-4"></td>
<td class="bf-4">{{TITLE_R1_C2}}</td>
</tr>
</table>
</div>
<div class="img-wrap ppr-15">
{{IMAGE_1}}
<p class="img-caption">{{IMAGE_1_CAPTION}}</p>
</div>
<div class="img-wrap ppr-12">
{{IMAGE_2}}
<p class="img-caption">{{IMAGE_2_CAPTION}}</p>
</div>
<p class="ppr-18"><span class="cpr-22">{{PARA_1}}</span></p>
<div class="img-wrap ppr-19">
{{IMAGE_3}}
<p class="img-caption">{{IMAGE_3_CAPTION}}</p>
</div>
<div class="img-wrap ppr-12">
{{IMAGE_4}}
<p class="img-caption">{{IMAGE_4_CAPTION}}</p>
</div>
<div class="img-wrap ppr-12">
{{IMAGE_5}}
<p class="img-caption">{{IMAGE_5_CAPTION}}</p>
</div>
<table class="data-table tbl-1">
<colgroup>
<col style="width:11%">
<col style="width:20%">
<col style="width:58%">
<col style="width:11%">
</colgroup>
<thead>
<tr>
<th class="bf-10" colspan="2">{{TABLE_1_H_C1}}</th>
<th class="bf-13">{{TABLE_1_H_C2}}</th>
<th class="bf-14">{{TABLE_1_H_C3}}</th>
</tr>
</thead>
<tbody>
{{TABLE_1_BODY}}
</tbody>
</table>
<div class="doc-footer">
<table>
<colgroup>
<col style="width:35%">
<col style="width:6%">
<col style="width:59%">
</colgroup>
<tr>
<td class="bf-5">{{FOOTER_R1_C1_LINE_1}}<br>{{FOOTER_R1_C1_LINE_2}}</td>
<td class="bf-5"></td>
<td class="bf-5"></td>
</tr>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{
"id": "tpl_1770301063",
"name": "55 양식",
"original_file": "발표자료복사본.hwpx",
"file_type": ".hwpx",
"description": "55에서 추출한 문서 양식",
"features": [
"폰트: 돋움",
"표: 3x3"
],
"created_at": "2026-02-05T23:17:43Z",
"source": "doc_template_analyzer"
}

View File

@@ -0,0 +1,94 @@
{
"version": "1.0",
"table_roles": {
"0": {
"role": "data_table",
"header_row": null,
"col_headers": [],
"row_count": 3,
"col_count": 3
}
},
"body_tables": [
0
],
"title_table": null,
"sections": [
{
"index": 1,
"title": "1. (스마트설계팀) SamanPro(V3.0)",
"pattern_type": "numbered"
}
],
"style_mappings": {
"char_pr": {},
"border_fill": {
"1": {
"css_class": "bf-1",
"bg": "",
"borders": {}
},
"2": {
"css_class": "bf-2",
"bg": "",
"borders": {}
},
"3": {
"css_class": "bf-3",
"bg": "",
"borders": {
"border-left": "0.12mm solid #000000",
"border-right": "0.12mm solid #000000",
"border-top": "0.12mm solid #000000",
"border-bottom": "0.12mm solid #000000"
}
},
"4": {
"css_class": "bf-4",
"bg": "",
"borders": {}
},
"5": {
"css_class": "bf-5",
"bg": "",
"borders": {}
},
"6": {
"css_class": "bf-6",
"bg": "",
"borders": {}
},
"7": {
"css_class": "bf-7",
"bg": "",
"borders": {}
},
"8": {
"css_class": "bf-8",
"bg": "#F3F3F3",
"borders": {}
},
"9": {
"css_class": "bf-9",
"bg": "",
"borders": {}
},
"10": {
"css_class": "bf-10",
"bg": "",
"borders": {}
},
"11": {
"css_class": "bf-11",
"bg": "",
"borders": {}
},
"12": {
"css_class": "bf-12",
"bg": "",
"borders": {}
}
},
"para_pr": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Template</title>
<style>
@page {
size: 210.0mm 297.0mm;
margin: 10.0mm 20.0mm 10.0mm 20.0mm;
}
@media screen {
@page { margin: 0; }
}
body {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
line-height: 180%;
color: #000000;
margin: 0; padding: 0;
}
.page {
width: 170mm;
margin: 0 auto;
padding: 10.0mm 20.0mm 10.0mm 20.0mm;
}
/* 헤더/푸터 */
.doc-header { margin-bottom: 4.5mm; }
.doc-footer { margin-top: 6.0mm; }
.doc-header table, .doc-footer table {
width: 100%; border-collapse: collapse;
}
.doc-header td { padding: 2px 4px; vertical-align: middle; }
.doc-footer td { padding: 2px 4px; vertical-align: middle; }
/* 제목 블록 */
.title-block { margin-bottom: 4mm; }
.title-table { width: 100%; border-collapse: collapse; }
.title-block h1 {
font-size: 15pt; font-weight: normal;
text-align: center; margin: 0; padding: 4mm 0;
}
/* 섹션 */
.section-title {
font-size: 13.0pt;
font-weight: normal;
color: #000000;
margin-bottom: 3mm;
}
.section { margin-bottom: 6mm; }
.section-content { text-align: justify; }
/* 이미지/문단 (content_order) */
.img-wrap { text-align: center; margin: 3mm 0; }
.img-wrap img { max-width: 100%; height: auto; }
.img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }
/* 데이터 표 */
.data-table {
width: 100%; border-collapse: collapse; margin: 4mm 0;
}
.data-table th, .data-table td {
border: none;
font-size: 10.0pt;
line-height: 180%;
text-align: justify;
vertical-align: middle;
}
.data-table th {
font-weight: bold; text-align: center;
}
/* borderFill → CSS 클래스 */
.bf-1 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-2 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-3 {
border-left: 0.12mm solid #000000;
border-right: 0.12mm solid #000000;
border-top: 0.12mm solid #000000;
border-bottom: 0.12mm solid #000000;
}
.bf-4 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-5 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-6 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-7 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-8 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
background-color: #F3F3F3;
}
.bf-9 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-10 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-11 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
.bf-12 {
border-left: none;
border-right: none;
border-top: none;
border-bottom: none;
}
/* charPr → CSS 클래스 (글자 모양) */
.cpr-0 {
font-family: '나눔명조', sans-serif;
font-size: 10.0pt;
}
.cpr-1 {
font-family: '맑은 고딕', sans-serif;
font-size: 9.0pt;
letter-spacing: -0.45pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-2 {
font-family: '맑은 고딕', sans-serif;
font-size: 8.0pt;
}
.cpr-3 {
font-family: '맑은 고딕', sans-serif;
font-size: 9.0pt;
}
.cpr-4 {
font-family: 'HY헤드라인M', sans-serif;
font-size: 10.0pt;
}
.cpr-5 {
font-family: '나눔명조', sans-serif;
font-size: 9.0pt;
}
.cpr-6 {
font-family: '-윤고딕130', sans-serif;
font-size: 13.0pt;
letter-spacing: -0.65pt;
transform: scaleX(0.98);
display: inline-block;
}
.cpr-7 {
font-family: '한양견명조', sans-serif;
font-size: 16.0pt;
}
.cpr-8 {
font-family: '돋움', sans-serif;
font-size: 11.0pt;
}
.cpr-9 {
font-family: '나눔명조', sans-serif;
font-size: 1.0pt;
font-weight: bold;
}
.cpr-10 {
font-family: '나눔명조', sans-serif;
font-size: 11.0pt;
letter-spacing: -0.55pt;
}
.cpr-11 {
font-family: '나눔명조', sans-serif;
font-size: 11.0pt;
}
.cpr-12 {
font-family: '맑은 고딕', sans-serif;
font-size: 13.0pt;
font-weight: bold;
}
.cpr-13 {
font-family: '맑은 고딕', sans-serif;
font-size: 11.0pt;
}
.cpr-14 {
font-family: '맑은 고딕', sans-serif;
font-size: 11.0pt;
font-weight: bold;
}
.cpr-15 {
font-family: '맑은 고딕', sans-serif;
font-size: 9.0pt;
letter-spacing: -0.18pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-16 {
font-family: '맑은 고딕', sans-serif;
font-size: 11.0pt;
letter-spacing: -0.22pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-17 {
font-family: '맑은 고딕', sans-serif;
font-size: 11.0pt;
letter-spacing: -0.22pt;
transform: scaleX(0.95);
display: inline-block;
}
.cpr-18 {
font-family: '맑은 고딕', sans-serif;
font-size: 11.0pt;
letter-spacing: -0.44pt;
}
/* paraPr → CSS 클래스 (문단 모양) */
.ppr-0 {
text-align: justify;
line-height: 130%;
text-indent: -4.6mm;
}
.ppr-1 {
text-align: justify;
line-height: 160%;
}
.ppr-2 {
text-align: justify;
line-height: 150%;
}
.ppr-3 {
text-align: justify;
line-height: 180%;
}
.ppr-4 {
text-align: center;
line-height: 180%;
}
.ppr-5 {
text-align: justify;
line-height: 180%;
margin-left: 1.8mm;
margin-right: 1.8mm;
}
.ppr-6 {
text-align: justify;
line-height: 180%;
}
.ppr-7 {
text-align: justify;
line-height: 180%;
margin-left: 1.8mm;
}
.ppr-8 {
text-align: justify;
line-height: 170%;
text-indent: -4.3mm;
margin-left: 1.8mm;
margin-bottom: 1.8mm;
}
.ppr-9 {
text-align: justify;
line-height: 155%;
margin-top: 2.1mm;
}
.ppr-10 {
text-align: justify;
line-height: 140%;
text-indent: -4.9mm;
margin-left: 2.8mm;
margin-bottom: 1.1mm;
}
.ppr-11 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-top: 1.8mm;
margin-bottom: 1.1mm;
}
.ppr-12 {
text-align: justify;
line-height: 160%;
margin-top: 3.5mm;
margin-bottom: 1.8mm;
}
.ppr-13 {
text-align: center;
line-height: 160%;
}
.ppr-14 {
text-align: justify;
line-height: 180%;
margin-bottom: 3.0mm;
}
.ppr-15 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-bottom: 1.8mm;
}
.ppr-16 {
text-align: center;
line-height: 170%;
text-indent: -4.3mm;
margin-left: 1.8mm;
margin-bottom: 1.8mm;
}
.ppr-17 {
text-align: justify;
line-height: 170%;
text-indent: -4.3mm;
margin-left: 1.8mm;
margin-top: 1.8mm;
margin-bottom: 1.8mm;
}
.ppr-18 {
text-align: justify;
line-height: 140%;
margin-left: 2.8mm;
margin-bottom: 1.8mm;
}
.ppr-19 {
text-align: justify;
line-height: 160%;
text-indent: -6.1mm;
margin-left: 2.8mm;
margin-bottom: 1.1mm;
}
.ppr-20 {
text-align: justify;
line-height: 160%;
text-indent: -7.9mm;
margin-left: 2.8mm;
margin-bottom: 1.1mm;
}
.ppr-21 {
text-align: justify;
line-height: 160%;
margin-top: 1.8mm;
margin-bottom: 1.1mm;
}
.ppr-22 {
text-align: justify;
line-height: 160%;
margin-bottom: 1.8mm;
}
/* named styles */
/* .sty-0 '바탕글' = cpr-0 + ppr-3 */
/* .sty-1 '머리말' = cpr-3 + ppr-2 */
/* .sty-2 '쪽 번호' = cpr-2 + ppr-1 */
/* .sty-3 '각주' = cpr-1 + ppr-0 */
/* .sty-4 '미주' = cpr-1 + ppr-0 */
/* .sty-5 '표위' = cpr-4 + ppr-4 */
/* .sty-6 '표옆' = cpr-0 + ppr-5 */
/* .sty-7 '표내용' = cpr-0 + ppr-7 */
/* .sty-8 '주)' = cpr-5 + ppr-6 */
/* .sty-9 '#큰아이콘' = cpr-6 + ppr-9 */
/* .sty-10 '개요1' = cpr-7 + ppr-14 */
/* .sty-11 'xl63' = cpr-8 + ppr-13 */
/* 표 상세 (tools 추출값) */
.tbl-1 col:nth-child(1) { width: 1%; }
.tbl-1 col:nth-child(2) { width: 99%; }
.tbl-1 col:nth-child(3) { width: 1%; }
.tbl-1 td, .tbl-1 th {
padding: 0.6mm 0.0mm 0.0mm 0.0mm;
}
.tbl-1 thead th { height: 1.0mm; }
</style>
</head>
<body>
<div class="page">
<!-- no header -->
<div class="section" data-section="1">
<p class="section-title ppr-22 cpr-12">{{SECTION_1_TITLE}}</p>
<div class="img-wrap ppr-12">
{{IMAGE_1}}
<p class="img-caption">{{IMAGE_1_CAPTION}}</p>
</div>
<div class="img-wrap ppr-8">
{{IMAGE_2}}
<p class="img-caption">{{IMAGE_2_CAPTION}}</p>
</div>
<p class="ppr-15"><span class="cpr-13">{{PARA_1}}</span></p>
<div class="img-wrap ppr-8">
{{IMAGE_3}}
<p class="img-caption">{{IMAGE_3_CAPTION}}</p>
</div>
<p class="ppr-15"><span class="cpr-13">{{PARA_2}}</span></p>
<p class="ppr-19"><span class="cpr-13">{{PARA_3}}</span></p>
<p class="ppr-20"><span class="cpr-13">{{PARA_4}}</span></p>
<p class="ppr-20"><span class="cpr-13">{{PARA_5}}</span></p>
<p class="ppr-10"><span class="cpr-15">{{PARA_6}}</span></p>
<p class="ppr-11"><span class="cpr-16">{{PARA_7_RUN_1}}</span><span class="cpr-17">{{PARA_7_RUN_2}}</span></p>
<p class="ppr-10"><span class="cpr-15">{{PARA_8}}</span></p>
<div class="img-wrap ppr-12">
{{IMAGE_4}}
<p class="img-caption">{{IMAGE_4_CAPTION}}</p>
</div>
<table class="data-table tbl-1">
<colgroup>
<col style="width:1%">
<col style="width:99%">
<col style="width:1%">
</colgroup>
<thead>
<tr>
<th class="bf-4">{{TABLE_1_H_C1}}</th>
<th class="bf-5">{{TABLE_1_H_C2}}</th>
<th class="bf-6">{{TABLE_1_H_C3}}</th>
</tr>
</thead>
<tbody>
{{TABLE_1_BODY}}
</tbody>
</table>
<p class="ppr-10"><span class="cpr-15">{{PARA_9}}</span></p>
<div class="img-wrap ppr-17">
{{IMAGE_5}}
<p class="img-caption">{{IMAGE_5_CAPTION}}</p>
</div>
<p class="ppr-15"><span class="cpr-13">{{PARA_10}}</span></p>
<p class="ppr-15"><span class="cpr-13">{{PARA_11}}</span></p>
<div class="img-wrap ppr-17">
{{IMAGE_6}}
<p class="img-caption">{{IMAGE_6_CAPTION}}</p>
</div>
<p class="ppr-15"><span class="cpr-13">{{PARA_12}}</span></p>
<p class="ppr-18"><span class="cpr-13">{{PARA_13}}</span></p>
<div class="img-wrap ppr-17">
{{IMAGE_7}}
<p class="img-caption">{{IMAGE_7_CAPTION}}</p>
</div>
<p class="ppr-15"><span class="cpr-13">{{PARA_14}}</span></p>
<p class="ppr-15"><span class="cpr-13">{{PARA_15}}</span></p>
<p class="ppr-10"><span class="cpr-15">{{PARA_16}}</span></p>
<p class="ppr-15"><span class="cpr-13">{{PARA_17}}</span></p>
<p class="ppr-15"><span class="cpr-18">{{PARA_18}}</span></p>
</div>
<!-- no footer -->
</div>
</body>
</html>