v8:문서유형 분석등록 및 추출_20260206

This commit is contained in:
2026-02-20 11:46:52 +09:00
parent db6532b33c
commit c3e9e29205
57 changed files with 22138 additions and 1421 deletions

BIN
7th.zip

Binary file not shown.

295
README.md
View File

@@ -1,14 +1,15 @@
# 글벗 (Geulbeot) v7.0
# 글벗 (Geulbeot) v8.0
**UI 고도화 — 템플릿 관리·작성 방식·문서 유형 선택 UI**
**문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화**
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
v7에서는 프론트엔드 UI를 고도화했습니다.
v6에서 백엔드로만 존재하던 템플릿 관리를 화면에서 직접 조작할 수 있게 되었고,
자료 활용 방식(형식 변경·재구성·신규 작성)과 문서 유형을 시각적으로 선택하는 UI가 추가되었습니다.
v8에서는 **문서 유형 분석·등록 시스템**을 구축했습니다.
HWPX 파일을 업로드하면 12종의 추출 도구가 XML을 코드 기반으로 파싱하고,
시맨틱 매퍼가 요소 의미를 판별한 뒤, 스타일 생성기가 CSS를 산출하여
사용자 정의 문서 유형으로 등록합니다. 등록된 유형은 기획서·보고서와 동일하게 문서 생성에 사용됩니다.
---
@@ -20,20 +21,20 @@ v6에서 백엔드로만 존재하던 템플릿 관리를 화면에서 직접
자료 입력 (파일/폴더)
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성 (v7 신규)
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성
RAG 파이프라인 (9단계) ─── 공통 처리
문서 유형 선택 ─── UI 리스트 (v7 신규)
문서 유형 선택
├─ 기획서 (기본)
├─ 보고서 (기본)
├─ 발표자료 (기본)
└─ 사용자 등록 (확장 가능)
└─ 사용자 등록 (v8 — HWPX 분석 → 자동 등록)
글벗 표준 HTML 생성 ◀── 템플릿 스타일 참조 + 요소 선택 (v7 신규)
글벗 표준 HTML 생성 ◀── 템플릿 스타일 + 시맨틱 맵 참조
웹 편집기 (수기 편집 / AI 편집)
@@ -47,51 +48,76 @@ RAG 파이프라인 (9단계) ─── 공통 처리
- **Language**: Python 3.13
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
- **AI**:
- Claude API (Anthropic) — 기획서 생성, AI 편집
- Claude API (Anthropic) — 기획서 생성, AI 편집, 문서 유형 맥락 분석
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
- Gemini API — 보고서 콘텐츠·HTML 생성
- **Features**:
- 자료 입력 → 9단계 RAG 파이프라인
- 문서 유형별 생성: 기획서 (Claude 3단계), 보고서 (Gemini 2단계)
- 문서 유형별 생성: 기획서 (Claude), 보고서 (Gemini), 사용자 정의 유형 (v8 신규)
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
- HWPX 템플릿 분석·저장·관리
- HWP 변환: 하이브리드 방식 — pyhwpx → HWPX 스타일 주입 → 표 열 너비 수정
- 문서 유형 분석·등록 (v8 신규): HWPX 업로드 → 12종 도구 추출 → 시맨틱 매핑 → 스타일 생성 → 유형 CRUD
- HWPX 템플릿 관리: 추출·저장·교체·삭제
- HWP 변환: 하이브리드 방식
- PDF 변환: WeasyPrint 기반
### 2. Frontend (순수 JavaScript)
- **Features**:
- 웹 WYSIWYG 편집기 — 브라우저에서 생성된 문서 직접 수정
- 페이지 넘김·들여쓰기·정렬 등 서식 도구
- 웹 WYSIWYG 편집기 — 생성된 문서 직접 수정
- 작성 방식 선택 탭: 형식만 변경 / 내용 재구성 / 신규 작성
- 문서 유형 선택 UI: 기본 3종 + 사용자 등록 유형 동적 표시
- 템플릿 관리 UI: 사이드바 목록·선택·삭제, 요소별 체크박스
- HTML / PDF / HWP 다운로드
- **작성 방식 선택 탭 (v7 신규)**: 📄 형식만 변경 / 🔄 내용의 재구성 / ✨ 문서 참고 신규 작성
- **문서 유형 선택 UI (v7 신규)**: 기획서·보고서 라디오 리스트 + 배지 스타일
- **템플릿 관리 UI (v7 신규)**: 사이드바에서 템플릿 업로드·선택·삭제, 적용할 요소 체크박스
### 3. 변환 엔진 (Converters)
- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립
- **분량 자동 판단**: 5,000자 기준 — 긴 문서는 전체 파이프라인, 짧은 문서는 축약 파이프라인
- **HWP 변환 (하이브리드 방식)**: HTML 분석 → pyhwpx 변환 → HWPX 스타일 주입 → 표 열 너비 수정
- **분량 자동 판단**: 5,000자 기준
- **HWP 변환 (하이브리드)**: HTML 분석 → pyhwpx 변환 → HWPX 스타일 주입 → 표 열 너비 수정
### 4. 템플릿 관리
### 4. HWPX 추출 도구 12종 (v8 신규)
- **HWPX 파싱**: 업로드된 HWPX를 압축 해제하여 header.xml + section*.xml 구조 분석
- **자동 추출**: 폰트·문단·표·배경·테두리·페이지 설정
- **CSS 자동 생성**: 분석된 스타일 → CSS 변환
- **저장소**: `templates_store/` — meta.json + 원본 + 분석 결과
- **UI 연동 (v7 신규)**: 사이드바에서 목록 조회·선택·삭제, 요소별 적용 체크박스
HWPX XML에서 특정 항목을 **코드 기반**으로 추출하는 모듈 패키지 (`handlers/tools/`):
### 5. 주요 시나리오 (Core Scenarios)
| 도구 | 대상 | 추출 내용 |
|------|------|----------|
| 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 문단·표·이미지 순서 |
1. **기획서 생성**: 텍스트 또는 파일을 입력하면, RAG 분석 후 Claude API가 구조 추출 → 페이지 배치 계획 → 글벗 표준 HTML 기획서를 생성. 1~N페이지 옵션 지원
2. **보고서 생성**: 폴더 경로의 자료들을 RAG 파이프라인으로 분석하고, Gemini API가 섹션별 콘텐츠 초안 → 표지·목차·간지·별첨이 포함된 다페이지 HTML 보고서를 생성
3. **작성 방식 선택 (v7 신규)**: 업로드 자료를 어떻게 활용할지 3가지 모드 중 선택
- 📄 **형식만 변경** — 원본 내용 유지, 글벗 양식으로만 변환
- 🔄 **내용의 재구성** — 원본 기반으로 구조와 내용을 재구성 (기본값)
-**문서 참고 신규 작성** — 원본을 참고 자료로만 활용, 새로 작성
4. **템플릿 적용**: 등록된 HWPX 템플릿을 선택하고, 적용할 요소(제목 스타일·표 스타일·색상 등)를 체크박스로 선택
5. **HWP 내보내기**: pyhwpx 변환 후 HWPX 스타일 주입 + 표 열 너비 정밀 수정
- 추출 실패 시 `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 내보내기**: 하이브리드 변환
### 프로세스 플로우
@@ -123,30 +149,125 @@ flowchart TD
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
```
---
## 🔄 v6 → v7 변경사항
## 🔄 v7 → v8 변경사항
| 영역 | v6 | v7 |
| 영역 | v7 | v8 |
|------|------|------|
| 작성 방식 | 없음 (무조건 재구성) | **3가지 모드 UI**: 형식 변경 / 재구성 / 신규 작성 |
| 문서 유형 선택 | 기획서·보고서 구분 없이 탭 | **문서 유형 라디오 리스트** + 배지 스타일 |
| 템플릿 관리 UI | API만 존재 (화면 없음) | **사이드바 UI**: 목록·선택·삭제 + 요소별 체크박스 |
| 템플릿 업로드 | API 직접 호출 | **모달 UI**: 파일 선택 + 이름 입력 + 업로드 |
| index.html | 2,974줄 | 3,400줄 (+426) |
| Python | 변경 없음 | 변경 없음 |
| 문서 유형 등록 | 없음 | **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 생성 + 글벗 표준 HTML 양식 (🔧 기본 구현)
- **Phase 2**: 문서 생성 — 기획서·보고서·사용자 정의 유형 AI 생성 (🔧 기본 구현)
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
- **Phase 5**: 문서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정)
- **Phase 6**: HWPX 템플릿 관리 — 파싱·스타일 추출·CSS 생성·저장·조회·삭제 (🔧 기본 구현)
- **Phase 7**: UI 고도화 — 작성 방식 선택, 문서 유형 UI, 템플릿 관리 UI (🔧 기본 구현 · 현재 버전)
- **Phase 5**: 문서 유형 분석·등록 — HWPX → 12종 도구 추출 → 시맨틱 매핑 → 유형 CRUD (🔧 기본 구현 · 현재 버전)
- **Phase 6**: HWPX 템플릿 관리 — template_manager v5.2, content_order 기반 조립, 독립 저장 (🔧 기본 구현 · 현재 버전)
- **Phase 7**: UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI (🔧 기본 구현)
- **Phase 8**: 백엔드 재구조화 + 배포 — 패키지 정리, API 키 공통화, 로깅, Docker (예정)
---
@@ -156,7 +277,7 @@ flowchart TD
### 사전 요구사항
- Python 3.10+
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집, 문서 유형 분석
- OpenAI API 키 — RAG 파이프라인
- Gemini API 키 — 보고서 콘텐츠·HTML 생성
- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수)
@@ -164,9 +285,9 @@ flowchart TD
### 환경 설정
```bash
# 저장소 클론 및 설정
git clone http://[Gitea주소]/kei/geulbeot-v7.git
cd geulbeot-v7
# 저장소 클론
git clone http://[Gitea주소]/kei/geulbeot-v8.git
cd geulbeot-v8
# 가상환경
python -m venv venv
@@ -183,7 +304,7 @@ cp .env.sample .env
### .env 작성
```env
CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집
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 # 보고서 콘텐츠 생성
```
@@ -200,15 +321,42 @@ python app.py
## 📂 프로젝트 구조
```
geulbeot_7th/
├── app.py # Flask 웹 서버 — API 라우팅
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/ # 기획서 처리 (구조추출 → 배치 → HTML)
│ ├── report/ # 보고서 처리 (RAG 파이프라인 연동)
── template/ # 템플릿 관리 (HWPX 파싱·분석·CRUD)
│ ├── 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 파이프라인
@@ -220,16 +368,23 @@ geulbeot_7th/
│ ├── html_to_hwp.py # 보고서 → HWP 변환
│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환
├── templates_store/ # 등록된 템플릿 저장소
├── 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 # 편집기 스타일
├── templates/
│ ├── index.html # ★ v7 고도화 — 작성 방식·문서 유형·템플릿 UI
│ └── hwp_guide.html # HWP 변환 가이드
├── .env / .env.sample # API 키 관리
├── .env / .env.sample
├── .gitignore
├── requirements.txt
├── Procfile
@@ -255,10 +410,9 @@ geulbeot_7th/
- 로컬 경로 하드코딩: `D:\for python\...` 잔존 (router.py, app.py)
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
- 문서 유형: 기획서·보고서만 구현, 발표자료·사용자 등록 유형 미구현
- 작성 방식: UI만 구현, 백엔드 로직 미연동 (모드별 프롬프트 분기 예정)
- 템플릿 → 문서 생성 연동: 아직 미연결 (선택·체크는 가능, 생성 시 자동 적용은 예정)
- 레거시 잔존: prompts/ 디렉토리
- 발표자료: config.json만 존재, 실제 생성 미구현
- 사용자 유형 생성: template.html 기반 채움만 (AI 창작 아닌 정리·재구성)
- 레거시 잔존: prompts/ 디렉토리, templates/hwp_guide.html → .md 전환 중
---
@@ -266,9 +420,9 @@ geulbeot_7th/
| 영역 | 줄 수 |
|------|-------|
| Python 전체 | 11,500 |
| 프론트엔드 (JS + CSS + HTML) | 4,904 (+1,045) |
| **합계** | **~16,400** |
| Python 전체 | 18,917 (+7,417) |
| 프론트엔드 (JS + CSS + HTML) | 5,269 (+365) |
| **합계** | **~24,200** |
---
@@ -282,7 +436,8 @@ geulbeot_7th/
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
| v6 | HWPX 템플릿 분석·저장·관리 |
| **v7** | **UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI** |
| v7 | UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI |
| **v8** | **문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화** |
---

362
app.py
View File

@@ -7,41 +7,135 @@ 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
from handlers.template import TemplateProcessor
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() # 추가
'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"""
"""서 생성 API"""
try:
content = ""
if 'file' in request.files and request.files['file'].filename:
@@ -50,13 +144,22 @@ def generate():
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', '총괄기획실'),
'department': request.form.get('department', ''),
'instruction': request.form.get('instruction', '')
}
result = processors['briefing'].generate(content, options)
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
@@ -257,6 +360,40 @@ def export_hwp():
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 스타일 분석 미리보기"""
@@ -300,15 +437,24 @@ def analyze_styles():
def get_templates():
"""저장된 템플릿 목록 조회"""
try:
result = processors['template'].get_list()
return jsonify(result)
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
@@ -322,29 +468,211 @@ def analyze_template():
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
result = processors['template'].analyze(file, name)
# 임시 저장 → HWPX 파싱 → 템플릿 추출
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
if 'error' in result:
return jsonify(result), 400
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 = processors['template'].delete(template_id)
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

View File

@@ -776,8 +776,8 @@ class SurveyingFileConverter:
if __name__ == "__main__":
# 경로 설정
SOURCE_DIR = r"D:\for python\테스트 중(측량)\측량_GIS_드론 관련 자료들"
OUTPUT_DIR = r"D:\for python\테스트 중(측량)\추출"
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)

View File

@@ -27,8 +27,8 @@ except ImportError:
print("[INFO] pytesseract 미설치 - 텍스트 잘림 필터 비활성화")
# ===== 경로 설정 =====
BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원본 위치
OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치
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+)?',

View File

@@ -29,8 +29,8 @@ 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\survey_test\extract")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"

View File

@@ -26,8 +26,8 @@ from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"

View File

@@ -20,8 +20,8 @@ from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"

View File

@@ -23,8 +23,8 @@ from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"

View File

@@ -22,8 +22,8 @@ from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"

View File

@@ -55,8 +55,8 @@ GEMINI_MODEL = "gemini-3-pro-preview"
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
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"
RAG_DIR = OUTPUT_ROOT / "rag"

View File

@@ -25,7 +25,7 @@ from typing import List, Dict, Any, Tuple, Optional
from dataclasses import dataclass, field
# ===== 경로 설정 =====
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
GEN_DIR = OUTPUT_ROOT / "generated"
ASSETS_DIR = GEN_DIR / "assets"
LOG_DIR = OUTPUT_ROOT / "logs"

0
domain/__init__.py Normal file
View File

0
domain/hwpx/__init__.py Normal file
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)"을 참고하여 작성되었습니다.*

323
domain/hwpx/hwpx_utils.py Normal file
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

@@ -3,3 +3,5 @@
handlers 패키지
문서 유형별 처리 로직을 분리하여 관리
"""
from .doc_template_analyzer import DocTemplateAnalyzer

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

555
handlers/custom_doc_type.py Normal file
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

382
handlers/semantic_mapper.py Normal file
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

824
handlers/style_generator.py Normal file
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

File diff suppressed because it is too large Load Diff

1008
handlers/template_manager.py Normal file

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

82
handlers/tools/font.py Normal file
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

98
handlers/tools/image.py Normal file
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

136
handlers/tools/numbering.py Normal file
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

120
handlers/tools/section.py Normal file
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

328
handlers/tools/table.py Normal file
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

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

@@ -1,343 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HWP 변환 가이드 - 글벗 Light</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Noto Sans KR', sans-serif; }
.gradient-bg { background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%); }
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
code { font-family: 'Consolas', 'Monaco', monospace; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="gradient-bg text-white py-6 shadow-lg">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between">
<div>
<a href="/" class="text-blue-200 hover:text-white text-sm">← 메인으로</a>
<h1 class="text-2xl font-bold mt-2">HWP 변환 가이드</h1>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 안내 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
<h2 class="font-bold text-yellow-800 mb-2">⚠️ HWP 변환 요구사항</h2>
<ul class="text-yellow-700 text-sm space-y-1">
<li>• Windows 운영체제</li>
<li>• 한글 프로그램 (한컴오피스) 설치</li>
<li>• Python 3.8 이상</li>
</ul>
</div>
<!-- 설치 방법 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">1. 필요 라이브러리 설치</h2>
<pre><code>pip install pyhwpx beautifulsoup4</code></pre>
</div>
<!-- 사용 방법 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">2. 사용 방법</h2>
<ol class="list-decimal list-inside space-y-2 text-gray-700">
<li>글벗 Light에서 HTML 파일을 다운로드합니다.</li>
<li>아래 Python 스크립트를 다운로드합니다.</li>
<li>스크립트 내 경로를 수정합니다.</li>
<li>스크립트를 실행합니다.</li>
</ol>
</div>
<!-- 스크립트 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-800">3. HWP 변환 스크립트</h2>
<button onclick="copyScript()" class="px-4 py-2 bg-blue-900 text-white rounded hover:bg-blue-800 text-sm">
📋 복사
</button>
</div>
<pre id="scriptCode"><code># -*- coding: utf-8 -*-
"""
글벗 Light - HTML → HWP 변환기
Windows + 한글 프로그램 필요
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup
import os
class HtmlToHwpConverter:
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.colors = {}
def _init_colors(self):
self.colors = {
'primary-navy': self.hwp.RGBColor(26, 54, 93),
'secondary-navy': self.hwp.RGBColor(44, 82, 130),
'dark-gray': self.hwp.RGBColor(45, 55, 72),
'medium-gray': self.hwp.RGBColor(74, 85, 104),
'bg-light': self.hwp.RGBColor(247, 250, 252),
'white': self.hwp.RGBColor(255, 255, 255),
'black': self.hwp.RGBColor(0, 0, 0),
}
def _mm(self, mm):
return self.hwp.MiliToHwpUnit(mm)
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 _align(self, align):
actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
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 _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)
def _create_header(self, left_text, right_text):
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._font(9, 'medium-gray')
self.hwp.insert_text(left_text)
self.hwp.insert_text("\t" * 12)
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f"머리말 생성 실패: {e}")
def _create_footer(self, text):
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", 1)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self._align('center')
self._font(8.5, 'medium-gray')
self.hwp.insert_text(text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f"꼬리말 생성 실패: {e}")
def _convert_lead_box(self, elem):
content = elem.find("div")
if not content:
return
text = ' '.join(content.get_text().split())
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_bottom_box(self, elem):
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)
self.hwp.create_table(1, 2, treat_as_char=True)
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):
title = section.find(class_="section-title")
if title:
self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
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')
self._para()
def _convert_sheet(self, sheet, is_first_page=False):
if is_first_page:
header = sheet.find(class_="page-header")
if header:
left = header.find(class_="header-left")
right = header.find(class_="header-right")
left_text = left.get_text(strip=True) if left else ""
right_text = right.get_text(strip=True) if right else ""
if left_text or right_text:
self._create_header(left_text, right_text)
footer = sheet.find(class_="page-footer")
if footer:
self._create_footer(footer.get_text(strip=True))
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')
else:
self._para(title_text, 23, 'primary-navy', True, 'center')
self._font(10, 'secondary-navy')
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):
print(f"[입력] {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
self.hwp.FileNew()
self._init_colors()
# 페이지 설정
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
sec = self.hwp.HParameterSet.HSecDef
sec.PageDef.LeftMargin = self._mm(20)
sec.PageDef.RightMargin = self._mm(20)
sec.PageDef.TopMargin = self._mm(20)
sec.PageDef.BottomMargin = self._mm(20)
sec.PageDef.HeaderLen = self._mm(10)
sec.PageDef.FooterLen = self._mm(10)
self.hwp.HAction.Execute("PageSetup", sec.HSet)
except Exception as e:
print(f"페이지 설정 실패: {e}")
sheets = soup.find_all(class_="sheet")
total = len(sheets)
print(f"[변환] 총 {total} 페이지")
for i, sheet in enumerate(sheets, 1):
print(f"[{i}/{total}] 페이지 처리 중...")
self._convert_sheet(sheet, is_first_page=(i == 1))
if i < total:
self.hwp.HAction.Run("BreakPage")
self.hwp.SaveAs(output_path)
print(f" 저장 완료: {output_path}")
def close(self):
try:
self.hwp.Quit()
except:
pass
def main():
# ====================================
# 경로 설정 (본인 환경에 맞게 수정)
# ====================================
html_path = r"C:\Users\User\Downloads\report.html"
output_path = r"C:\Users\User\Downloads\report.hwp"
print("=" * 50)
print("글벗 Light - HTML HWP 변환기")
print("=" * 50)
try:
converter = HtmlToHwpConverter(visible=True)
converter.convert(html_path, output_path)
print("\n 변환 완료!")
input("Enter를 누르면 HWP가 닫힙니다...")
converter.close()
except FileNotFoundError:
print(f"\n[에러] 파일을 찾을 없습니다: {html_path}")
except Exception as e:
print(f"\n[에러] {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()</code></pre>
</div>
<!-- 경로 수정 안내 -->
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">4. 경로 수정</h2>
<p class="text-gray-700 mb-4">스크립트 하단의 <code class="bg-gray-100 px-2 py-1 rounded">main()</code> 함수에서 경로를 수정하세요:</p>
<pre><code>html_path = r"C:\다운로드경로\report.html"
output_path = r"C:\저장경로\report.hwp"</code></pre>
</div>
</main>
<script>
function copyScript() {
const code = document.getElementById('scriptCode').innerText;
navigator.clipboard.writeText(code).then(() => {
alert('스크립트가 클립보드에 복사되었습니다!');
});
}
</script>
</body>
</html>

302
templates/hwp_guide.md Normal file
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>