📦 Initialize Geulbeot structure and merge Prompts & test projects
7
03. Code/geulbeot_8th/.env.sample
Normal file
@@ -0,0 +1,7 @@
|
||||
# 글벗 API Keys
|
||||
# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요
|
||||
# cp .env.sample .env
|
||||
|
||||
CLAUDE_API_KEY=여기에_키값_입력
|
||||
GEMINI_API_KEY=여기에_키값_입력
|
||||
GPT_API_KEY=여기에_키값_입력
|
||||
32
03. Code/geulbeot_8th/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# API Keys - Gitea에 올리지 않기!
|
||||
api_keys.json
|
||||
1
03. Code/geulbeot_8th/Procfile
Normal file
@@ -0,0 +1 @@
|
||||
web: gunicorn app:app
|
||||
446
03. Code/geulbeot_8th/README.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# 글벗 (Geulbeot) v8.0
|
||||
|
||||
**문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화**
|
||||
|
||||
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
|
||||
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
|
||||
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
|
||||
|
||||
v8에서는 **문서 유형 분석·등록 시스템**을 구축했습니다.
|
||||
HWPX 파일을 업로드하면 12종의 추출 도구가 XML을 코드 기반으로 파싱하고,
|
||||
시맨틱 매퍼가 요소 의미를 판별한 뒤, 스타일 생성기가 CSS를 산출하여
|
||||
사용자 정의 문서 유형으로 등록합니다. 등록된 유형은 기획서·보고서와 동일하게 문서 생성에 사용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
### 핵심 흐름
|
||||
|
||||
```
|
||||
자료 입력 (파일/폴더)
|
||||
│
|
||||
▼
|
||||
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성
|
||||
│
|
||||
▼
|
||||
RAG 파이프라인 (9단계) ─── 공통 처리
|
||||
│
|
||||
▼
|
||||
문서 유형 선택
|
||||
├─ 기획서 (기본)
|
||||
├─ 보고서 (기본)
|
||||
├─ 발표자료 (기본)
|
||||
└─ 사용자 등록 (v8 — HWPX 분석 → 자동 등록)
|
||||
│
|
||||
▼
|
||||
글벗 표준 HTML 생성 ◀── 템플릿 스타일 + 시맨틱 맵 참조
|
||||
│
|
||||
▼
|
||||
웹 편집기 (수기 편집 / AI 편집)
|
||||
│
|
||||
▼
|
||||
출력 (HTML / PDF / HWP)
|
||||
```
|
||||
|
||||
### 1. Backend (Python Flask)
|
||||
|
||||
- **Language**: Python 3.13
|
||||
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
|
||||
- **AI**:
|
||||
- Claude API (Anthropic) — 기획서 생성, AI 편집, 문서 유형 맥락 분석
|
||||
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
|
||||
- Gemini API — 보고서 콘텐츠·HTML 생성
|
||||
- **Features**:
|
||||
- 자료 입력 → 9단계 RAG 파이프라인
|
||||
- 문서 유형별 생성: 기획서 (Claude), 보고서 (Gemini), 사용자 정의 유형 (v8 신규)
|
||||
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
|
||||
- 문서 유형 분석·등록 (v8 신규): HWPX 업로드 → 12종 도구 추출 → 시맨틱 매핑 → 스타일 생성 → 유형 CRUD
|
||||
- HWPX 템플릿 관리: 추출·저장·교체·삭제
|
||||
- HWP 변환: 하이브리드 방식
|
||||
- PDF 변환: WeasyPrint 기반
|
||||
|
||||
### 2. Frontend (순수 JavaScript)
|
||||
|
||||
- **Features**:
|
||||
- 웹 WYSIWYG 편집기 — 생성된 문서 직접 수정
|
||||
- 작성 방식 선택 탭: 형식만 변경 / 내용 재구성 / 신규 작성
|
||||
- 문서 유형 선택 UI: 기본 3종 + 사용자 등록 유형 동적 표시
|
||||
- 템플릿 관리 UI: 사이드바 목록·선택·삭제, 요소별 체크박스
|
||||
- HTML / PDF / HWP 다운로드
|
||||
|
||||
### 3. 변환 엔진 (Converters)
|
||||
|
||||
- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립
|
||||
- **분량 자동 판단**: 5,000자 기준
|
||||
- **HWP 변환 (하이브리드)**: HTML 분석 → pyhwpx 변환 → HWPX 스타일 주입 → 표 열 너비 수정
|
||||
|
||||
### 4. HWPX 추출 도구 12종 (v8 신규)
|
||||
|
||||
HWPX XML에서 특정 항목을 **코드 기반**으로 추출하는 모듈 패키지 (`handlers/tools/`):
|
||||
|
||||
| 도구 | 대상 | 추출 내용 |
|
||||
|------|------|----------|
|
||||
| page_setup | §7 용지/여백 | pagePr, margin, 용지 크기 |
|
||||
| font | §3 글꼴 | fontface → 폰트명·유형 매핑 |
|
||||
| char_style | §4 글자 모양 | charPr 28개 속성 전체 |
|
||||
| para_style | §5 문단 모양 | paraPr 23개 속성 전체 |
|
||||
| border_fill | §2 테두리/배경 | borderFill, 색상·선 종류 |
|
||||
| table | §6 표 | tbl, tc, 병합·너비·셀 구조 |
|
||||
| header_footer | §8 머리말/꼬리말 | headerFooter 영역 |
|
||||
| section | §9 구역 정의 | secPr, 다단, 페이지 속성 |
|
||||
| style_def | 스타일 정의 | styles 목록 (charPr + paraPr 조합) |
|
||||
| numbering | 번호매기기 | 글머리표·번호 체계 |
|
||||
| image | 이미지 | 그리기 객체, 크기·위치 |
|
||||
| content_order | 본문 순서 | section*.xml 문단·표·이미지 순서 |
|
||||
|
||||
- 추출 실패 시 `None` 반환 (디폴트값 절대 생성 안 함)
|
||||
- 모든 단위 변환은 `hwpx_utils` 사용 (hwpunit→mm, charsize→pt)
|
||||
- `hwpx_domain_guide.md` 기준 준수
|
||||
|
||||
### 5. 문서 유형 분석·등록 (v8 신규)
|
||||
|
||||
HWPX 업로드 → 자동 분석 → 사용자 정의 문서 유형 등록:
|
||||
|
||||
1. **DocTemplateAnalyzer**: HWPX 압축 해제 → 12종 도구로 코드 기반 추출
|
||||
2. **SemanticMapper**: 추출 결과에서 요소 의미 판별 (헤더표/푸터표/제목블록/데이터표/섹션)
|
||||
3. **StyleGenerator**: 추출값 → CSS 생성 (charPr→클래스, paraPr→클래스, 폰트 매핑, 줄간격)
|
||||
4. **ContentAnalyzer**: template_info + semantic_map → content_prompt.json (placeholder 의미·유형·작성 패턴)
|
||||
5. **DocTypeAnalyzer**: AI로 맥락(목적/문서유형)과 구조 가이드(섹션별 작성법) 분석 — 레이아웃은 코드 추출
|
||||
6. **TemplateManager**: 템플릿 CRUD (생성·조회·삭제·교체), template.html 조립
|
||||
7. **CustomDocType**: 등록된 유형으로 실제 문서 생성 — template.html에 사용자 콘텐츠 채움
|
||||
|
||||
### 6. 주요 시나리오 (Core Scenarios)
|
||||
|
||||
1. **기획서 생성**: RAG 분석 후 Claude API가 구조 추출 → 배치 → 글벗 표준 HTML 생성
|
||||
2. **보고서 생성**: RAG 파이프라인 → Gemini API가 다페이지 HTML 보고서 생성
|
||||
3. **사용자 정의 문서 생성 (v8 신규)**: 등록된 유형의 template.html + content_prompt.json을 기반으로, 사용자 입력 내용을 정리·재구성하여 문서 생성
|
||||
4. **문서 유형 등록 (v8 신규)**: HWPX 업로드 → 12종 도구 추출 → 시맨틱 매핑 → CSS 생성 → config.json + template.html + semantic_map.json + style.json 자동 저장
|
||||
5. **AI 편집**: 웹 편집기에서 전체·부분 수정
|
||||
6. **HWP 내보내기**: 하이브리드 변환
|
||||
|
||||
### 프로세스 플로우
|
||||
|
||||
#### RAG 파이프라인 (공통)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
|
||||
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
|
||||
A[/"📂 자료 입력 (파일/폴더)"/]:::process
|
||||
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
|
||||
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
|
||||
D{"분량 판단\n5,000자 기준"}:::decision
|
||||
|
||||
E["step3: 도메인 분석"]:::process
|
||||
F["step4: 의미 단위 청킹"]:::process
|
||||
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
|
||||
H["step6: 코퍼스 생성"]:::process
|
||||
|
||||
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
|
||||
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
|
||||
|
||||
A --> B --> C --> D
|
||||
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
|
||||
D -->|"< 5,000자"| I
|
||||
I --> J
|
||||
```
|
||||
|
||||
#### 전체 워크플로우 (v8 시점)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
|
||||
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
|
||||
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
|
||||
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
|
||||
classDef newModule fill:#e0f2f1,stroke:#00695c,stroke-width:2px,color:#004d40
|
||||
classDef uiNew fill:#e8eaf6,stroke:#3949ab,stroke-width:2px,color:#1a237e
|
||||
|
||||
A(["📂 자료 입력"]):::startEnd
|
||||
|
||||
W{"작성 방식 선택"}:::uiNew
|
||||
W1["📄 형식만 변경"]:::uiNew
|
||||
W2["🔄 내용 재구성"]:::uiNew
|
||||
W3["✨ 신규 작성"]:::uiNew
|
||||
|
||||
R["RAG 파이프라인\n9단계 공통 처리"]:::startEnd
|
||||
|
||||
B{"문서 유형 선택"}:::decision
|
||||
|
||||
C["기획서 생성\n⚡ Claude API"]:::aiClaude
|
||||
D["보고서 생성\n⚡ Gemini API"]:::aiGemini
|
||||
E["발표자료\n예정"]:::planned
|
||||
U["사용자 정의 유형\ntemplate.html 기반\n(v8 신규)"]:::newModule
|
||||
|
||||
T["📋 템플릿 + 시맨틱 맵\nstyle.json\nsemantic_map.json\ncontent_prompt.json"]:::newModule
|
||||
|
||||
G["글벗 표준 HTML"]:::startEnd
|
||||
|
||||
H{"편집 방식"}:::decision
|
||||
I["웹 편집기\n수기 편집"]:::editStyle
|
||||
J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude
|
||||
|
||||
K{"출력 형식"}:::decision
|
||||
L["HTML / PDF"]:::exportStyle
|
||||
M["HWP 변환\n하이브리드"]:::exportStyle
|
||||
N["PPT\n예정"]:::planned
|
||||
O(["✅ 최종 산출물"]):::startEnd
|
||||
|
||||
A --> W
|
||||
W --> W1 & W2 & W3
|
||||
W1 & W2 & W3 --> R
|
||||
R --> B
|
||||
|
||||
B -->|"기획서"| C --> G
|
||||
B -->|"보고서"| D --> G
|
||||
B -->|"발표자료"| E -.-> G
|
||||
B -->|"사용자 유형"| U --> G
|
||||
|
||||
T -.->|"스타일·구조 참조"| U
|
||||
|
||||
G --> H
|
||||
H -->|"수기"| I --> K
|
||||
H -->|"AI"| J --> K
|
||||
K -->|"웹/인쇄"| L --> O
|
||||
K -->|"HWP"| M --> O
|
||||
K -->|"PPT"| N -.-> O
|
||||
```
|
||||
|
||||
#### 문서 유형 등록 (v8 신규)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
|
||||
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
|
||||
classDef aiNode fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
|
||||
classDef dataStore fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
|
||||
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||
|
||||
A(["📄 HWPX 업로드"]):::startEnd
|
||||
B["DocTemplateAnalyzer\n12종 tools 코드 추출"]:::newModule
|
||||
C["SemanticMapper\n요소 의미 판별\n헤더표/푸터표/제목블록/데이터표"]:::newModule
|
||||
D["StyleGenerator\n추출값 → CSS 생성\ncharPr·paraPr·폰트 매핑"]:::newModule
|
||||
E["ContentAnalyzer\nplaceholder 의미·유형\ncontent_prompt.json"]:::newModule
|
||||
F["DocTypeAnalyzer\n⚡ AI 맥락·구조 분석\nconfig.json"]:::aiNode
|
||||
G["TemplateManager\ntemplate.html 조립"]:::newModule
|
||||
|
||||
H[("📋 templates/user/\ntemplates/{tpl_id}/\ndoc_types/{type_id}/")]:::dataStore
|
||||
|
||||
A --> B --> C --> D --> E
|
||||
B --> F
|
||||
C & D & E & F --> G --> H
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 v7 → v8 변경사항
|
||||
|
||||
| 영역 | v7 | v8 |
|
||||
|------|------|------|
|
||||
| 문서 유형 등록 | 없음 | **HWPX → 자동 분석 → 유형 CRUD** (doc_type_analyzer + custom_doc_type) |
|
||||
| HWPX 추출 | template/processor.py 단일 | **handlers/tools/ 12종 모듈** (폰트·문단·표·테두리·이미지 등) |
|
||||
| 시맨틱 매핑 | 없음 | **semantic_mapper** — 요소 의미 판별 (헤더/푸터/제목/데이터표) |
|
||||
| 스타일 생성 | CSS 자동 생성 (기초) | **style_generator v2.1** — charPr 28개·paraPr 23개 전체 CSS 클래스 |
|
||||
| 콘텐츠 분석 | 없음 | **content_analyzer** — placeholder 의미·유형·작성 패턴 추출 |
|
||||
| 템플릿 관리 | 분석·저장·CRUD | **template_manager v5.2** — content_order 기반 본문 조립, 독립 저장 구조 |
|
||||
| 도메인 지식 | 없음 | **domain/hwpx/** — hwpx_utils + hwpx_domain_guide.md |
|
||||
| 기본 문서유형 | 하드코딩 | **config.json 3종** (briefing·report·presentation) |
|
||||
| 사용자 유형 | 없음 | **templates/user/** 디렉토리 (doc_types + templates 분리) |
|
||||
| 신규 API | — | `/api/doc-types` CRUD, `/api/templates` CRUD, `/api/doc-types/analyze-stream` |
|
||||
| app.py | 354줄 | 682줄 (+328) |
|
||||
| Python 전체 | 11,500줄 | 18,917줄 (+7,417) |
|
||||
|
||||
---
|
||||
|
||||
## 🗺 상태 및 로드맵 (Status & Roadmap)
|
||||
|
||||
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현)
|
||||
- **Phase 2**: 문서 생성 — 기획서·보고서·사용자 정의 유형 AI 생성 (🔧 기본 구현)
|
||||
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
|
||||
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
|
||||
- **Phase 5**: 문서 유형 분석·등록 — HWPX → 12종 도구 추출 → 시맨틱 매핑 → 유형 CRUD (🔧 기본 구현 · 현재 버전)
|
||||
- **Phase 6**: HWPX 템플릿 관리 — template_manager v5.2, content_order 기반 조립, 독립 저장 (🔧 기본 구현 · 현재 버전)
|
||||
- **Phase 7**: UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI (🔧 기본 구현)
|
||||
- **Phase 8**: 백엔드 재구조화 + 배포 — 패키지 정리, API 키 공통화, 로깅, Docker (예정)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기 (Getting Started)
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
- Python 3.10+
|
||||
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집, 문서 유형 분석
|
||||
- OpenAI API 키 — RAG 파이프라인
|
||||
- Gemini API 키 — 보고서 콘텐츠·HTML 생성
|
||||
- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수)
|
||||
|
||||
### 환경 설정
|
||||
|
||||
```bash
|
||||
# 저장소 클론
|
||||
git clone http://[Gitea주소]/kei/geulbeot-v8.git
|
||||
cd geulbeot-v8
|
||||
|
||||
# 가상환경
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# 패키지 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 환경변수
|
||||
cp .env.sample .env
|
||||
# .env 파일을 열어 실제 API 키 입력
|
||||
```
|
||||
|
||||
### .env 작성
|
||||
|
||||
```env
|
||||
CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집, 유형 분석
|
||||
GPT_API_KEY=sk-proj-your-key-here # RAG 파이프라인
|
||||
GEMINI_API_KEY=AIzaSy-your-key-here # 보고서 콘텐츠 생성
|
||||
```
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
# → http://localhost:5000 접속
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 프로젝트 구조
|
||||
|
||||
```
|
||||
geulbeot_8th/
|
||||
├── app.py # Flask 웹 서버 — API 라우팅 (682줄)
|
||||
├── api_config.py # .env 환경변수 로더
|
||||
│
|
||||
├── domain/ # ★ v8 신규 — 도메인 지식
|
||||
│ └── hwpx/
|
||||
│ ├── hwpx_domain_guide.md # HWPX 명세서 (§1~§11)
|
||||
│ └── hwpx_utils.py # 단위 변환 (hwpunit→mm, charsize→pt)
|
||||
│
|
||||
├── handlers/ # 비즈니스 로직
|
||||
│ ├── common.py # Claude API 호출, JSON/HTML 추출
|
||||
│ ├── briefing/ # 기획서 처리
|
||||
│ ├── report/ # 보고서 처리
|
||||
│ ├── template/ # 템플릿 기본 관리
|
||||
│ │
|
||||
│ ├── doc_type_analyzer.py # ★ v8 — 문서 유형 AI 분석 (맥락·구조)
|
||||
│ ├── doc_template_analyzer.py # ★ v8 — HWPX → 12종 도구 추출 오케스트레이터
|
||||
│ ├── semantic_mapper.py # ★ v8 — 요소 의미 판별 (헤더/푸터/제목/데이터표)
|
||||
│ ├── style_generator.py # ★ v8 — 추출값 → CSS 클래스 생성
|
||||
│ ├── content_analyzer.py # ★ v8 — placeholder 의미·유형·작성 패턴
|
||||
│ ├── template_manager.py # ★ v8 — 템플릿 CRUD + template.html 조립
|
||||
│ ├── custom_doc_type.py # ★ v8 — 사용자 정의 유형 문서 생성
|
||||
│ │
|
||||
│ └── tools/ # ★ v8 — HWPX 추출 도구 12종
|
||||
│ ├── page_setup.py # §7 용지/여백
|
||||
│ ├── font.py # §3 글꼴
|
||||
│ ├── char_style.py # §4 글자 모양 (charPr 28개)
|
||||
│ ├── para_style.py # §5 문단 모양 (paraPr 23개)
|
||||
│ ├── border_fill.py # §2 테두리/배경
|
||||
│ ├── table.py # §6 표 (병합·너비·셀)
|
||||
│ ├── header_footer.py # §8 머리말/꼬리말
|
||||
│ ├── section.py # §9 구역 정의
|
||||
│ ├── style_def.py # 스타일 정의
|
||||
│ ├── numbering.py # 번호매기기/글머리표
|
||||
│ ├── image.py # 이미지/그리기 객체
|
||||
│ └── content_order.py # 본문 콘텐츠 순서
|
||||
│
|
||||
├── converters/ # 변환 엔진
|
||||
│ ├── pipeline/ # 9단계 RAG 파이프라인
|
||||
│ ├── style_analyzer.py # HTML 요소 역할 분류
|
||||
│ ├── hwpx_generator.py # HWPX 파일 직접 생성
|
||||
│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑
|
||||
│ ├── hwpx_style_injector.py # HWPX 커스텀 스타일 주입
|
||||
│ ├── hwpx_table_injector.py # HWPX 표 열 너비 정밀 수정
|
||||
│ ├── html_to_hwp.py # 보고서 → HWP 변환
|
||||
│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환
|
||||
│
|
||||
├── templates/ # 문서 유형 + UI
|
||||
│ ├── default/doc_types/ # 기본 유형 설정
|
||||
│ │ ├── briefing/config.json # 기획서
|
||||
│ │ ├── report/config.json # 보고서
|
||||
│ │ └── presentation/config.json # 발표자료
|
||||
│ ├── user/ # ★ v8 — 사용자 등록 데이터
|
||||
│ │ ├── doc_types/{type_id}/ # config.json + content_prompt.json
|
||||
│ │ └── templates/{tpl_id}/ # meta.json + style.json + semantic_map.json + template.html
|
||||
│ ├── hwp_guide.md
|
||||
│ ├── hwp_html_defaults.json
|
||||
│ └── index.html # 메인 UI
|
||||
│
|
||||
├── static/
|
||||
│ ├── js/editor.js # 웹 WYSIWYG 편집기
|
||||
│ └── css/editor.css # 편집기 스타일
|
||||
│
|
||||
├── .env / .env.sample
|
||||
├── .gitignore
|
||||
├── requirements.txt
|
||||
├── Procfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 글벗 표준 HTML 양식
|
||||
|
||||
| 항목 | 사양 |
|
||||
|------|------|
|
||||
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
|
||||
| 폰트 | Noto Sans KR (Google Fonts) |
|
||||
| 색상 | Navy 계열 (#1a365d 기본) |
|
||||
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
|
||||
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 알려진 제한사항
|
||||
|
||||
- 로컬 경로 하드코딩: `D:\for python\...` 잔존 (router.py, app.py)
|
||||
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
|
||||
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
|
||||
- 발표자료: config.json만 존재, 실제 생성 미구현
|
||||
- 사용자 유형 생성: template.html 기반 채움만 (AI 창작 아닌 정리·재구성)
|
||||
- 레거시 잔존: prompts/ 디렉토리, templates/hwp_guide.html → .md 전환 중
|
||||
|
||||
---
|
||||
|
||||
## 📊 코드 규모
|
||||
|
||||
| 영역 | 줄 수 |
|
||||
|------|-------|
|
||||
| Python 전체 | 18,917 (+7,417) |
|
||||
| 프론트엔드 (JS + CSS + HTML) | 5,269 (+365) |
|
||||
| **합계** | **~24,200** |
|
||||
|
||||
---
|
||||
|
||||
## 📝 버전 이력
|
||||
|
||||
| 버전 | 핵심 변경 |
|
||||
|------|----------|
|
||||
| v1 | Flask + Claude API 기획서 생성기 |
|
||||
| v2 | 웹 편집기 추가 |
|
||||
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
|
||||
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
|
||||
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
|
||||
| v6 | HWPX 템플릿 분석·저장·관리 |
|
||||
| v7 | UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI |
|
||||
| **v8** | **문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화** |
|
||||
|
||||
---
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
Private — GPD 내부 사용
|
||||
30
03. Code/geulbeot_8th/api_config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""API 키 관리 - .env 파일에서 읽기"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def load_api_keys():
|
||||
"""프로젝트 폴더의 .env에서 API 키 로딩"""
|
||||
# python-dotenv 있으면 사용
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).resolve().parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
except ImportError:
|
||||
# python-dotenv 없으면 수동 파싱
|
||||
env_path = Path(__file__).resolve().parent / '.env'
|
||||
if env_path.exists():
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, _, value = line.partition('=')
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
return {
|
||||
'CLAUDE_API_KEY': os.getenv('CLAUDE_API_KEY', ''),
|
||||
'GPT_API_KEY': os.getenv('GPT_API_KEY', ''),
|
||||
'GEMINI_API_KEY': os.getenv('GEMINI_API_KEY', ''),
|
||||
'PERPLEXITY_API_KEY': os.getenv('PERPLEXITY_API_KEY', ''),
|
||||
}
|
||||
|
||||
API_KEYS = load_api_keys()
|
||||
683
03. Code/geulbeot_8th/app.py
Normal file
@@ -0,0 +1,683 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
글벗 Light v2.0
|
||||
Flask 라우팅 + 공통 기능
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import tempfile
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, request, jsonify, Response, session, send_file
|
||||
import queue
|
||||
import threading
|
||||
from handlers.template_manager import TemplateManager
|
||||
from pathlib import Path
|
||||
|
||||
# 문서 유형별 프로세서
|
||||
from handlers.template import TemplateProcessor
|
||||
from handlers.briefing import BriefingProcessor
|
||||
from handlers.report import ReportProcessor
|
||||
from handlers.custom_doc_type import CustomDocTypeProcessor
|
||||
from handlers.doc_type_analyzer import DocTypeAnalyzer
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
|
||||
|
||||
# processors 딕셔너리에 추가
|
||||
template_mgr = TemplateManager()
|
||||
processors = {
|
||||
'briefing': BriefingProcessor(),
|
||||
'report': ReportProcessor(),
|
||||
'template': TemplateProcessor(),
|
||||
'custom': CustomDocTypeProcessor()
|
||||
}
|
||||
|
||||
DOC_TYPES_DEFAULT = Path('templates/default/doc_types')
|
||||
DOC_TYPES_USER = Path('templates/user/doc_types')
|
||||
|
||||
|
||||
# ============== 메인 페이지 ==============
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""메인 페이지"""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/doc-types', methods=['GET'])
|
||||
def get_doc_types():
|
||||
"""문서 유형 목록 조회"""
|
||||
try:
|
||||
doc_types = []
|
||||
|
||||
# default 폴더 스캔
|
||||
if DOC_TYPES_DEFAULT.exists():
|
||||
for folder in DOC_TYPES_DEFAULT.iterdir():
|
||||
if folder.is_dir():
|
||||
config_file = folder / 'config.json'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
doc_types.append(json.load(f))
|
||||
|
||||
# user 폴더 스캔
|
||||
if DOC_TYPES_USER.exists():
|
||||
for folder in DOC_TYPES_USER.iterdir():
|
||||
if folder.is_dir():
|
||||
config_file = folder / 'config.json'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
doc_types.append(json.load(f))
|
||||
|
||||
# order → isDefault 순 정렬
|
||||
doc_types.sort(key=lambda x: (x.get('order', 999), not x.get('isDefault', False)))
|
||||
|
||||
return jsonify(doc_types)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types', methods=['POST'])
|
||||
def add_doc_type():
|
||||
"""문서 유형 추가 (분석 결과 저장)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'JSON 데이터가 필요합니다'}), 400
|
||||
|
||||
# user 폴더 생성
|
||||
DOC_TYPES_USER.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
type_id = data.get('id')
|
||||
if not type_id:
|
||||
import time
|
||||
type_id = f"user_{int(time.time())}"
|
||||
data['id'] = type_id
|
||||
|
||||
folder_path = DOC_TYPES_USER / type_id
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# config.json 저장
|
||||
with open(folder_path / 'config.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>', methods=['DELETE'])
|
||||
def delete_doc_type(type_id):
|
||||
"""문서 유형 삭제"""
|
||||
try:
|
||||
folder_path = DOC_TYPES_USER / type_id
|
||||
|
||||
if not folder_path.exists():
|
||||
return jsonify({'error': '문서 유형을 찾을 수 없습니다'}), 404
|
||||
|
||||
shutil.rmtree(folder_path)
|
||||
return jsonify({'success': True, 'deleted': type_id})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
# ============== 생성 API ==============
|
||||
|
||||
@app.route('/generate', methods=['POST'])
|
||||
def generate():
|
||||
"""문서 생성 API"""
|
||||
try:
|
||||
content = ""
|
||||
if 'file' in request.files and request.files['file'].filename:
|
||||
file = request.files['file']
|
||||
content = file.read().decode('utf-8')
|
||||
elif 'content' in request.form:
|
||||
content = request.form.get('content', '')
|
||||
|
||||
doc_type = request.form.get('doc_type', 'briefing')
|
||||
|
||||
if doc_type.startswith('user_'):
|
||||
options = {
|
||||
'instruction': request.form.get('instruction', '')
|
||||
}
|
||||
result = processors['custom'].generate(content, doc_type, options)
|
||||
else:
|
||||
options = {
|
||||
'page_option': request.form.get('page_option', '1'),
|
||||
'department': request.form.get('department', ''),
|
||||
'instruction': request.form.get('instruction', '')
|
||||
}
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.generate(content, options)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400 if 'trace' not in result else 500
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/generate-report', methods=['POST'])
|
||||
def generate_report():
|
||||
"""보고서 생성 API"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
content = data.get('content', '')
|
||||
|
||||
options = {
|
||||
'folder_path': data.get('folder_path', ''),
|
||||
'cover': data.get('cover', False),
|
||||
'toc': data.get('toc', False),
|
||||
'divider': data.get('divider', False),
|
||||
'instruction': data.get('instruction', ''),
|
||||
'template_id': data.get('template_id')
|
||||
}
|
||||
|
||||
result = processors['report'].generate(content, options)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 500
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
# ============== 수정 API ==============
|
||||
|
||||
@app.route('/refine', methods=['POST'])
|
||||
def refine():
|
||||
"""피드백 반영 API"""
|
||||
try:
|
||||
feedback = request.json.get('feedback', '')
|
||||
current_html = request.json.get('current_html', '') or session.get('current_html', '')
|
||||
original_html = session.get('original_html', '')
|
||||
doc_type = request.json.get('doc_type', 'briefing')
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.refine(feedback, current_html, original_html)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/refine-selection', methods=['POST'])
|
||||
def refine_selection():
|
||||
"""선택 부분 수정 API"""
|
||||
try:
|
||||
data = request.json
|
||||
current_html = data.get('current_html', '')
|
||||
selected_text = data.get('selected_text', '')
|
||||
user_request = data.get('request', '')
|
||||
doc_type = data.get('doc_type', 'briefing')
|
||||
|
||||
processor = processors.get(doc_type, processors['briefing'])
|
||||
result = processor.refine_selection(current_html, selected_text, user_request)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ============== 다운로드 API ==============
|
||||
|
||||
@app.route('/download/html', methods=['POST'])
|
||||
def download_html():
|
||||
"""HTML 파일 다운로드"""
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.html'
|
||||
|
||||
return Response(
|
||||
html_content,
|
||||
mimetype='text/html',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
|
||||
|
||||
@app.route('/download/pdf', methods=['POST'])
|
||||
def download_pdf():
|
||||
"""PDF 파일 다운로드"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
HTML(string=html_content).write_pdf(pdf_buffer)
|
||||
pdf_buffer.seek(0)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.pdf'
|
||||
|
||||
return Response(
|
||||
pdf_buffer.getvalue(),
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
except ImportError:
|
||||
return jsonify({'error': 'PDF 변환 미지원'}), 501
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ============== 기타 API ==============
|
||||
|
||||
@app.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
"""로컬 assets 폴더 서빙"""
|
||||
assets_dir = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
|
||||
return send_file(os.path.join(assets_dir, filename))
|
||||
|
||||
|
||||
@app.route('/hwp-script')
|
||||
def hwp_script():
|
||||
"""HWP 변환 스크립트 안내"""
|
||||
return render_template('hwp_guide.html')
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
"""헬스 체크"""
|
||||
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
||||
|
||||
|
||||
@app.route('/export-hwp', methods=['POST'])
|
||||
def export_hwp():
|
||||
"""HWP 변환 (스타일 그루핑 지원)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
html_content = data.get('html', '')
|
||||
doc_type = data.get('doc_type', 'briefing')
|
||||
use_style_grouping = data.get('style_grouping', False) # 새 옵션
|
||||
|
||||
if not html_content:
|
||||
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
||||
|
||||
temp_dir = tempfile.gettempdir()
|
||||
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
|
||||
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
|
||||
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
# 변환기 선택
|
||||
if doc_type == 'briefing':
|
||||
from converters.html_to_hwp_briefing import HtmlToHwpConverter
|
||||
else:
|
||||
from converters.html_to_hwp import HtmlToHwpConverter
|
||||
|
||||
converter = HtmlToHwpConverter(visible=False)
|
||||
|
||||
# 스타일 그루핑 사용 여부
|
||||
if use_style_grouping:
|
||||
final_path = converter.convert_with_styles(html_path, hwp_path)
|
||||
# HWPX 파일 전송
|
||||
return send_file(
|
||||
final_path,
|
||||
as_attachment=True,
|
||||
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwpx',
|
||||
mimetype='application/vnd.hancom.hwpx'
|
||||
)
|
||||
else:
|
||||
converter.convert(html_path, hwp_path)
|
||||
return send_file(
|
||||
hwp_path,
|
||||
as_attachment=True,
|
||||
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp',
|
||||
mimetype='application/x-hwp'
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# 기존 add_doc_type 대체 또는 수정
|
||||
@app.route('/api/doc-types/analyze', methods=['POST'])
|
||||
def analyze_doc_type():
|
||||
"""문서 유형 분석 API"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "파일이 필요합니다"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
doc_name = request.form.get('name', '새 문서 유형')
|
||||
|
||||
# 임시 저장
|
||||
import tempfile
|
||||
temp_path = os.path.join(tempfile.gettempdir(), file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
try:
|
||||
analyzer = DocTypeAnalyzer()
|
||||
result = analyzer.analyze(temp_path, doc_name)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"config": result["config"],
|
||||
"summary": {
|
||||
"pageCount": result["structure"]["pageCount"],
|
||||
"sections": len(result["toc"]),
|
||||
"style": result["style"]
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
@app.route('/analyze-styles', methods=['POST'])
|
||||
def analyze_styles():
|
||||
"""HTML 스타일 분석 미리보기"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
html_content = data.get('html', '')
|
||||
|
||||
if not html_content:
|
||||
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
||||
|
||||
from converters.style_analyzer import StyleAnalyzer
|
||||
from converters.hwp_style_mapping import ROLE_TO_STYLE_NAME
|
||||
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(html_content)
|
||||
|
||||
# 요약 정보
|
||||
summary = analyzer.get_role_summary()
|
||||
|
||||
# 상세 정보 (처음 50개만)
|
||||
details = []
|
||||
for elem in elements[:50]:
|
||||
details.append({
|
||||
'role': elem.role,
|
||||
'hwp_style': ROLE_TO_STYLE_NAME.get(elem.role, '바탕글'),
|
||||
'text': elem.text[:50] + ('...' if len(elem.text) > 50 else ''),
|
||||
'section': elem.section
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total_elements': len(elements),
|
||||
'summary': summary,
|
||||
'details': details
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
@app.route('/templates', methods=['GET'])
|
||||
def get_templates():
|
||||
"""저장된 템플릿 목록 조회"""
|
||||
try:
|
||||
templates = template_mgr.list_templates()
|
||||
return jsonify(templates)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/templates', methods=['GET'])
|
||||
def get_templates_api():
|
||||
"""템플릿 목록 조회 (API 경로)"""
|
||||
try:
|
||||
templates = template_mgr.list_templates()
|
||||
return jsonify(templates)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/analyze-template', methods=['POST'])
|
||||
def analyze_template():
|
||||
"""템플릿 추출 및 저장 (doc_template_analyzer → template_manager)"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '파일이 없습니다'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
name = request.form.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': '템플릿 이름을 입력해주세요'}), 400
|
||||
|
||||
if not file.filename:
|
||||
return jsonify({'error': '파일을 선택해주세요'}), 400
|
||||
|
||||
# 임시 저장 → HWPX 파싱 → 템플릿 추출
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
try:
|
||||
# v3 파서 재사용 (HWPX → parsed dict)
|
||||
from handlers.doc_type_analyzer import DocTypeAnalyzer
|
||||
parser = DocTypeAnalyzer()
|
||||
parsed = parser._parse_hwpx(temp_path)
|
||||
|
||||
# template_manager로 추출+저장
|
||||
result = template_mgr.extract_and_save(
|
||||
parsed, name,
|
||||
source_file=file.filename
|
||||
)
|
||||
|
||||
return jsonify(result)
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
||||
|
||||
# ============== 문서 유형 분석 SSE API ==============
|
||||
|
||||
@app.route('/api/doc-types/analyze-stream', methods=['POST'])
|
||||
def analyze_doc_type_stream():
|
||||
"""
|
||||
문서 유형 분석 (SSE 스트리밍)
|
||||
실시간으로 각 단계의 진행 상황을 전달
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
# 파일 및 데이터 검증
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '파일이 없습니다'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': '문서 유형 이름을 입력해주세요'}), 400
|
||||
|
||||
if not file.filename:
|
||||
return jsonify({'error': '파일을 선택해주세요'}), 400
|
||||
|
||||
# 임시 파일 저장
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_path = os.path.join(temp_dir, file.filename)
|
||||
file.save(temp_path)
|
||||
|
||||
# 메시지 큐 생성
|
||||
message_queue = queue.Queue()
|
||||
analysis_result = {"data": None, "error": None}
|
||||
|
||||
def progress_callback(step_id, status, message):
|
||||
"""진행 상황 콜백 - 메시지 큐에 추가"""
|
||||
message_queue.put({
|
||||
"type": "progress",
|
||||
"step": step_id,
|
||||
"status": status,
|
||||
"message": message
|
||||
})
|
||||
|
||||
def run_analysis():
|
||||
"""분석 실행 (별도 스레드)"""
|
||||
try:
|
||||
|
||||
analyzer = DocTypeAnalyzer(progress_callback=progress_callback)
|
||||
result = analyzer.analyze(temp_path, name, description)
|
||||
|
||||
# 저장
|
||||
save_path = analyzer.save_doc_type(result["config"], result.get("template", "") )
|
||||
|
||||
analysis_result["data"] = {
|
||||
"success": True,
|
||||
"config": result["config"],
|
||||
"layout": result.get("layout", {}),
|
||||
"context": result.get("context", {}),
|
||||
"structure": result.get("structure", {}),
|
||||
"template_generated": bool(result.get("template_id") or result.get("template")),
|
||||
"template_id": result.get("template_id"), # ★ 추가
|
||||
"saved_path": save_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
analysis_result["error"] = {
|
||||
"message": str(e),
|
||||
"trace": traceback.format_exc()
|
||||
}
|
||||
finally:
|
||||
# 완료 신호
|
||||
message_queue.put({"type": "complete"})
|
||||
# 임시 파일 삭제
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def generate_events():
|
||||
"""SSE 이벤트 생성기"""
|
||||
# 분석 시작
|
||||
analysis_thread = threading.Thread(target=run_analysis)
|
||||
analysis_thread.start()
|
||||
|
||||
# 이벤트 스트리밍
|
||||
while True:
|
||||
try:
|
||||
msg = message_queue.get(timeout=60) # 60초 타임아웃
|
||||
|
||||
if msg["type"] == "complete":
|
||||
# 분석 완료
|
||||
if analysis_result["error"]:
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': analysis_result['error']}, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'result', 'data': analysis_result['data']}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
else:
|
||||
# 진행 상황
|
||||
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||
|
||||
except queue.Empty:
|
||||
# 타임아웃
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': {'message': '분석 시간 초과'}}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
return Response(
|
||||
generate_events(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
||||
|
||||
@app.route('/delete-template/<template_id>', methods=['DELETE'])
|
||||
def delete_template(template_id):
|
||||
"""템플릿 삭제 (레거시 호환)"""
|
||||
try:
|
||||
result = template_mgr.delete_template(template_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/templates/<tpl_id>', methods=['GET'])
|
||||
def get_template(tpl_id):
|
||||
"""특정 템플릿 조회"""
|
||||
try:
|
||||
result = template_mgr.load_template(tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/templates/<tpl_id>', methods=['DELETE'])
|
||||
def delete_template_new(tpl_id):
|
||||
"""템플릿 삭제"""
|
||||
try:
|
||||
result = template_mgr.delete_template(tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>/template', methods=['PUT'])
|
||||
def change_doc_type_template(type_id):
|
||||
"""문서 유형의 템플릿 교체"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
new_tpl_id = data.get('template_id')
|
||||
|
||||
if not new_tpl_id:
|
||||
return jsonify({'error': 'template_id가 필요합니다'}), 400
|
||||
|
||||
result = template_mgr.change_template(type_id, new_tpl_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/doc-types/<type_id>/template', methods=['GET'])
|
||||
def get_doc_type_template(type_id):
|
||||
"""문서 유형에 연결된 템플릿 조회"""
|
||||
try:
|
||||
result = template_mgr.get_template_for_doctype(type_id)
|
||||
if 'error' in result:
|
||||
return jsonify(result), 404
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
app.run(host='0.0.0.0', port=port, debug=debug)
|
||||
0
03. Code/geulbeot_8th/converters/__init__.py
Normal file
1123
03. Code/geulbeot_8th/converters/html_to_hwp.py
Normal file
616
03. Code/geulbeot_8th/converters/html_to_hwp_briefing.py
Normal file
@@ -0,0 +1,616 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HTML → HWP 변환기 (기획서 전용)
|
||||
|
||||
✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함)
|
||||
✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원
|
||||
✅ process-container (단계별 프로세스) 지원
|
||||
✅ badge 스타일 텍스트 변환
|
||||
✅ Navy 색상 테마
|
||||
|
||||
pip install pyhwpx beautifulsoup4
|
||||
"""
|
||||
|
||||
from pyhwpx import Hwp
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""페이지 설정"""
|
||||
PAGE_WIDTH = 210
|
||||
PAGE_HEIGHT = 297
|
||||
MARGIN_LEFT = 20
|
||||
MARGIN_RIGHT = 20
|
||||
MARGIN_TOP = 20
|
||||
MARGIN_BOTTOM = 15
|
||||
HEADER_LEN = 10
|
||||
FOOTER_LEN = 10
|
||||
CONTENT_WIDTH = 170
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
"""HTML → HWP 변환기 (기획서 전용)"""
|
||||
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
self.cfg = Config()
|
||||
self.colors = {}
|
||||
self.is_first_h1 = True
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 초기화 및 유틸리티
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _init_colors(self):
|
||||
"""색상 팔레트 초기화 (Navy 계열)"""
|
||||
self.colors = {
|
||||
'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d
|
||||
'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282
|
||||
'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce
|
||||
'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748
|
||||
'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568
|
||||
'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0
|
||||
'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc
|
||||
'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0
|
||||
'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f
|
||||
'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13
|
||||
'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b
|
||||
'white': self.hwp.RGBColor(255, 255, 255),
|
||||
'black': self.hwp.RGBColor(0, 0, 0),
|
||||
}
|
||||
|
||||
def _mm(self, mm):
|
||||
"""밀리미터를 HWP 단위로 변환"""
|
||||
return self.hwp.MiliToHwpUnit(mm)
|
||||
|
||||
def _pt(self, pt):
|
||||
"""포인트를 HWP 단위로 변환"""
|
||||
return self.hwp.PointToHwpUnit(pt)
|
||||
|
||||
def _rgb(self, hex_color):
|
||||
"""HEX 색상을 RGB로 변환"""
|
||||
c = hex_color.lstrip('#')
|
||||
return self.hwp.RGBColor(int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) if len(c) >= 6 else self.hwp.RGBColor(0, 0, 0)
|
||||
|
||||
def _font(self, size=10, color='black', bold=False):
|
||||
"""폰트 설정 (색상 이름 사용)"""
|
||||
self.hwp.set_font(
|
||||
FaceName='맑은 고딕',
|
||||
Height=size,
|
||||
Bold=bold,
|
||||
TextColor=self.colors.get(color, self.colors['black'])
|
||||
)
|
||||
|
||||
def _set_font(self, size=11, bold=False, hex_color='#000000'):
|
||||
"""폰트 설정 (HEX 색상 사용)"""
|
||||
self.hwp.set_font(
|
||||
FaceName='맑은 고딕',
|
||||
Height=size,
|
||||
Bold=bold,
|
||||
TextColor=self._rgb(hex_color)
|
||||
)
|
||||
|
||||
def _align(self, align):
|
||||
"""정렬 설정"""
|
||||
actions = {
|
||||
'left': 'ParagraphShapeAlignLeft',
|
||||
'center': 'ParagraphShapeAlignCenter',
|
||||
'right': 'ParagraphShapeAlignRight',
|
||||
'justify': 'ParagraphShapeAlignJustify',
|
||||
}
|
||||
if align in actions:
|
||||
self.hwp.HAction.Run(actions[align])
|
||||
|
||||
def _para(self, text='', size=10, color='black', bold=False, align='left'):
|
||||
"""문단 삽입"""
|
||||
self._align(align)
|
||||
self._font(size, color, bold)
|
||||
if text:
|
||||
self.hwp.insert_text(text)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _exit_table(self):
|
||||
"""표 편집 모드 종료"""
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _setup_page(self):
|
||||
"""페이지 설정"""
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
|
||||
s = self.hwp.HParameterSet.HSecDef
|
||||
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
|
||||
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
|
||||
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
|
||||
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
|
||||
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
|
||||
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
|
||||
self.hwp.HAction.Execute("PageSetup", s.HSet)
|
||||
print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm")
|
||||
except Exception as e:
|
||||
print(f"[경고] 페이지 설정 실패: {e}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 머리말 / 꼬리말 (보고서 방식)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _create_header(self, right_text=""):
|
||||
"""머리말 생성 (우측 정렬)"""
|
||||
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#4a5568')
|
||||
if right_text:
|
||||
self.hwp.insert_text(right_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 머리말: {e}")
|
||||
|
||||
def _create_footer(self, left_text=""):
|
||||
"""꼬리말 생성 (좌측 텍스트 + 우측 페이지 번호)"""
|
||||
print(f" → 꼬리말: {left_text}")
|
||||
|
||||
# 1. 꼬리말 열기
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
# 2. 좌측 정렬 + 제목 8pt
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
|
||||
self._set_font(8, False, '#4a5568')
|
||||
self.hwp.insert_text(left_text)
|
||||
|
||||
# 3. 꼬리말 닫기
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
|
||||
# 4. 쪽번호 (우측 하단)
|
||||
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
|
||||
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
|
||||
|
||||
def _new_section_with_header(self, header_text):
|
||||
"""새 구역 생성 후 머리말 설정"""
|
||||
print(f" → 새 구역 머리말: {header_text}")
|
||||
try:
|
||||
self.hwp.HAction.Run("BreakSection")
|
||||
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
|
||||
self.hwp.HAction.Run("SelectAll")
|
||||
self.hwp.HAction.Run("Delete")
|
||||
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
self._set_font(9, False, '#4a5568')
|
||||
self.hwp.insert_text(header_text)
|
||||
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f" [경고] 구역 머리말: {e}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 셀 배경색 설정
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _set_cell_bg(self, color_name):
|
||||
"""셀 배경색 설정 (색상 이름)"""
|
||||
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||
pset = self.hwp.HParameterSet.HCellBorderFill
|
||||
pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
|
||||
pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
|
||||
pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0)
|
||||
pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white'])
|
||||
pset.FillAttr.WindowsBrush = 1
|
||||
self.hwp.HAction.Execute("CellBorderFill", pset.HSet)
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# HTML 요소 변환 (기획서 전용)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def _convert_lead_box(self, elem):
|
||||
"""lead-box 변환 (핵심 기조 박스)"""
|
||||
content = elem.find("div")
|
||||
if not content:
|
||||
return
|
||||
|
||||
text = content.get_text(strip=True)
|
||||
text = ' '.join(text.split())
|
||||
print(f" → lead-box")
|
||||
|
||||
self.hwp.create_table(1, 1, treat_as_char=True)
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(11.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(text)
|
||||
self._exit_table()
|
||||
|
||||
def _convert_strategy_grid(self, elem):
|
||||
"""strategy-grid 변환 (2x2 전략 박스)"""
|
||||
items = elem.find_all(class_="strategy-item")
|
||||
if not items:
|
||||
return
|
||||
|
||||
print(f" → strategy-grid: {len(items)} items")
|
||||
|
||||
self.hwp.create_table(2, 2, treat_as_char=True)
|
||||
|
||||
for i, item in enumerate(items[:4]):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._set_cell_bg('bg-light')
|
||||
|
||||
title = item.find(class_="strategy-title")
|
||||
if title:
|
||||
self._font(10, 'primary-navy', True)
|
||||
self.hwp.insert_text(title.get_text(strip=True))
|
||||
self.hwp.BreakPara()
|
||||
|
||||
p = item.find("p")
|
||||
if p:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(p.get_text(strip=True))
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_process_container(self, elem):
|
||||
"""process-container 변환 (단계별 프로세스)"""
|
||||
steps = elem.find_all(class_="process-step")
|
||||
if not steps:
|
||||
return
|
||||
|
||||
print(f" → process-container: {len(steps)} steps")
|
||||
|
||||
rows = len(steps)
|
||||
self.hwp.create_table(rows, 2, treat_as_char=True)
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 번호 셀
|
||||
num = step.find(class_="step-num")
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(10, 'white', True)
|
||||
self._align('center')
|
||||
if num:
|
||||
self.hwp.insert_text(num.get_text(strip=True))
|
||||
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 내용 셀
|
||||
content = step.find(class_="step-content")
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(10.5, 'dark-gray', False)
|
||||
self._align('left')
|
||||
if content:
|
||||
self.hwp.insert_text(content.get_text(strip=True))
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_data_table(self, table):
|
||||
"""data-table 변환 (badge 포함)"""
|
||||
data = []
|
||||
|
||||
thead = table.find("thead")
|
||||
if thead:
|
||||
ths = thead.find_all("th")
|
||||
data.append([th.get_text(strip=True) for th in ths])
|
||||
|
||||
tbody = table.find("tbody")
|
||||
if tbody:
|
||||
for tr in tbody.find_all("tr"):
|
||||
row = []
|
||||
for td in tr.find_all("td"):
|
||||
badge = td.find(class_="badge")
|
||||
if badge:
|
||||
badge_class = ' '.join(badge.get('class', []))
|
||||
badge_text = badge.get_text(strip=True)
|
||||
if 'badge-safe' in badge_class:
|
||||
row.append(f"[✓ {badge_text}]")
|
||||
elif 'badge-caution' in badge_class:
|
||||
row.append(f"[△ {badge_text}]")
|
||||
elif 'badge-risk' in badge_class:
|
||||
row.append(f"[✗ {badge_text}]")
|
||||
else:
|
||||
row.append(f"[{badge_text}]")
|
||||
else:
|
||||
row.append(td.get_text(strip=True))
|
||||
data.append(row)
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
rows = len(data)
|
||||
cols = len(data[0]) if data else 0
|
||||
print(f" → data-table: {rows}×{cols}")
|
||||
|
||||
self.hwp.create_table(rows, cols, treat_as_char=True)
|
||||
|
||||
for row_idx, row in enumerate(data):
|
||||
for col_idx, cell_text in enumerate(row):
|
||||
is_header = (row_idx == 0)
|
||||
is_first_col = (col_idx == 0 and not is_header)
|
||||
|
||||
is_safe = '[✓' in str(cell_text)
|
||||
is_caution = '[△' in str(cell_text)
|
||||
is_risk = '[✗' in str(cell_text)
|
||||
|
||||
if is_header:
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(9, 'white', True)
|
||||
elif is_first_col:
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(9.5, 'primary-navy', True)
|
||||
elif is_safe:
|
||||
self._font(9.5, 'badge-safe', True)
|
||||
elif is_caution:
|
||||
self._font(9.5, 'badge-caution', True)
|
||||
elif is_risk:
|
||||
self._font(9.5, 'badge-risk', True)
|
||||
else:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
|
||||
self._align('center')
|
||||
self.hwp.insert_text(str(cell_text))
|
||||
|
||||
if not (row_idx == rows - 1 and col_idx == cols - 1):
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_qa_grid(self, elem):
|
||||
"""qa-grid 변환 (Q&A 2단 박스)"""
|
||||
items = elem.find_all(class_="qa-item")
|
||||
if not items:
|
||||
return
|
||||
|
||||
print(f" → qa-grid: {len(items)} items")
|
||||
|
||||
self.hwp.create_table(1, 2, treat_as_char=True)
|
||||
|
||||
for i, item in enumerate(items[:2]):
|
||||
if i > 0:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
self._set_cell_bg('bg-light')
|
||||
|
||||
text = item.get_text(strip=True)
|
||||
strong = item.find("strong")
|
||||
if strong:
|
||||
q_text = strong.get_text(strip=True)
|
||||
a_text = text.replace(q_text, '').strip()
|
||||
|
||||
self._font(9.5, 'primary-navy', True)
|
||||
self.hwp.insert_text(q_text)
|
||||
self.hwp.BreakPara()
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(a_text)
|
||||
else:
|
||||
self._font(9.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(text)
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_bottom_box(self, elem):
|
||||
"""bottom-box 변환 (핵심 결론 박스)"""
|
||||
left = elem.find(class_="bottom-left")
|
||||
right = elem.find(class_="bottom-right")
|
||||
|
||||
if not left or not right:
|
||||
return
|
||||
|
||||
left_text = ' '.join(left.get_text().split())
|
||||
right_text = right.get_text(strip=True)
|
||||
print(f" → bottom-box")
|
||||
|
||||
self.hwp.create_table(1, 2, treat_as_char=True)
|
||||
|
||||
# 좌측 (Navy 배경)
|
||||
self._set_cell_bg('primary-navy')
|
||||
self._font(10.5, 'white', True)
|
||||
self._align('center')
|
||||
self.hwp.insert_text(left_text)
|
||||
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 우측 (연한 배경)
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(10.5, 'primary-navy', True)
|
||||
self._align('center')
|
||||
self.hwp.insert_text(right_text)
|
||||
|
||||
self._exit_table()
|
||||
|
||||
def _convert_section(self, section):
|
||||
"""section 변환"""
|
||||
title = section.find(class_="section-title")
|
||||
if title:
|
||||
self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
|
||||
|
||||
strategy_grid = section.find(class_="strategy-grid")
|
||||
if strategy_grid:
|
||||
self._convert_strategy_grid(strategy_grid)
|
||||
|
||||
process = section.find(class_="process-container")
|
||||
if process:
|
||||
self._convert_process_container(process)
|
||||
|
||||
table = section.find("table", class_="data-table")
|
||||
if table:
|
||||
self._convert_data_table(table)
|
||||
|
||||
ul = section.find("ul")
|
||||
if ul:
|
||||
for li in ul.find_all("li", recursive=False):
|
||||
keyword = li.find(class_="keyword")
|
||||
if keyword:
|
||||
kw_text = keyword.get_text(strip=True)
|
||||
full = li.get_text(strip=True)
|
||||
rest = full.replace(kw_text, '', 1).strip()
|
||||
|
||||
self._font(10.5, 'primary-navy', True)
|
||||
self.hwp.insert_text(" • " + kw_text + " ")
|
||||
self._font(10.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(rest)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._para(" • " + li.get_text(strip=True), 10.5, 'dark-gray')
|
||||
|
||||
qa_grid = section.find(class_="qa-grid")
|
||||
if qa_grid:
|
||||
self._convert_qa_grid(qa_grid)
|
||||
|
||||
self._para()
|
||||
|
||||
def _convert_sheet(self, sheet, is_first_page=False, footer_title=""):
|
||||
"""한 페이지(sheet) 변환"""
|
||||
|
||||
# 첫 페이지에서만 머리말/꼬리말 설정
|
||||
if is_first_page:
|
||||
# 머리말: page-header에서 텍스트 추출
|
||||
header = sheet.find(class_="page-header")
|
||||
if header:
|
||||
left = header.find(class_="header-left")
|
||||
right = header.find(class_="header-right")
|
||||
# 우측 텍스트 사용 (부서명 등)
|
||||
header_text = right.get_text(strip=True) if right else ""
|
||||
if header_text:
|
||||
self._create_header(header_text)
|
||||
|
||||
# 꼬리말: 제목 + 페이지번호
|
||||
self._create_footer(footer_title)
|
||||
|
||||
# 대제목
|
||||
title = sheet.find(class_="header-title")
|
||||
if title:
|
||||
title_text = title.get_text(strip=True)
|
||||
if '[첨부]' in title_text:
|
||||
self._para(title_text, 15, 'primary-navy', True, 'left')
|
||||
self._font(10, 'secondary-navy', False)
|
||||
self._align('left')
|
||||
self.hwp.insert_text("─" * 60)
|
||||
self.hwp.BreakPara()
|
||||
else:
|
||||
self._para(title_text, 23, 'primary-navy', True, 'center')
|
||||
self._font(10, 'secondary-navy', False)
|
||||
self._align('center')
|
||||
self.hwp.insert_text("━" * 45)
|
||||
self.hwp.BreakPara()
|
||||
|
||||
self._para()
|
||||
|
||||
# 리드 박스
|
||||
lead_box = sheet.find(class_="lead-box")
|
||||
if lead_box:
|
||||
self._convert_lead_box(lead_box)
|
||||
self._para()
|
||||
|
||||
# 섹션들
|
||||
for section in sheet.find_all(class_="section"):
|
||||
self._convert_section(section)
|
||||
|
||||
# 하단 박스
|
||||
bottom_box = sheet.find(class_="bottom-box")
|
||||
if bottom_box:
|
||||
self._para()
|
||||
self._convert_bottom_box(bottom_box)
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 메인 변환 함수
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
def convert(self, html_path, output_path):
|
||||
"""HTML → HWP 변환 실행"""
|
||||
|
||||
print("=" * 60)
|
||||
print("HTML → HWP 변환기 (기획서 전용)")
|
||||
print(" ✓ 머리말/꼬리말: 보고서 방식")
|
||||
print(" ✓ Navy 테마, 기획서 요소")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n[입력] {html_path}")
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
# 제목 추출 (꼬리말용)
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
full_title = title_tag.get_text(strip=True)
|
||||
footer_title = full_title.split(':')[0].strip()
|
||||
else:
|
||||
footer_title = ""
|
||||
|
||||
self.hwp.FileNew()
|
||||
self._init_colors()
|
||||
self._setup_page()
|
||||
|
||||
# 페이지별 변환
|
||||
sheets = soup.find_all(class_="sheet")
|
||||
total = len(sheets)
|
||||
print(f"[변환] 총 {total} 페이지\n")
|
||||
|
||||
for i, sheet in enumerate(sheets, 1):
|
||||
print(f"[{i}/{total}] 페이지 처리 중...")
|
||||
self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title)
|
||||
|
||||
if i < total:
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
|
||||
# 저장
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"\n✅ 저장 완료: {output_path}")
|
||||
|
||||
def close(self):
|
||||
"""HWP 종료"""
|
||||
try:
|
||||
self.hwp.Quit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 실행"""
|
||||
|
||||
html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html"
|
||||
output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp"
|
||||
|
||||
print("=" * 60)
|
||||
print("HTML → HWP 변환기 (기획서)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
converter = HtmlToHwpConverter(visible=True)
|
||||
converter.convert(html_path, output_path)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 변환 완료!")
|
||||
print("=" * 60)
|
||||
|
||||
input("\nEnter를 누르면 HWP가 닫힙니다...")
|
||||
converter.close()
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}")
|
||||
print("경로를 확인해주세요.")
|
||||
except Exception as e:
|
||||
print(f"\n[에러] {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
434
03. Code/geulbeot_8th/converters/hwp_style_mapping.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HWP 스타일 매핑 모듈 v2.0
|
||||
HTML 역할(Role) → HWP 스타일 매핑
|
||||
|
||||
✅ v2.0 변경사항:
|
||||
- pyhwpx API에 맞게 apply_to_hwp() 재작성
|
||||
- CharShape/ParaShape 직접 설정 방식
|
||||
- 역할 → 개요 스타일 매핑
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class HwpStyleType(Enum):
|
||||
"""HWP 스타일 유형"""
|
||||
PARAGRAPH = "paragraph"
|
||||
CHARACTER = "character"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HwpStyle:
|
||||
"""HWP 스타일 정의"""
|
||||
id: int
|
||||
name: str
|
||||
type: HwpStyleType
|
||||
font_size: float
|
||||
font_bold: bool = False
|
||||
font_color: str = "000000"
|
||||
align: str = "justify"
|
||||
line_spacing: float = 160
|
||||
space_before: float = 0
|
||||
space_after: float = 0
|
||||
indent_left: float = 0
|
||||
indent_first: float = 0
|
||||
bg_color: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 기본 스타일 템플릿
|
||||
# =============================================================================
|
||||
DEFAULT_STYLES: Dict[str, HwpStyle] = {
|
||||
# 표지
|
||||
"COVER_TITLE": HwpStyle(
|
||||
id=100, name="표지제목", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=32, font_bold=True, align="center",
|
||||
space_before=20, space_after=10, font_color="1a365d"
|
||||
),
|
||||
"COVER_SUBTITLE": HwpStyle(
|
||||
id=101, name="표지부제", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=18, font_bold=False, align="center",
|
||||
font_color="555555"
|
||||
),
|
||||
"COVER_INFO": HwpStyle(
|
||||
id=102, name="표지정보", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, align="center", font_color="666666"
|
||||
),
|
||||
|
||||
# 목차
|
||||
"TOC_H1": HwpStyle(
|
||||
id=110, name="목차1수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, font_bold=True, indent_left=0
|
||||
),
|
||||
"TOC_H2": HwpStyle(
|
||||
id=111, name="목차2수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, indent_left=20
|
||||
),
|
||||
"TOC_H3": HwpStyle(
|
||||
id=112, name="목차3수준", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, indent_left=40, font_color="666666"
|
||||
),
|
||||
|
||||
# 제목 계층 (개요 1~7 매핑)
|
||||
"H1": HwpStyle(
|
||||
id=1, name="개요 1", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=20, font_bold=True, align="left",
|
||||
space_before=30, space_after=15, font_color="1a365d"
|
||||
),
|
||||
"H2": HwpStyle(
|
||||
id=2, name="개요 2", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=16, font_bold=True, align="left",
|
||||
space_before=20, space_after=10, font_color="2c5282"
|
||||
),
|
||||
"H3": HwpStyle(
|
||||
id=3, name="개요 3", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=14, font_bold=True, align="left",
|
||||
space_before=15, space_after=8, font_color="2b6cb0"
|
||||
),
|
||||
"H4": HwpStyle(
|
||||
id=4, name="개요 4", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=12, font_bold=True, align="left",
|
||||
space_before=10, space_after=5, indent_left=10
|
||||
),
|
||||
"H5": HwpStyle(
|
||||
id=5, name="개요 5", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, font_bold=True, align="left",
|
||||
space_before=8, space_after=4, indent_left=20
|
||||
),
|
||||
"H6": HwpStyle(
|
||||
id=6, name="개요 6", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, font_bold=False, align="left",
|
||||
indent_left=30
|
||||
),
|
||||
"H7": HwpStyle(
|
||||
id=7, name="개요 7", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10.5, font_bold=False, align="left",
|
||||
indent_left=40
|
||||
),
|
||||
|
||||
# 본문
|
||||
"BODY": HwpStyle(
|
||||
id=20, name="바탕글", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, align="justify",
|
||||
line_spacing=180, indent_first=10
|
||||
),
|
||||
"LIST_ITEM": HwpStyle(
|
||||
id=8, name="개요 8", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=11, align="left",
|
||||
indent_left=15, line_spacing=160
|
||||
),
|
||||
"HIGHLIGHT_BOX": HwpStyle(
|
||||
id=21, name="강조박스", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10.5, align="left",
|
||||
bg_color="f7fafc", indent_left=10, indent_first=0
|
||||
),
|
||||
|
||||
# 표
|
||||
"TABLE": HwpStyle(
|
||||
id=30, name="표", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="center"
|
||||
),
|
||||
"TH": HwpStyle(
|
||||
id=11, name="표제목", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, font_bold=True, align="center",
|
||||
bg_color="e2e8f0"
|
||||
),
|
||||
"TD": HwpStyle(
|
||||
id=31, name="표내용", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="left"
|
||||
),
|
||||
"TABLE_CAPTION": HwpStyle(
|
||||
id=19, name="표캡션", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, font_bold=True, align="center",
|
||||
space_before=5, space_after=3
|
||||
),
|
||||
|
||||
# 그림
|
||||
"FIGURE": HwpStyle(
|
||||
id=32, name="그림", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="center"
|
||||
),
|
||||
"FIGURE_CAPTION": HwpStyle(
|
||||
id=18, name="그림캡션", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=9.5, align="center",
|
||||
font_color="666666", space_before=5
|
||||
),
|
||||
|
||||
# 기타
|
||||
"UNKNOWN": HwpStyle(
|
||||
id=0, name="바탕글", type=HwpStyleType.PARAGRAPH,
|
||||
font_size=10, align="left"
|
||||
),
|
||||
}
|
||||
|
||||
# 역할 → 개요 번호 매핑 (StyleShortcut 용)
|
||||
ROLE_TO_OUTLINE_NUM = {
|
||||
"H1": 1,
|
||||
"H2": 2,
|
||||
"H3": 3,
|
||||
"H4": 4,
|
||||
"H5": 5,
|
||||
"H6": 6,
|
||||
"H7": 7,
|
||||
"LIST_ITEM": 8,
|
||||
"BODY": 0, # 바탕글
|
||||
"COVER_TITLE": 0,
|
||||
"COVER_SUBTITLE": 0,
|
||||
"COVER_INFO": 0,
|
||||
}
|
||||
|
||||
# 역할 → HWP 스타일 이름 매핑
|
||||
ROLE_TO_STYLE_NAME = {
|
||||
"H1": "개요 1",
|
||||
"H2": "개요 2",
|
||||
"H3": "개요 3",
|
||||
"H4": "개요 4",
|
||||
"H5": "개요 5",
|
||||
"H6": "개요 6",
|
||||
"H7": "개요 7",
|
||||
"LIST_ITEM": "개요 8",
|
||||
"BODY": "바탕글",
|
||||
"COVER_TITLE": "표지제목",
|
||||
"COVER_SUBTITLE": "표지부제",
|
||||
"TH": "표제목",
|
||||
"TD": "표내용",
|
||||
"TABLE_CAPTION": "표캡션",
|
||||
"FIGURE_CAPTION": "그림캡션",
|
||||
"UNKNOWN": "바탕글",
|
||||
}
|
||||
|
||||
|
||||
class HwpStyleMapper:
|
||||
"""HTML 역할 → HWP 스타일 매퍼"""
|
||||
|
||||
def __init__(self, custom_styles: Optional[Dict[str, HwpStyle]] = None):
|
||||
self.styles = DEFAULT_STYLES.copy()
|
||||
if custom_styles:
|
||||
self.styles.update(custom_styles)
|
||||
|
||||
def get_style(self, role: str) -> HwpStyle:
|
||||
return self.styles.get(role, self.styles["UNKNOWN"])
|
||||
|
||||
def get_style_id(self, role: str) -> int:
|
||||
return self.get_style(role).id
|
||||
|
||||
def get_all_styles(self) -> Dict[str, HwpStyle]:
|
||||
return self.styles
|
||||
|
||||
|
||||
class HwpStyGenerator:
|
||||
"""
|
||||
HTML 스타일 → HWP 스타일 적용기
|
||||
|
||||
pyhwpx API를 사용하여:
|
||||
1. 역할별 스타일 정보 저장
|
||||
2. 텍스트 삽입 시 CharShape/ParaShape 직접 적용
|
||||
3. 개요 스타일 번호 매핑 반환
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.styles: Dict[str, HwpStyle] = {}
|
||||
self.hwp = None
|
||||
|
||||
def update_from_html(self, html_styles: Dict[str, Dict]):
|
||||
"""HTML에서 추출한 스타일로 업데이트"""
|
||||
for role, style_dict in html_styles.items():
|
||||
if role in DEFAULT_STYLES:
|
||||
base = DEFAULT_STYLES[role]
|
||||
|
||||
# color 처리 - # 제거
|
||||
color = style_dict.get('color', base.font_color)
|
||||
if isinstance(color, str):
|
||||
color = color.lstrip('#')
|
||||
|
||||
self.styles[role] = HwpStyle(
|
||||
id=base.id,
|
||||
name=base.name,
|
||||
type=base.type,
|
||||
font_size=style_dict.get('font_size', base.font_size),
|
||||
font_bold=style_dict.get('bold', base.font_bold),
|
||||
font_color=color,
|
||||
align=style_dict.get('align', base.align),
|
||||
line_spacing=style_dict.get('line_spacing', base.line_spacing),
|
||||
space_before=style_dict.get('space_before', base.space_before),
|
||||
space_after=style_dict.get('space_after', base.space_after),
|
||||
indent_left=style_dict.get('indent_left', base.indent_left),
|
||||
indent_first=style_dict.get('indent_first', base.indent_first),
|
||||
bg_color=style_dict.get('bg_color', base.bg_color),
|
||||
)
|
||||
else:
|
||||
# 기본 스타일 사용
|
||||
self.styles[role] = DEFAULT_STYLES.get('UNKNOWN')
|
||||
|
||||
# 누락된 역할은 기본값으로 채움
|
||||
for role in DEFAULT_STYLES:
|
||||
if role not in self.styles:
|
||||
self.styles[role] = DEFAULT_STYLES[role]
|
||||
|
||||
def apply_to_hwp(self, hwp) -> Dict[str, HwpStyle]:
|
||||
"""역할 → HwpStyle 매핑 반환"""
|
||||
self.hwp = hwp
|
||||
|
||||
# 🚫 스타일 생성 비활성화 (API 문제)
|
||||
# for role, style in self.styles.items():
|
||||
# self._create_or_update_style(hwp, role, style)
|
||||
|
||||
if not self.styles:
|
||||
self.styles = DEFAULT_STYLES.copy()
|
||||
|
||||
print(f" ✅ 스타일 매핑 완료: {len(self.styles)}개")
|
||||
return self.styles
|
||||
|
||||
def _create_or_update_style(self, hwp, role: str, style: HwpStyle):
|
||||
"""HWP에 스타일 생성 또는 수정"""
|
||||
try:
|
||||
# 1. 스타일 편집 모드
|
||||
hwp.HAction.GetDefault("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
|
||||
hwp.HParameterSet.HStyle.StyleName = style.name
|
||||
|
||||
# 2. 글자 모양
|
||||
color_hex = style.font_color.lstrip('#')
|
||||
if len(color_hex) == 6:
|
||||
r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16)
|
||||
text_color = hwp.RGBColor(r, g, b)
|
||||
else:
|
||||
text_color = hwp.RGBColor(0, 0, 0)
|
||||
|
||||
hwp.HParameterSet.HStyle.CharShape.Height = hwp.PointToHwpUnit(style.font_size)
|
||||
hwp.HParameterSet.HStyle.CharShape.Bold = style.font_bold
|
||||
hwp.HParameterSet.HStyle.CharShape.TextColor = text_color
|
||||
|
||||
# 3. 문단 모양
|
||||
align_map = {'left': 0, 'center': 1, 'right': 2, 'justify': 3}
|
||||
hwp.HParameterSet.HStyle.ParaShape.Align = align_map.get(style.align, 3)
|
||||
hwp.HParameterSet.HStyle.ParaShape.LineSpacing = int(style.line_spacing)
|
||||
hwp.HParameterSet.HStyle.ParaShape.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
|
||||
hwp.HParameterSet.HStyle.ParaShape.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
|
||||
|
||||
# 4. 실행
|
||||
hwp.HAction.Execute("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
|
||||
print(f" ✓ 스타일 '{style.name}' 정의됨")
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 스타일 '{style.name}' 생성 실패: {e}")
|
||||
|
||||
def get_style(self, role: str) -> HwpStyle:
|
||||
"""역할에 해당하는 스타일 반환"""
|
||||
return self.styles.get(role, DEFAULT_STYLES.get('UNKNOWN'))
|
||||
|
||||
def apply_char_shape(self, hwp, role: str):
|
||||
"""현재 선택 영역에 글자 모양 적용"""
|
||||
style = self.get_style(role)
|
||||
|
||||
try:
|
||||
# RGB 색상 변환
|
||||
color_hex = style.font_color.lstrip('#') if style.font_color else '000000'
|
||||
if len(color_hex) == 6:
|
||||
r = int(color_hex[0:2], 16)
|
||||
g = int(color_hex[2:4], 16)
|
||||
b = int(color_hex[4:6], 16)
|
||||
text_color = hwp.RGBColor(r, g, b)
|
||||
else:
|
||||
text_color = hwp.RGBColor(0, 0, 0)
|
||||
|
||||
# 글자 모양 설정
|
||||
hwp.HAction.GetDefault("CharShape", hwp.HParameterSet.HCharShape.HSet)
|
||||
hwp.HParameterSet.HCharShape.Height = hwp.PointToHwpUnit(style.font_size)
|
||||
hwp.HParameterSet.HCharShape.Bold = style.font_bold
|
||||
hwp.HParameterSet.HCharShape.TextColor = text_color
|
||||
hwp.HAction.Execute("CharShape", hwp.HParameterSet.HCharShape.HSet)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 글자 모양 적용 실패 ({role}): {e}")
|
||||
|
||||
def apply_para_shape(self, hwp, role: str):
|
||||
"""현재 문단에 문단 모양 적용"""
|
||||
style = self.get_style(role)
|
||||
|
||||
try:
|
||||
# 정렬
|
||||
align_actions = {
|
||||
'left': "ParagraphShapeAlignLeft",
|
||||
'center': "ParagraphShapeAlignCenter",
|
||||
'right': "ParagraphShapeAlignRight",
|
||||
'justify': "ParagraphShapeAlignJustify"
|
||||
}
|
||||
if style.align in align_actions:
|
||||
hwp.HAction.Run(align_actions[style.align])
|
||||
|
||||
# 문단 모양 상세 설정
|
||||
hwp.HAction.GetDefault("ParagraphShape", hwp.HParameterSet.HParaShape.HSet)
|
||||
p = hwp.HParameterSet.HParaShape
|
||||
p.LineSpaceType = 0 # 퍼센트
|
||||
p.LineSpacing = int(style.line_spacing)
|
||||
p.LeftMargin = hwp.MiliToHwpUnit(style.indent_left)
|
||||
p.IndentMargin = hwp.MiliToHwpUnit(style.indent_first)
|
||||
p.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
|
||||
p.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
|
||||
hwp.HAction.Execute("ParagraphShape", p.HSet)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [경고] 문단 모양 적용 실패 ({role}): {e}")
|
||||
|
||||
def apply_style(self, hwp, role: str):
|
||||
"""역할에 맞는 전체 스타일 적용 (글자 + 문단)"""
|
||||
self.apply_char_shape(hwp, role)
|
||||
self.apply_para_shape(hwp, role)
|
||||
|
||||
def export_sty(self, hwp, output_path: str) -> bool:
|
||||
"""스타일 파일 내보내기 (현재 미지원)"""
|
||||
print(f" [알림] .sty 내보내기는 현재 미지원")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 번호 제거 유틸리티
|
||||
# =============================================================================
|
||||
import re
|
||||
|
||||
NUMBERING_PATTERNS = {
|
||||
'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → ""
|
||||
'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → ""
|
||||
'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → ""
|
||||
'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → ""
|
||||
'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → ""
|
||||
'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → ""
|
||||
'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → ""
|
||||
'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → ""
|
||||
}
|
||||
|
||||
def strip_numbering(text: str, role: str) -> str:
|
||||
"""
|
||||
역할에 따라 텍스트 앞의 번호/기호 제거
|
||||
HWP 개요 기능이 번호를 자동 생성하므로 중복 방지
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
pattern = NUMBERING_PATTERNS.get(role)
|
||||
if pattern:
|
||||
return pattern.sub('', text).strip()
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
print("=== 스타일 매핑 테스트 ===")
|
||||
|
||||
gen = HwpStyGenerator()
|
||||
|
||||
# HTML 스타일 시뮬레이션
|
||||
html_styles = {
|
||||
'H1': {'font_size': 20, 'color': '#1a365d', 'bold': True},
|
||||
'H2': {'font_size': 16, 'color': '#2c5282', 'bold': True},
|
||||
'BODY': {'font_size': 11, 'align': 'justify'},
|
||||
}
|
||||
|
||||
gen.update_from_html(html_styles)
|
||||
|
||||
for role, style in gen.styles.items():
|
||||
print(f"{role:15} → size={style.font_size}pt, bold={style.font_bold}, color=#{style.font_color}")
|
||||
431
03. Code/geulbeot_8th/converters/hwpx_generator.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
HWPX 파일 생성기
|
||||
StyleAnalyzer 결과를 받아 스타일이 적용된 HWPX 파일 생성
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from style_analyzer import StyleAnalyzer, StyledElement
|
||||
from hwp_style_mapping import HwpStyleMapper, HwpStyle, ROLE_TO_STYLE_NAME
|
||||
|
||||
|
||||
@dataclass
|
||||
class HwpxConfig:
|
||||
"""HWPX 생성 설정"""
|
||||
paper_width: int = 59528 # A4 너비 (hwpunit, 1/7200 inch)
|
||||
paper_height: int = 84188 # A4 높이
|
||||
margin_left: int = 8504
|
||||
margin_right: int = 8504
|
||||
margin_top: int = 5668
|
||||
margin_bottom: int = 4252
|
||||
default_font: str = "함초롬바탕"
|
||||
default_font_size: int = 1000 # 10pt (hwpunit)
|
||||
|
||||
|
||||
class HwpxGenerator:
|
||||
"""HWPX 파일 생성기"""
|
||||
|
||||
def __init__(self, config: Optional[HwpxConfig] = None):
|
||||
self.config = config or HwpxConfig()
|
||||
self.mapper = HwpStyleMapper()
|
||||
self.used_styles: set = set()
|
||||
|
||||
def generate(self, elements: List[StyledElement], output_path: str) -> str:
|
||||
"""
|
||||
StyledElement 리스트로부터 HWPX 파일 생성
|
||||
|
||||
Args:
|
||||
elements: StyleAnalyzer로 분류된 요소 리스트
|
||||
output_path: 출력 파일 경로 (.hwpx)
|
||||
|
||||
Returns:
|
||||
생성된 파일 경로
|
||||
"""
|
||||
# 사용된 스타일 수집
|
||||
self.used_styles = {e.role for e in elements}
|
||||
|
||||
# 임시 디렉토리 생성
|
||||
temp_dir = Path(output_path).with_suffix('.temp')
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# HWPX 구조 생성
|
||||
self._create_mimetype(temp_dir)
|
||||
self._create_meta_inf(temp_dir)
|
||||
self._create_version(temp_dir)
|
||||
self._create_header(temp_dir)
|
||||
self._create_content(temp_dir, elements)
|
||||
self._create_settings(temp_dir)
|
||||
|
||||
# ZIP으로 압축
|
||||
self._create_hwpx(temp_dir, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
finally:
|
||||
# 임시 파일 정리
|
||||
import shutil
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def _create_mimetype(self, temp_dir: Path):
|
||||
"""mimetype 파일 생성"""
|
||||
mimetype_path = temp_dir / "mimetype"
|
||||
mimetype_path.write_text("application/hwp+zip")
|
||||
|
||||
def _create_meta_inf(self, temp_dir: Path):
|
||||
"""META-INF/manifest.xml 생성"""
|
||||
meta_dir = temp_dir / "META-INF"
|
||||
meta_dir.mkdir(exist_ok=True)
|
||||
|
||||
manifest = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
|
||||
<manifest:file-entry manifest:full-path="version.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
|
||||
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="application/xml"/>
|
||||
</manifest:manifest>"""
|
||||
|
||||
(meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8')
|
||||
|
||||
def _create_version(self, temp_dir: Path):
|
||||
"""version.xml 생성"""
|
||||
version = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hh:HWPMLVersion xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" version="1.1"/>"""
|
||||
|
||||
(temp_dir / "version.xml").write_text(version, encoding='utf-8')
|
||||
|
||||
def _create_header(self, temp_dir: Path):
|
||||
"""Contents/header.xml 생성 (스타일 정의 포함)"""
|
||||
contents_dir = temp_dir / "Contents"
|
||||
contents_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 스타일별 속성 생성
|
||||
char_props_xml = self._generate_char_properties()
|
||||
para_props_xml = self._generate_para_properties()
|
||||
styles_xml = self._generate_styles_xml()
|
||||
|
||||
header = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"
|
||||
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"
|
||||
xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
|
||||
version="1.5" secCnt="1">
|
||||
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
|
||||
<hh:refList>
|
||||
<hh:fontfaces itemCnt="7">
|
||||
<hh:fontface lang="HANGUL" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="LATIN" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="HANJA" fontCnt="2">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="JAPANESE" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="OTHER" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="SYMBOL" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="USER" fontCnt="1">
|
||||
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
|
||||
</hh:fontface>
|
||||
</hh:fontfaces>
|
||||
<hh:borderFills itemCnt="2">
|
||||
<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE">
|
||||
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
</hh:borderFill>
|
||||
<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE">
|
||||
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
|
||||
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
|
||||
<hc:fillBrush><hc:winBrush faceColor="none" hatchColor="#000000" alpha="0"/></hc:fillBrush>
|
||||
</hh:borderFill>
|
||||
</hh:borderFills>
|
||||
{char_props_xml}
|
||||
{para_props_xml}
|
||||
{styles_xml}
|
||||
</hh:refList>
|
||||
<hh:compatibleDocument targetProgram="HWP201X"/>
|
||||
<hh:docOption>
|
||||
<hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/>
|
||||
</hh:docOption>
|
||||
</hh:head>"""
|
||||
|
||||
(contents_dir / "header.xml").write_text(header, encoding='utf-8')
|
||||
|
||||
def _generate_char_properties(self) -> str:
|
||||
"""글자 속성 XML 생성"""
|
||||
lines = [f' <hh:charProperties itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 글자 속성 (id=0)
|
||||
lines.append(''' <hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
|
||||
<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
|
||||
<hh:strikeout shape="NONE" color="#000000"/>
|
||||
<hh:outline type="NONE"/>
|
||||
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
|
||||
</hh:charPr>''')
|
||||
|
||||
# 역할별 글자 속성
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
height = int(style.font_size * 100) # pt → hwpunit
|
||||
color = style.font_color.lstrip('#')
|
||||
font_id = "1" if style.font_bold else "0" # 굵게면 함초롬돋움
|
||||
|
||||
lines.append(f''' <hh:charPr id="{idx}" height="{height}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
|
||||
<hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/>
|
||||
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
|
||||
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
|
||||
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
|
||||
<hh:strikeout shape="NONE" color="#000000"/>
|
||||
<hh:outline type="NONE"/>
|
||||
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
|
||||
</hh:charPr>''')
|
||||
|
||||
lines.append(' </hh:charProperties>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _generate_para_properties(self) -> str:
|
||||
"""문단 속성 XML 생성"""
|
||||
lines = [f' <hh:paraProperties itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 문단 속성 (id=0)
|
||||
lines.append(''' <hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
|
||||
<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>
|
||||
<hh:heading type="NONE" idRef="0" level="0"/>
|
||||
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
|
||||
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
|
||||
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
|
||||
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
|
||||
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
|
||||
</hp:case>
|
||||
<hp:default>
|
||||
<hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/>
|
||||
</hp:default>
|
||||
</hp:switch>
|
||||
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
|
||||
</hh:paraPr>''')
|
||||
|
||||
# 역할별 문단 속성
|
||||
align_map = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", "justify": "JUSTIFY"}
|
||||
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
align_val = align_map.get(style.align, "JUSTIFY")
|
||||
line_spacing = int(style.line_spacing)
|
||||
left_margin = int(style.indent_left * 100)
|
||||
indent = int(style.indent_first * 100)
|
||||
space_before = int(style.space_before * 100)
|
||||
space_after = int(style.space_after * 100)
|
||||
|
||||
lines.append(f''' <hh:paraPr id="{idx}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
|
||||
<hh:align horizontal="{align_val}" vertical="BASELINE"/>
|
||||
<hh:heading type="NONE" idRef="0" level="0"/>
|
||||
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
|
||||
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
|
||||
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
|
||||
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
|
||||
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
|
||||
</hp:case>
|
||||
<hp:default>
|
||||
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
|
||||
</hp:default>
|
||||
</hp:switch>
|
||||
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
|
||||
</hh:paraPr>''')
|
||||
|
||||
lines.append(' </hh:paraProperties>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _generate_styles_xml(self) -> str:
|
||||
"""스타일 정의 XML 생성 (charPrIDRef, paraPrIDRef 참조)"""
|
||||
lines = [f' <hh:styles itemCnt="{len(self.used_styles) + 1}">']
|
||||
|
||||
# 기본 스타일 (id=0, 바탕글)
|
||||
lines.append(' <hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>')
|
||||
|
||||
# 역할별 스타일 (charPrIDRef, paraPrIDRef 참조)
|
||||
for idx, role in enumerate(sorted(self.used_styles), start=1):
|
||||
style = self.mapper.get_style(role)
|
||||
style_name = style.name.replace('<', '<').replace('>', '>')
|
||||
|
||||
lines.append(f' <hh:style id="{idx}" type="PARA" name="{style_name}" engName="" paraPrIDRef="{idx}" charPrIDRef="{idx}" nextStyleIDRef="{idx}" langID="1042" lockForm="0"/>')
|
||||
|
||||
lines.append(' </hh:styles>')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _create_content(self, temp_dir: Path, elements: List[StyledElement]):
|
||||
"""Contents/section0.xml 생성 (본문 + 스타일 참조)"""
|
||||
contents_dir = temp_dir / "Contents"
|
||||
|
||||
# 문단 XML 생성
|
||||
paragraphs = []
|
||||
current_table = None
|
||||
|
||||
# 역할 → 스타일 인덱스 매핑 생성
|
||||
role_to_idx = {role: idx for idx, role in enumerate(sorted(self.used_styles), start=1)}
|
||||
|
||||
for elem in elements:
|
||||
style = self.mapper.get_style(elem.role)
|
||||
style_idx = role_to_idx.get(elem.role, 0)
|
||||
|
||||
# 테이블 요소는 특수 처리
|
||||
if elem.role in ["TH", "TD", "TABLE_CAPTION", "TABLE", "FIGURE"]:
|
||||
continue # 테이블/그림은 별도 처리 필요
|
||||
|
||||
# 일반 문단
|
||||
para_xml = self._create_paragraph(elem.text, style, style_idx)
|
||||
paragraphs.append(para_xml)
|
||||
|
||||
section = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"
|
||||
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
|
||||
{"".join(paragraphs)}
|
||||
</hs:sec>"""
|
||||
|
||||
(contents_dir / "section0.xml").write_text(section, encoding='utf-8')
|
||||
|
||||
def _create_paragraph(self, text: str, style: HwpStyle, style_idx: int) -> str:
|
||||
"""단일 문단 XML 생성"""
|
||||
text = self._escape_xml(text)
|
||||
|
||||
return f'''
|
||||
<hp:p xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
|
||||
paraPrIDRef="{style_idx}" styleIDRef="{style_idx}" pageBreak="0" columnBreak="0" merged="0">
|
||||
<hp:run charPrIDRef="{style_idx}">
|
||||
<hp:t>{text}</hp:t>
|
||||
</hp:run>
|
||||
</hp:p>'''
|
||||
|
||||
def _escape_xml(self, text: str) -> str:
|
||||
"""XML 특수문자 이스케이프"""
|
||||
return (text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'"))
|
||||
|
||||
def _create_settings(self, temp_dir: Path):
|
||||
"""settings.xml 생성"""
|
||||
settings = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hs:settings xmlns:hs="http://www.hancom.co.kr/hwpml/2011/settings">
|
||||
<hs:viewSetting>
|
||||
<hs:viewType val="printView"/>
|
||||
<hs:zoom val="100"/>
|
||||
</hs:viewSetting>
|
||||
</hs:settings>"""
|
||||
|
||||
(temp_dir / "settings.xml").write_text(settings, encoding='utf-8')
|
||||
|
||||
def _create_hwpx(self, temp_dir: Path, output_path: str):
|
||||
"""HWPX 파일 생성 (ZIP 압축)"""
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축하지 않고 첫 번째로
|
||||
mimetype_path = temp_dir / "mimetype"
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
|
||||
def convert_html_to_hwpx(html: str, output_path: str) -> str:
|
||||
"""
|
||||
HTML → HWPX 변환 메인 함수
|
||||
|
||||
Args:
|
||||
html: HTML 문자열
|
||||
output_path: 출력 파일 경로
|
||||
|
||||
Returns:
|
||||
생성된 파일 경로
|
||||
"""
|
||||
# 1. HTML 분석 → 역할 분류
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(html)
|
||||
|
||||
print(f"📊 분석 완료: {len(elements)}개 요소")
|
||||
for role, count in analyzer.get_role_summary().items():
|
||||
print(f" {role}: {count}")
|
||||
|
||||
# 2. HWPX 생성
|
||||
generator = HwpxGenerator()
|
||||
result_path = generator.generate(elements, output_path)
|
||||
|
||||
print(f"✅ 생성 완료: {result_path}")
|
||||
return result_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
test_html = """
|
||||
<html>
|
||||
<body>
|
||||
<div class="box-cover">
|
||||
<h1>건설·토목 측량 DX 실무지침</h1>
|
||||
<h2>드론/UAV·GIS·지형/지반 모델 기반</h2>
|
||||
<p>2024년 1월</p>
|
||||
</div>
|
||||
|
||||
<h1>1. 개요</h1>
|
||||
<p>본 보고서는 건설 및 토목 분야의 측량 디지털 전환에 대한 실무 지침을 제공합니다.</p>
|
||||
|
||||
<h2>1.1 배경</h2>
|
||||
<p>최근 드론과 GIS 기술의 발전으로 측량 업무가 크게 변화하고 있습니다.</p>
|
||||
|
||||
<h3>1.1.1 기술 동향</h3>
|
||||
<p>1) <strong>드론 측량의 발전</strong></p>
|
||||
<p>드론을 활용한 측량은 기존 방식 대비 효율성이 크게 향상되었습니다.</p>
|
||||
|
||||
<p>(1) <strong>RTK 드론</strong></p>
|
||||
<p>실시간 보정 기능을 갖춘 RTK 드론이 보급되고 있습니다.</p>
|
||||
|
||||
<ul>
|
||||
<li>고정밀 GPS 수신기 내장</li>
|
||||
<li>센티미터 단위 정확도</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
output = "/home/claude/test_output.hwpx"
|
||||
convert_html_to_hwpx(test_html, output)
|
||||
750
03. Code/geulbeot_8th/converters/hwpx_style_injector.py
Normal file
@@ -0,0 +1,750 @@
|
||||
"""
|
||||
HWPX 스타일 주입기
|
||||
pyhwpx로 생성된 HWPX 파일에 커스텀 스타일을 후처리로 주입
|
||||
|
||||
워크플로우:
|
||||
1. HWPX 압축 해제
|
||||
2. header.xml에 커스텀 스타일 정의 추가
|
||||
3. section*.xml에서 역할별 styleIDRef 매핑
|
||||
4. 다시 압축
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StyleDefinition:
|
||||
"""스타일 정의"""
|
||||
id: int
|
||||
name: str
|
||||
font_size: int # hwpunit (pt * 100)
|
||||
font_bold: bool
|
||||
font_color: str # #RRGGBB
|
||||
align: str # LEFT, CENTER, RIGHT, JUSTIFY
|
||||
line_spacing: int # percent (160 = 160%)
|
||||
indent_left: int # hwpunit
|
||||
indent_first: int # hwpunit
|
||||
space_before: int # hwpunit
|
||||
space_after: int # hwpunit
|
||||
outline_level: int = -1 # 🆕 개요 수준 (-1=없음, 0=1수준, 1=2수준, ...)
|
||||
|
||||
|
||||
# 역할 → 스타일 정의 매핑
|
||||
ROLE_STYLES: Dict[str, StyleDefinition] = {
|
||||
# 🆕 개요 문단 (자동 번호 매기기!)
|
||||
'H1': StyleDefinition(
|
||||
id=101, name='제1장 제목', font_size=2200, font_bold=True,
|
||||
font_color='#006400', align='CENTER', line_spacing=200,
|
||||
indent_left=0, indent_first=0, space_before=400, space_after=200,
|
||||
outline_level=0 # 🆕 제^1장
|
||||
),
|
||||
'H2': StyleDefinition(
|
||||
id=102, name='1.1 제목', font_size=1500, font_bold=True,
|
||||
font_color='#03581d', align='LEFT', line_spacing=200,
|
||||
indent_left=0, indent_first=0, space_before=300, space_after=100,
|
||||
outline_level=1 # 🆕 ^1.^2
|
||||
),
|
||||
'H3': StyleDefinition(
|
||||
id=103, name='1.1.1 제목', font_size=1400, font_bold=True,
|
||||
font_color='#228B22', align='LEFT', line_spacing=200,
|
||||
indent_left=500, indent_first=0, space_before=200, space_after=100,
|
||||
outline_level=2 # 🆕 ^1.^2.^3
|
||||
),
|
||||
'H4': StyleDefinition(
|
||||
id=104, name='가. 제목', font_size=1300, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=1000, indent_first=0, space_before=150, space_after=50,
|
||||
outline_level=3 # 🆕 ^4.
|
||||
),
|
||||
'H5': StyleDefinition(
|
||||
id=105, name='1) 제목', font_size=1200, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=1500, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=4 # 🆕 ^5)
|
||||
),
|
||||
'H6': StyleDefinition(
|
||||
id=106, name='가) 제목', font_size=1150, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=2000, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=5 # 🆕 ^6)
|
||||
),
|
||||
'H7': StyleDefinition(
|
||||
id=115, name='① 제목', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=200,
|
||||
indent_left=2300, indent_first=0, space_before=100, space_after=50,
|
||||
outline_level=6 # 🆕 ^7 (원문자)
|
||||
),
|
||||
# 본문 스타일 (개요 아님)
|
||||
'BODY': StyleDefinition(
|
||||
id=107, name='○본문', font_size=1100, font_bold=False,
|
||||
font_color='#000000', align='JUSTIFY', line_spacing=200,
|
||||
indent_left=1500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
'LIST_ITEM': StyleDefinition(
|
||||
id=108, name='●본문', font_size=1050, font_bold=False,
|
||||
font_color='#000000', align='JUSTIFY', line_spacing=200,
|
||||
indent_left=2500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
'TABLE_CAPTION': StyleDefinition(
|
||||
id=109, name='<표 제목>', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=130,
|
||||
indent_left=0, indent_first=0, space_before=200, space_after=100
|
||||
),
|
||||
'FIGURE_CAPTION': StyleDefinition(
|
||||
id=110, name='<그림 제목>', font_size=1100, font_bold=True,
|
||||
font_color='#000000', align='CENTER', line_spacing=130,
|
||||
indent_left=0, indent_first=0, space_before=100, space_after=200
|
||||
),
|
||||
'COVER_TITLE': StyleDefinition(
|
||||
id=111, name='표지제목', font_size=2800, font_bold=True,
|
||||
font_color='#1a365d', align='CENTER', line_spacing=150,
|
||||
indent_left=0, indent_first=0, space_before=0, space_after=200
|
||||
),
|
||||
'COVER_SUBTITLE': StyleDefinition(
|
||||
id=112, name='표지부제', font_size=1800, font_bold=False,
|
||||
font_color='#2d3748', align='CENTER', line_spacing=150,
|
||||
indent_left=0, indent_first=0, space_before=0, space_after=100
|
||||
),
|
||||
'TOC_1': StyleDefinition(
|
||||
id=113, name='목차1수준', font_size=1200, font_bold=True,
|
||||
font_color='#000000', align='LEFT', line_spacing=180,
|
||||
indent_left=0, indent_first=0, space_before=100, space_after=50
|
||||
),
|
||||
'TOC_2': StyleDefinition(
|
||||
id=114, name='목차2수준', font_size=1100, font_bold=False,
|
||||
font_color='#000000', align='LEFT', line_spacing=180,
|
||||
indent_left=500, indent_first=0, space_before=0, space_after=0
|
||||
),
|
||||
}
|
||||
|
||||
# ⚠️ 개요 자동 번호 기능 활성화!
|
||||
# idRef="0"은 numbering id=1을 참조하므로, 해당 패턴을 교체하면 동작함
|
||||
|
||||
|
||||
class HwpxStyleInjector:
|
||||
"""HWPX 스타일 주입기"""
|
||||
|
||||
def __init__(self):
|
||||
self.temp_dir: Optional[Path] = None
|
||||
self.role_to_style_id: Dict[str, int] = {}
|
||||
self.role_to_para_id: Dict[str, int] = {} # 🆕
|
||||
self.role_to_char_id: Dict[str, int] = {} # 🆕
|
||||
self.next_char_id = 0
|
||||
self.next_para_id = 0
|
||||
self.next_style_id = 0
|
||||
|
||||
def _find_max_ids(self):
|
||||
"""기존 스타일 교체: 바탕글(id=0)만 유지, 나머지는 우리 스타일로 교체"""
|
||||
header_path = self.temp_dir / "Contents" / "header.xml"
|
||||
if not header_path.exists():
|
||||
self.next_char_id = 1
|
||||
self.next_para_id = 1
|
||||
self.next_style_id = 1
|
||||
return
|
||||
|
||||
content = header_path.read_text(encoding='utf-8')
|
||||
|
||||
# 🆕 기존 "본문", "개요 1~10" 등 스타일 제거 (id=1~22)
|
||||
# 바탕글(id=0)만 유지!
|
||||
|
||||
# style id=1~30 제거 (바탕글 제외)
|
||||
content = re.sub(r'<hh:style id="([1-9]|[12]\d|30)"[^/]*/>\s*', '', content)
|
||||
|
||||
# itemCnt는 나중에 _update_item_counts에서 자동 업데이트됨
|
||||
|
||||
# 파일 저장
|
||||
header_path.write_text(content, encoding='utf-8')
|
||||
print(f" [INFO] 기존 스타일(본문, 개요1~10 등) 제거 완료")
|
||||
|
||||
# charPr, paraPr은 기존 것 다음부터 (참조 깨지지 않도록)
|
||||
char_ids = [int(m) for m in re.findall(r'<hh:charPr id="(\d+)"', content)]
|
||||
self.next_char_id = max(char_ids) + 1 if char_ids else 20
|
||||
|
||||
para_ids = [int(m) for m in re.findall(r'<hh:paraPr id="(\d+)"', content)]
|
||||
self.next_para_id = max(para_ids) + 1 if para_ids else 20
|
||||
|
||||
# 스타일은 1부터 시작! (Ctrl+2 = id=1, Ctrl+3 = id=2, ...)
|
||||
self.next_style_id = 1
|
||||
|
||||
def inject(self, hwpx_path: str, role_positions: Dict[str, List[tuple]]) -> str:
|
||||
"""
|
||||
HWPX 파일에 커스텀 스타일 주입
|
||||
|
||||
Args:
|
||||
hwpx_path: 원본 HWPX 파일 경로
|
||||
role_positions: 역할별 위치 정보 {role: [(section_idx, para_idx), ...]}
|
||||
|
||||
Returns:
|
||||
수정된 HWPX 파일 경로
|
||||
"""
|
||||
print(f"\n🎨 HWPX 스타일 주입 시작...")
|
||||
print(f" 입력: {hwpx_path}")
|
||||
|
||||
# 1. 임시 디렉토리에 압축 해제
|
||||
self.temp_dir = Path(tempfile.mkdtemp(prefix='hwpx_inject_'))
|
||||
print(f" 임시 폴더: {self.temp_dir}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(hwpx_path, 'r') as zf:
|
||||
zf.extractall(self.temp_dir)
|
||||
|
||||
# 압축 해제 직후 section 파일 크기 확인
|
||||
print(f" [DEBUG] After unzip:")
|
||||
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
|
||||
sec_path = self.temp_dir / "Contents" / sec
|
||||
if sec_path.exists():
|
||||
print(f" [DEBUG] {sec} size: {sec_path.stat().st_size} bytes")
|
||||
|
||||
# 🆕 기존 최대 ID 찾기 (연속 ID 할당을 위해)
|
||||
self._find_max_ids()
|
||||
print(f" [DEBUG] Starting IDs: char={self.next_char_id}, para={self.next_para_id}, style={self.next_style_id}")
|
||||
|
||||
# 2. header.xml에 스타일 정의 추가
|
||||
used_roles = set(role_positions.keys())
|
||||
self._inject_header_styles(used_roles)
|
||||
|
||||
# 3. section*.xml에 styleIDRef 매핑
|
||||
self._inject_section_styles(role_positions)
|
||||
|
||||
# 4. 다시 압축
|
||||
output_path = hwpx_path # 원본 덮어쓰기
|
||||
self._repack_hwpx(output_path)
|
||||
|
||||
print(f" ✅ 스타일 주입 완료: {output_path}")
|
||||
return output_path
|
||||
|
||||
finally:
|
||||
# 임시 폴더 정리
|
||||
if self.temp_dir and self.temp_dir.exists():
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def _inject_header_styles(self, used_roles: set):
|
||||
"""header.xml에 스타일 정의 추가 (모든 ROLE_STYLES 주입)"""
|
||||
header_path = self.temp_dir / "Contents" / "header.xml"
|
||||
if not header_path.exists():
|
||||
print(" [경고] header.xml 없음")
|
||||
return
|
||||
|
||||
content = header_path.read_text(encoding='utf-8')
|
||||
|
||||
# 🆕 모든 ROLE_STYLES 주입 (used_roles 무시)
|
||||
char_props = []
|
||||
para_props = []
|
||||
styles = []
|
||||
|
||||
for role, style_def in ROLE_STYLES.items():
|
||||
char_id = self.next_char_id
|
||||
para_id = self.next_para_id
|
||||
style_id = self.next_style_id
|
||||
|
||||
self.role_to_style_id[role] = style_id
|
||||
self.role_to_para_id[role] = para_id # 🆕
|
||||
self.role_to_char_id[role] = char_id # 🆕
|
||||
|
||||
# charPr 생성
|
||||
char_props.append(self._make_char_pr(char_id, style_def))
|
||||
|
||||
# paraPr 생성
|
||||
para_props.append(self._make_para_pr(para_id, style_def))
|
||||
|
||||
# style 생성
|
||||
styles.append(self._make_style(style_id, style_def.name, para_id, char_id))
|
||||
|
||||
self.next_char_id += 1
|
||||
self.next_para_id += 1
|
||||
self.next_style_id += 1
|
||||
|
||||
if not styles:
|
||||
print(" [정보] 주입할 스타일 없음")
|
||||
return
|
||||
|
||||
# charProperties에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:charProperties>', '\n'.join(char_props) + '\n'
|
||||
)
|
||||
|
||||
# paraProperties에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:paraProperties>', '\n'.join(para_props) + '\n'
|
||||
)
|
||||
|
||||
# styles에 추가
|
||||
content = self._insert_before_tag(
|
||||
content, '</hh:styles>', '\n'.join(styles) + '\n'
|
||||
)
|
||||
|
||||
# 🆕 numbering id=1 패턴 교체 (idRef="0"이 참조하는 기본 번호 모양)
|
||||
# 이렇게 하면 개요 자동 번호가 "제1장, 1.1, 1.1.1..." 형식으로 동작!
|
||||
content = self._replace_default_numbering(content)
|
||||
|
||||
# itemCnt 업데이트
|
||||
content = self._update_item_counts(content)
|
||||
|
||||
header_path.write_text(content, encoding='utf-8')
|
||||
print(f" → header.xml 수정 완료 ({len(styles)}개 스타일 추가)")
|
||||
|
||||
def _make_char_pr(self, id: int, style: StyleDefinition) -> str:
|
||||
"""charPr XML 생성 (한 줄로!)"""
|
||||
color = style.font_color.lstrip('#')
|
||||
font_id = "1" if style.font_bold else "0"
|
||||
|
||||
return f'<hh:charPr id="{id}" height="{style.font_size}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:underline type="NONE" shape="SOLID" color="#000000"/><hh:strikeout shape="NONE" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/></hh:charPr>'
|
||||
|
||||
def _make_para_pr(self, id: int, style: StyleDefinition) -> str:
|
||||
"""paraPr XML 생성 (한 줄로!)"""
|
||||
# 개요 문단이면 type="OUTLINE", 아니면 type="NONE"
|
||||
# idRef="0"은 numbering id=1 (기본 번호 모양)을 참조
|
||||
if style.outline_level >= 0:
|
||||
heading = f'<hh:heading type="OUTLINE" idRef="0" level="{style.outline_level}"/>'
|
||||
else:
|
||||
heading = '<hh:heading type="NONE" idRef="0" level="0"/>'
|
||||
|
||||
return f'<hh:paraPr id="{id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="{style.align}" vertical="BASELINE"/>{heading}<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/><hh:margin><hc:intent value="{style.indent_first}" unit="HWPUNIT"/><hc:left value="{style.indent_left}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{style.space_before}" unit="HWPUNIT"/><hc:next value="{style.space_after}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="{style.line_spacing}" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>'
|
||||
|
||||
def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str:
|
||||
"""style XML 생성"""
|
||||
safe_name = name.replace('<', '<').replace('>', '>')
|
||||
return f'<hh:style id="{id}" type="PARA" name="{safe_name}" engName="" paraPrIDRef="{para_id}" charPrIDRef="{char_id}" nextStyleIDRef="{id}" langID="1042" lockForm="0"/>'
|
||||
|
||||
def _insert_before_tag(self, content: str, tag: str, insert_text: str) -> str:
|
||||
"""특정 태그 앞에 텍스트 삽입"""
|
||||
return content.replace(tag, insert_text + tag)
|
||||
|
||||
def _update_item_counts(self, content: str) -> str:
|
||||
"""itemCnt 속성 업데이트"""
|
||||
# charProperties itemCnt
|
||||
char_count = content.count('<hh:charPr ')
|
||||
content = re.sub(
|
||||
r'<hh:charProperties itemCnt="(\d+)"',
|
||||
f'<hh:charProperties itemCnt="{char_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# paraProperties itemCnt
|
||||
para_count = content.count('<hh:paraPr ')
|
||||
content = re.sub(
|
||||
r'<hh:paraProperties itemCnt="(\d+)"',
|
||||
f'<hh:paraProperties itemCnt="{para_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# styles itemCnt
|
||||
style_count = content.count('<hh:style ')
|
||||
content = re.sub(
|
||||
r'<hh:styles itemCnt="(\d+)"',
|
||||
f'<hh:styles itemCnt="{style_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
# 🆕 numberings itemCnt
|
||||
numbering_count = content.count('<hh:numbering ')
|
||||
content = re.sub(
|
||||
r'<hh:numberings itemCnt="(\d+)"',
|
||||
f'<hh:numberings itemCnt="{numbering_count}"',
|
||||
content
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _replace_default_numbering(self, content: str) -> str:
|
||||
"""numbering id=1의 패턴을 우리 패턴으로 교체"""
|
||||
# 우리가 원하는 개요 번호 패턴
|
||||
new_patterns = [
|
||||
{'level': '1', 'format': 'DIGIT', 'pattern': '제^1장'},
|
||||
{'level': '2', 'format': 'DIGIT', 'pattern': '^1.^2'},
|
||||
{'level': '3', 'format': 'DIGIT', 'pattern': '^1.^2.^3'},
|
||||
{'level': '4', 'format': 'HANGUL_SYLLABLE', 'pattern': '^4.'},
|
||||
{'level': '5', 'format': 'DIGIT', 'pattern': '^5)'},
|
||||
{'level': '6', 'format': 'HANGUL_SYLLABLE', 'pattern': '^6)'},
|
||||
{'level': '7', 'format': 'CIRCLED_DIGIT', 'pattern': '^7'},
|
||||
]
|
||||
|
||||
# numbering id="1" 찾기
|
||||
match = re.search(r'(<hh:numbering id="1"[^>]*>)(.*?)(</hh:numbering>)', content, re.DOTALL)
|
||||
if not match:
|
||||
print(" [경고] numbering id=1 없음, 교체 건너뜀")
|
||||
return content
|
||||
|
||||
numbering_content = match.group(2)
|
||||
|
||||
for np in new_patterns:
|
||||
level = np['level']
|
||||
fmt = np['format']
|
||||
pattern = np['pattern']
|
||||
|
||||
# 해당 level의 paraHead 찾아서 교체
|
||||
def replace_parahead(m):
|
||||
tag = m.group(0)
|
||||
# numFormat 변경
|
||||
tag = re.sub(r'numFormat="[^"]*"', f'numFormat="{fmt}"', tag)
|
||||
# 패턴(텍스트 내용) 변경
|
||||
tag = re.sub(r'>([^<]*)</hh:paraHead>', f'>{pattern}</hh:paraHead>', tag)
|
||||
return tag
|
||||
|
||||
numbering_content = re.sub(
|
||||
rf'<hh:paraHead[^>]*level="{level}"[^>]*>.*?</hh:paraHead>',
|
||||
replace_parahead,
|
||||
numbering_content
|
||||
)
|
||||
|
||||
new_content = match.group(1) + numbering_content + match.group(3)
|
||||
print(" [INFO] numbering id=1 패턴 교체 완료 (제^1장, ^1.^2, ^1.^2.^3...)")
|
||||
return content.replace(match.group(0), new_content)
|
||||
|
||||
def _adjust_tables(self, content: str) -> str:
|
||||
"""표 셀 크기 자동 조정
|
||||
|
||||
1. 행 높이: 최소 800 hwpunit (내용 잘림 방지)
|
||||
2. 열 너비: 표 전체 너비를 열 개수로 균등 분배 (또는 첫 열 좁게)
|
||||
"""
|
||||
|
||||
def adjust_table(match):
|
||||
tbl = match.group(0)
|
||||
|
||||
# 표 전체 너비 추출
|
||||
sz_match = re.search(r'<hp:sz width="(\d+)"', tbl)
|
||||
table_width = int(sz_match.group(1)) if sz_match else 47624
|
||||
|
||||
# 열 개수 추출
|
||||
col_match = re.search(r'colCnt="(\d+)"', tbl)
|
||||
col_cnt = int(col_match.group(1)) if col_match else 4
|
||||
|
||||
# 열 너비 계산 (첫 열은 30%, 나머지 균등)
|
||||
first_col_width = int(table_width * 0.25)
|
||||
other_col_width = (table_width - first_col_width) // (col_cnt - 1) if col_cnt > 1 else table_width
|
||||
|
||||
# 행 높이 최소값 설정
|
||||
min_height = 800 # 약 8mm
|
||||
|
||||
# 셀 크기 조정
|
||||
col_idx = [0] # closure용
|
||||
|
||||
def adjust_cell_sz(cell_match):
|
||||
width = int(cell_match.group(1))
|
||||
height = int(cell_match.group(2))
|
||||
|
||||
# 높이 조정
|
||||
new_height = max(height, min_height)
|
||||
|
||||
return f'<hp:cellSz width="{width}" height="{new_height}"/>'
|
||||
|
||||
tbl = re.sub(
|
||||
r'<hp:cellSz width="(\d+)" height="(\d+)"/>',
|
||||
adjust_cell_sz,
|
||||
tbl
|
||||
)
|
||||
|
||||
return tbl
|
||||
|
||||
return re.sub(r'<hp:tbl[^>]*>.*?</hp:tbl>', adjust_table, content, flags=re.DOTALL)
|
||||
|
||||
def _inject_section_styles(self, role_positions: Dict[str, List[tuple]]):
|
||||
"""section*.xml에 styleIDRef 매핑 (텍스트 매칭 방식)"""
|
||||
contents_dir = self.temp_dir / "Contents"
|
||||
|
||||
# 🔍 디버그: role_to_style_id 확인
|
||||
print(f" [DEBUG] role_to_style_id: {self.role_to_style_id}")
|
||||
|
||||
# section 파일들 찾기
|
||||
section_files = sorted(contents_dir.glob("section*.xml"))
|
||||
print(f" [DEBUG] section files: {[f.name for f in section_files]}")
|
||||
|
||||
total_modified = 0
|
||||
|
||||
for section_file in section_files:
|
||||
print(f" [DEBUG] Processing: {section_file.name}")
|
||||
original_content = section_file.read_text(encoding='utf-8')
|
||||
print(f" [DEBUG] File size: {len(original_content)} bytes")
|
||||
|
||||
content = original_content # 작업용 복사본
|
||||
|
||||
# 🆕 머리말/꼬리말 영역 보존 (placeholder로 교체)
|
||||
header_footer_map = {}
|
||||
placeholder_idx = 0
|
||||
|
||||
def save_header_footer(match):
|
||||
nonlocal placeholder_idx
|
||||
key = f"__HF_PLACEHOLDER_{placeholder_idx}__"
|
||||
header_footer_map[key] = match.group(0)
|
||||
placeholder_idx += 1
|
||||
return key
|
||||
|
||||
# 머리말/꼬리말 임시 교체
|
||||
content = re.sub(r'<hp:header[^>]*>.*?</hp:header>', save_header_footer, content, flags=re.DOTALL)
|
||||
content = re.sub(r'<hp:footer[^>]*>.*?</hp:footer>', save_header_footer, content, flags=re.DOTALL)
|
||||
|
||||
# 모든 <hp:p> 태그와 내부 텍스트 추출
|
||||
para_pattern = r'(<hp:p [^>]*>)(.*?)(</hp:p>)'
|
||||
|
||||
section_modified = 0
|
||||
|
||||
def replace_style(match):
|
||||
nonlocal total_modified, section_modified
|
||||
open_tag = match.group(1)
|
||||
inner = match.group(2)
|
||||
close_tag = match.group(3)
|
||||
|
||||
# 텍스트 추출 (태그 제거)
|
||||
text = re.sub(r'<[^>]+>', '', inner).strip()
|
||||
if not text:
|
||||
return match.group(0)
|
||||
|
||||
# 텍스트 앞부분으로 역할 판단
|
||||
text_start = text[:50] # 처음 50자로 판단
|
||||
|
||||
matched_role = None
|
||||
matched_style_id = None
|
||||
matched_para_id = None
|
||||
matched_char_id = None
|
||||
|
||||
# 제목 패턴 매칭 (앞에 특수문자 허용)
|
||||
# Unicode: ■\u25a0 ▸\u25b8 ◆\u25c6 ▶\u25b6 ●\u25cf ○\u25cb ▪\u25aa ►\u25ba ☞\u261e ★\u2605 ※\u203b ·\u00b7
|
||||
prefix = r'^[\u25a0\u25b8\u25c6\u25b6\u25cf\u25cb\u25aa\u25ba\u261e\u2605\u203b\u00b7\s]*'
|
||||
|
||||
# 🆕 FIGURE_CAPTION: "[그림 1-1]", "[그림 1-2]" 등 (가장 먼저 체크!)
|
||||
# 그림 = \uadf8\ub9bc
|
||||
if re.match(r'^\[\uadf8\ub9bc\s*[\d-]+\]', text_start):
|
||||
matched_role = 'FIGURE_CAPTION'
|
||||
# 🆕 TABLE_CAPTION: "<표 1-1>", "[표 1-1]" 등
|
||||
# 표 = \ud45c
|
||||
elif re.match(r'^[<\[]\ud45c\s*[\d-]+[>\]]', text_start):
|
||||
matched_role = 'TABLE_CAPTION'
|
||||
# H1: "제1장", "1 개요" 등
|
||||
elif re.match(prefix + r'\uc81c?\s*\d+\uc7a5?\s', text_start) or re.match(prefix + r'[1-9]\s+[\uac00-\ud7a3]', text_start):
|
||||
matched_role = 'H1'
|
||||
# H3: "1.1.1 " (H2보다 먼저 체크!)
|
||||
elif re.match(prefix + r'\d+\.\d+\.\d+\s', text_start):
|
||||
matched_role = 'H3'
|
||||
# H2: "1.1 "
|
||||
elif re.match(prefix + r'\d+\.\d+\s', text_start):
|
||||
matched_role = 'H2'
|
||||
# H4: "가. "
|
||||
elif re.match(prefix + r'[\uac00-\ud7a3]\.\s', text_start):
|
||||
matched_role = 'H4'
|
||||
# H5: "1) "
|
||||
elif re.match(prefix + r'\d+\)\s', text_start):
|
||||
matched_role = 'H5'
|
||||
# H6: "(1) " 또는 "가) "
|
||||
elif re.match(prefix + r'\(\d+\)\s', text_start):
|
||||
matched_role = 'H6'
|
||||
elif re.match(prefix + r'[\uac00-\ud7a3]\)\s', text_start):
|
||||
matched_role = 'H6'
|
||||
# LIST_ITEM: "○ ", "● ", "• " 등
|
||||
elif re.match(r'^[\u25cb\u25cf\u25e6\u2022\u2023\u25b8]\s', text_start):
|
||||
matched_role = 'LIST_ITEM'
|
||||
elif re.match(r'^[-\u2013\u2014]\s', text_start):
|
||||
matched_role = 'LIST_ITEM'
|
||||
|
||||
# 매칭된 역할이 있고 스타일 ID가 있으면 적용
|
||||
if matched_role and matched_role in self.role_to_style_id:
|
||||
matched_style_id = self.role_to_style_id[matched_role]
|
||||
matched_para_id = self.role_to_para_id[matched_role]
|
||||
matched_char_id = self.role_to_char_id[matched_role]
|
||||
elif 'BODY' in self.role_to_style_id and len(text) > 20:
|
||||
# 긴 텍스트는 본문으로 간주
|
||||
matched_role = 'BODY'
|
||||
matched_style_id = self.role_to_style_id['BODY']
|
||||
matched_para_id = self.role_to_para_id['BODY']
|
||||
matched_char_id = self.role_to_char_id['BODY']
|
||||
|
||||
if matched_style_id:
|
||||
# 1. hp:p 태그의 styleIDRef 변경
|
||||
if 'styleIDRef="' in open_tag:
|
||||
new_open = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{matched_style_id}"', open_tag)
|
||||
else:
|
||||
new_open = open_tag.replace('<hp:p ', f'<hp:p styleIDRef="{matched_style_id}" ')
|
||||
|
||||
# 2. hp:p 태그의 paraPrIDRef도 변경! (스타일의 paraPrIDRef와 일치!)
|
||||
new_open = re.sub(r'paraPrIDRef="[^"]*"', f'paraPrIDRef="{matched_para_id}"', new_open)
|
||||
|
||||
# 3. inner에서 hp:run의 charPrIDRef도 변경! (스타일의 charPrIDRef와 일치!)
|
||||
new_inner = re.sub(r'(<hp:run[^>]*charPrIDRef=")[^"]*(")', f'\\g<1>{matched_char_id}\\2', inner)
|
||||
|
||||
# 🆕 4. 개요 문단이면 수동 번호 제거 (자동 번호가 붙으니까!)
|
||||
if matched_role in ROLE_STYLES and ROLE_STYLES[matched_role].outline_level >= 0:
|
||||
new_inner = self._remove_manual_numbering(new_inner, matched_role)
|
||||
|
||||
total_modified += 1
|
||||
section_modified += 1
|
||||
return new_open + new_inner + close_tag
|
||||
|
||||
return match.group(0)
|
||||
|
||||
new_content = re.sub(para_pattern, replace_style, content, flags=re.DOTALL)
|
||||
|
||||
# 🆕 표 크기 자동 조정
|
||||
new_content = self._adjust_tables(new_content)
|
||||
|
||||
# 🆕 outlineShapeIDRef를 1로 변경 (우리가 교체한 numbering id=1 사용)
|
||||
new_content = re.sub(
|
||||
r'outlineShapeIDRef="[^"]*"',
|
||||
'outlineShapeIDRef="1"',
|
||||
new_content
|
||||
)
|
||||
|
||||
|
||||
# 🆕 머리말/꼬리말 복원
|
||||
for key, original in header_footer_map.items():
|
||||
new_content = new_content.replace(key, original)
|
||||
|
||||
print(f" [DEBUG] {section_file.name}: {section_modified} paras modified, content changed: {new_content != original_content}")
|
||||
|
||||
if new_content != original_content:
|
||||
section_file.write_text(new_content, encoding='utf-8')
|
||||
print(f" -> {section_file.name} saved")
|
||||
|
||||
print(f" -> Total {total_modified} paragraphs styled")
|
||||
|
||||
def _update_para_style(self, content: str, para_idx: int, style_id: int) -> str:
|
||||
"""특정 인덱스의 문단 styleIDRef 변경"""
|
||||
# <hp:p ...> 태그들 찾기
|
||||
pattern = r'<hp:p\s[^>]*>'
|
||||
matches = list(re.finditer(pattern, content))
|
||||
|
||||
if para_idx >= len(matches):
|
||||
return content
|
||||
|
||||
match = matches[para_idx]
|
||||
old_tag = match.group(0)
|
||||
|
||||
# styleIDRef 속성 변경 또는 추가
|
||||
if 'styleIDRef=' in old_tag:
|
||||
new_tag = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{style_id}"', old_tag)
|
||||
else:
|
||||
# 속성 추가
|
||||
new_tag = old_tag.replace('<hp:p ', f'<hp:p styleIDRef="{style_id}" ')
|
||||
|
||||
return content[:match.start()] + new_tag + content[match.end():]
|
||||
|
||||
def _remove_manual_numbering(self, inner: str, role: str) -> str:
|
||||
"""🆕 개요 문단에서 수동 번호 제거 (자동 번호가 붙으니까!)
|
||||
|
||||
HTML에서 "제1장 DX 개요" → "DX 개요" (자동으로 "제1장" 붙음)
|
||||
HTML에서 "1.1 측량 DX" → "측량 DX" (자동으로 "1.1" 붙음)
|
||||
"""
|
||||
# 역할별 번호 패턴
|
||||
patterns = {
|
||||
'H1': r'^(제\s*\d+\s*장\s*)', # "제1장 " → 제거
|
||||
'H2': r'^(\d+\.\d+\s+)', # "1.1 " → 제거
|
||||
'H3': r'^(\d+\.\d+\.\d+\s+)', # "1.1.1 " → 제거
|
||||
'H4': r'^([가-힣]\.\s+)', # "가. " → 제거
|
||||
'H5': r'^(\d+\)\s+)', # "1) " → 제거
|
||||
'H6': r'^([가-힣]\)\s+|\(\d+\)\s+)', # "가) " 또는 "(1) " → 제거
|
||||
'H7': r'^([①②③④⑤⑥⑦⑧⑨⑩]+\s*)', # "① " → 제거
|
||||
}
|
||||
|
||||
if role not in patterns:
|
||||
return inner
|
||||
|
||||
pattern = patterns[role]
|
||||
|
||||
# <hp:t> 태그 내 텍스트에서 번호 제거
|
||||
def remove_number(match):
|
||||
text = match.group(1)
|
||||
# 첫 번째 <hp:t> 내용에서만 번호 제거
|
||||
new_text = re.sub(pattern, '', text, count=1)
|
||||
return f'<hp:t>{new_text}</hp:t>'
|
||||
|
||||
# 첫 번째 hp:t 태그만 처리
|
||||
new_inner = re.sub(r'<hp:t>([^<]*)</hp:t>', remove_number, inner, count=1)
|
||||
|
||||
return new_inner
|
||||
|
||||
def _repack_hwpx(self, output_path: str):
|
||||
"""HWPX 재압축"""
|
||||
print(f" [DEBUG] Repacking to: {output_path}")
|
||||
print(f" [DEBUG] Source dir: {self.temp_dir}")
|
||||
|
||||
# 압축 전 section 파일 크기 확인
|
||||
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
|
||||
sec_path = self.temp_dir / "Contents" / sec
|
||||
if sec_path.exists():
|
||||
print(f" [DEBUG] {sec} size before zip: {sec_path.stat().st_size} bytes")
|
||||
|
||||
# 🆕 임시 파일에 먼저 저장 (원본 파일 잠금 문제 회피)
|
||||
temp_output = output_path + ".tmp"
|
||||
|
||||
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축 없이 첫 번째로
|
||||
mimetype_path = self.temp_dir / "mimetype"
|
||||
if mimetype_path.exists():
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
file_count = 0
|
||||
for root, dirs, files in os.walk(self.temp_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(self.temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
file_count += 1
|
||||
|
||||
print(f" [DEBUG] Total files zipped: {file_count}")
|
||||
|
||||
# 🆕 원본 삭제 후 임시 파일을 원본 이름으로 변경
|
||||
import time
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
os.rename(temp_output, output_path)
|
||||
break
|
||||
except PermissionError:
|
||||
print(f" [DEBUG] 파일 잠금 대기 중... ({attempt + 1}/3)")
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
# 3번 시도 실패 시 임시 파일 이름으로 유지
|
||||
print(f" [경고] 원본 덮어쓰기 실패, 임시 파일 사용: {temp_output}")
|
||||
output_path = temp_output
|
||||
|
||||
# 압축 후 결과 확인
|
||||
print(f" [DEBUG] Output file size: {Path(output_path).stat().st_size} bytes")
|
||||
|
||||
|
||||
def inject_styles_to_hwpx(hwpx_path: str, elements: list) -> str:
|
||||
"""
|
||||
편의 함수: StyledElement 리스트로부터 역할 위치 추출 후 스타일 주입
|
||||
|
||||
Args:
|
||||
hwpx_path: HWPX 파일 경로
|
||||
elements: StyleAnalyzer의 StyledElement 리스트
|
||||
|
||||
Returns:
|
||||
수정된 HWPX 파일 경로
|
||||
"""
|
||||
# 역할별 위치 수집
|
||||
# 참고: 현재는 section 0, para 순서대로 가정
|
||||
role_positions: Dict[str, List[tuple]] = {}
|
||||
|
||||
for idx, elem in enumerate(elements):
|
||||
role = elem.role
|
||||
if role not in role_positions:
|
||||
role_positions[role] = []
|
||||
# (section_idx, para_idx) - 현재는 section 0 가정
|
||||
role_positions[role].append((0, idx))
|
||||
|
||||
injector = HwpxStyleInjector()
|
||||
return injector.inject(hwpx_path, role_positions)
|
||||
|
||||
|
||||
# 테스트
|
||||
if __name__ == "__main__":
|
||||
# 테스트용
|
||||
test_positions = {
|
||||
'H1': [(0, 0), (0, 5)],
|
||||
'H2': [(0, 1), (0, 6)],
|
||||
'BODY': [(0, 2), (0, 3), (0, 4)],
|
||||
}
|
||||
|
||||
# injector = HwpxStyleInjector()
|
||||
# injector.inject("test.hwpx", test_positions)
|
||||
print("HwpxStyleInjector 모듈 로드 완료")
|
||||
174
03. Code/geulbeot_8th/converters/hwpx_table_injector.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HWPX 표 열 너비 수정기 v2
|
||||
표 생성 후 HWPX 파일을 직접 수정하여 열 너비 적용
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# mm → HWPML 단위 변환 (1mm ≈ 283.46 HWPML units)
|
||||
MM_TO_HWPML = 7200 / 25.4 # ≈ 283.46
|
||||
|
||||
|
||||
def inject_table_widths(hwpx_path: str, table_widths_list: list):
|
||||
"""
|
||||
HWPX 파일의 표 열 너비를 수정
|
||||
|
||||
Args:
|
||||
hwpx_path: HWPX 파일 경로
|
||||
table_widths_list: [[w1, w2, w3], [w1, w2], ...] 형태 (mm 단위)
|
||||
"""
|
||||
if not table_widths_list:
|
||||
print(" [INFO] 수정할 표 없음")
|
||||
return
|
||||
|
||||
print(f"📐 HWPX 표 열 너비 수정 시작... ({len(table_widths_list)}개 표)")
|
||||
|
||||
# HWPX 압축 해제
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="hwpx_table_"))
|
||||
|
||||
with zipfile.ZipFile(hwpx_path, 'r') as zf:
|
||||
zf.extractall(temp_dir)
|
||||
|
||||
# section*.xml 파일들에서 표 찾기
|
||||
contents_dir = temp_dir / "Contents"
|
||||
|
||||
table_idx = 0
|
||||
total_modified = 0
|
||||
|
||||
for section_file in sorted(contents_dir.glob("section*.xml")):
|
||||
with open(section_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
|
||||
# 모든 표(<hp:tbl>...</hp:tbl>) 찾기
|
||||
tbl_pattern = re.compile(r'(<hp:tbl\b[^>]*>)(.*?)(</hp:tbl>)', re.DOTALL)
|
||||
|
||||
def process_table(match):
|
||||
nonlocal table_idx, total_modified
|
||||
|
||||
if table_idx >= len(table_widths_list):
|
||||
return match.group(0)
|
||||
|
||||
tbl_open = match.group(1)
|
||||
tbl_content = match.group(2)
|
||||
tbl_close = match.group(3)
|
||||
|
||||
col_widths_mm = table_widths_list[table_idx]
|
||||
col_widths_hwpml = [int(w * MM_TO_HWPML) for w in col_widths_mm]
|
||||
|
||||
# 표 전체 너비 수정 (hp:sz width="...")
|
||||
total_width = int(sum(col_widths_mm) * MM_TO_HWPML)
|
||||
tbl_content = re.sub(
|
||||
r'(<hp:sz\s+width=")(\d+)(")',
|
||||
lambda m: f'{m.group(1)}{total_width}{m.group(3)}',
|
||||
tbl_content,
|
||||
count=1
|
||||
)
|
||||
|
||||
# 각 셀의 cellSz width 수정
|
||||
# 방법: colAddr별로 너비 매핑
|
||||
def replace_cell_width(tc_match):
|
||||
tc_content = tc_match.group(0)
|
||||
|
||||
# colAddr 추출
|
||||
col_addr_match = re.search(r'<hp:cellAddr\s+colAddr="(\d+)"', tc_content)
|
||||
if not col_addr_match:
|
||||
return tc_content
|
||||
|
||||
col_idx = int(col_addr_match.group(1))
|
||||
if col_idx >= len(col_widths_hwpml):
|
||||
return tc_content
|
||||
|
||||
new_width = col_widths_hwpml[col_idx]
|
||||
|
||||
# cellSz width 교체
|
||||
tc_content = re.sub(
|
||||
r'(<hp:cellSz\s+width=")(\d+)(")',
|
||||
lambda m: f'{m.group(1)}{new_width}{m.group(3)}',
|
||||
tc_content
|
||||
)
|
||||
|
||||
return tc_content
|
||||
|
||||
# 각 <hp:tc>...</hp:tc> 블록 처리
|
||||
tbl_content = re.sub(
|
||||
r'<hp:tc\b[^>]*>.*?</hp:tc>',
|
||||
replace_cell_width,
|
||||
tbl_content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
print(f" ✅ 표 #{table_idx + 1}: {col_widths_mm} mm → HWPML 적용")
|
||||
table_idx += 1
|
||||
total_modified += 1
|
||||
|
||||
return tbl_open + tbl_content + tbl_close
|
||||
|
||||
# 표 처리
|
||||
new_content = tbl_pattern.sub(process_table, content)
|
||||
|
||||
# 변경사항 있으면 저장
|
||||
if new_content != original_content:
|
||||
with open(section_file, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
print(f" → {section_file.name} 저장됨")
|
||||
|
||||
# 다시 압축
|
||||
repack_hwpx(temp_dir, hwpx_path)
|
||||
|
||||
# 임시 폴더 삭제
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
print(f" ✅ 총 {total_modified}개 표 열 너비 수정 완료")
|
||||
|
||||
|
||||
def repack_hwpx(source_dir: Path, output_path: str):
|
||||
"""HWPX 파일 다시 압축"""
|
||||
import os
|
||||
import time
|
||||
|
||||
temp_output = output_path + ".tmp"
|
||||
|
||||
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# mimetype은 압축 없이 첫 번째로
|
||||
mimetype_path = source_dir / "mimetype"
|
||||
if mimetype_path.exists():
|
||||
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
# 나머지 파일들
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
for file in files:
|
||||
if file == "mimetype":
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
arcname = file_path.relative_to(source_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# 원본 교체
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
os.rename(temp_output, output_path)
|
||||
break
|
||||
except PermissionError:
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
# 테스트용
|
||||
if __name__ == "__main__":
|
||||
test_widths = [
|
||||
[18.2, 38.9, 42.8, 70.1],
|
||||
[19.9, 79.6, 70.5],
|
||||
[28.7, 81.4, 59.9],
|
||||
[19.2, 61.4, 89.5],
|
||||
]
|
||||
|
||||
hwpx_path = r"C:\Users\User\AppData\Local\Temp\geulbeot_output.hwpx"
|
||||
inject_table_widths(hwpx_path, test_widths)
|
||||
1
03. Code/geulbeot_8th/converters/pipeline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .router import process_document, is_long_document
|
||||
165
03. Code/geulbeot_8th/converters/pipeline/router.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
router.py
|
||||
|
||||
기능:
|
||||
- HTML 입력의 분량을 판단하여 적절한 파이프라인으로 분기
|
||||
- 긴 문서 (5000자 이상): RAG 파이프라인 (step3→4→5→6→7→8→9)
|
||||
- 짧은 문서 (5000자 미만): 직접 생성 (step7→8→9)
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
# 분량 판단 기준
|
||||
LONG_DOC_THRESHOLD = 5000 # 5000자 이상이면 긴 문서
|
||||
|
||||
# 이미지 assets 경로 (개발용 고정) - r prefix 필수!
|
||||
ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
|
||||
|
||||
def count_characters(html_content: str) -> int:
|
||||
"""HTML 태그 제외한 순수 텍스트 글자 수 계산"""
|
||||
# HTML 태그 제거
|
||||
text_only = re.sub(r'<[^>]+>', '', html_content)
|
||||
# 공백 정리
|
||||
text_only = ' '.join(text_only.split())
|
||||
return len(text_only)
|
||||
|
||||
|
||||
def is_long_document(html_content: str) -> bool:
|
||||
"""긴 문서 여부 판단"""
|
||||
char_count = count_characters(html_content)
|
||||
return char_count >= LONG_DOC_THRESHOLD
|
||||
|
||||
def convert_image_paths(html_content: str) -> str:
|
||||
"""
|
||||
HTML 내 이미지 경로를 서버 경로로 변환
|
||||
- assets/xxx.png → /assets/xxx.png (Flask 서빙용)
|
||||
- 절대 경로나 URL은 그대로 유지
|
||||
"""
|
||||
|
||||
def replace_src(match):
|
||||
original_path = match.group(1)
|
||||
|
||||
# 이미 절대 경로이거나 URL이면 그대로
|
||||
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')):
|
||||
return match.group(0)
|
||||
|
||||
# assets/로 시작하면 /assets/로 변환 (Flask 서빙)
|
||||
if original_path.startswith('assets/'):
|
||||
return f'src="/{original_path}"'
|
||||
|
||||
return match.group(0)
|
||||
|
||||
# src="..." 패턴 찾아서 변환
|
||||
result = re.sub(r'src="([^"]+)"', replace_src, html_content)
|
||||
return result
|
||||
|
||||
def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
짧은 문서 파이프라인 (5000자 미만)
|
||||
"""
|
||||
try:
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(html_content)
|
||||
|
||||
# TODO: step7, step8, step9 연동
|
||||
return {
|
||||
'success': True,
|
||||
'pipeline': 'short',
|
||||
'char_count': count_characters(html_content),
|
||||
'html': processed_html
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pipeline': 'short'
|
||||
}
|
||||
|
||||
def inject_template_css(html_content: str, template_css: str) -> str:
|
||||
"""
|
||||
HTML에 템플릿 CSS 주입
|
||||
- <style> 태그가 있으면 그 안에 추가
|
||||
- 없으면 <head>에 새로 생성
|
||||
"""
|
||||
if not template_css:
|
||||
return html_content
|
||||
|
||||
css_block = f"\n/* ===== 템플릿 스타일 ===== */\n{template_css}\n"
|
||||
|
||||
# 기존 </style> 태그 앞에 추가
|
||||
if '</style>' in html_content:
|
||||
return html_content.replace('</style>', f'{css_block}</style>', 1)
|
||||
|
||||
# <head> 태그 뒤에 새로 추가
|
||||
elif '<head>' in html_content:
|
||||
return html_content.replace('<head>', f'<head>\n<style>{css_block}</style>', 1)
|
||||
|
||||
# head도 없으면 맨 앞에 추가
|
||||
else:
|
||||
return f'<style>{css_block}</style>\n{html_content}'
|
||||
|
||||
|
||||
def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
긴 문서 파이프라인 (5000자 이상)
|
||||
"""
|
||||
try:
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(html_content)
|
||||
|
||||
# TODO: step3~9 순차 실행
|
||||
return {
|
||||
'success': True,
|
||||
'pipeline': 'long',
|
||||
'char_count': count_characters(html_content),
|
||||
'html': processed_html
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pipeline': 'long'
|
||||
}
|
||||
|
||||
|
||||
def process_document(content: str, options: dict = None) -> Dict[str, Any]:
|
||||
"""
|
||||
메인 라우터 함수
|
||||
- 분량에 따라 적절한 파이프라인으로 분기
|
||||
|
||||
Args:
|
||||
content: HTML 문자열
|
||||
options: 추가 옵션 (page_option, instruction 등)
|
||||
|
||||
Returns:
|
||||
{'success': bool, 'html': str, 'pipeline': str, ...}
|
||||
"""
|
||||
if options is None:
|
||||
options = {}
|
||||
|
||||
if not content or not content.strip():
|
||||
return {
|
||||
'success': False,
|
||||
'error': '내용이 비어있습니다.'
|
||||
}
|
||||
|
||||
char_count = count_characters(content)
|
||||
|
||||
if is_long_document(content):
|
||||
result = run_long_pipeline(content, options)
|
||||
else:
|
||||
result = run_short_pipeline(content, options)
|
||||
|
||||
# 공통 정보 추가
|
||||
result['char_count'] = char_count
|
||||
result['threshold'] = LONG_DOC_THRESHOLD
|
||||
|
||||
# ⭐ 템플릿 CSS 주입
|
||||
template_css = options.get('template_css')
|
||||
if template_css and result.get('success') and result.get('html'):
|
||||
result['html'] = inject_template_css(result['html'], template_css)
|
||||
|
||||
return result
|
||||
784
03. Code/geulbeot_8th/converters/pipeline/step1_convert.py
Normal file
@@ -0,0 +1,784 @@
|
||||
"""
|
||||
측량/GIS/드론 관련 자료 PDF 변환 및 정리 시스템
|
||||
- 모든 파일 형식을 PDF로 변환
|
||||
- DWG 파일: DWG TrueView를 사용한 자동 PDF 변환
|
||||
- 동영상 파일: Whisper를 사용한 음성→텍스트 변환 후 PDF 생성
|
||||
- 원본 경로와 변환 파일 경로를 엑셀로 관리
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
import win32com.client
|
||||
import pythoncom
|
||||
from PIL import Image
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
class SurveyingFileConverter:
|
||||
def _dbg(self, msg):
|
||||
if getattr(self, "debug", False):
|
||||
print(msg)
|
||||
|
||||
def _ensure_ffmpeg_on_path(self):
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
found = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG ffmpeg which before: {found}")
|
||||
if found:
|
||||
self.ffmpeg_exe = found
|
||||
return True
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
src = Path(imageio_ffmpeg.get_ffmpeg_exe())
|
||||
self._dbg(f"DEBUG imageio ffmpeg exe: {src}")
|
||||
self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}")
|
||||
|
||||
if not src.exists():
|
||||
return False
|
||||
|
||||
tools_dir = Path(self.output_dir) / "tools_ffmpeg"
|
||||
tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dst = tools_dir / "ffmpeg.exe"
|
||||
|
||||
if not dst.exists():
|
||||
shutil.copyfile(str(src), str(dst))
|
||||
|
||||
os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
found2 = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG ffmpeg which after: {found2}")
|
||||
|
||||
if found2:
|
||||
self.ffmpeg_exe = found2
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._dbg(f"DEBUG ensure ffmpeg error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def __init__(self, source_dir, output_dir):
|
||||
self.source_dir = Path(source_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.debug = True
|
||||
self.ffmpeg_exe = None
|
||||
ok = self._ensure_ffmpeg_on_path()
|
||||
self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}")
|
||||
|
||||
# 변환 로그를 저장할 리스트
|
||||
self.conversion_log = []
|
||||
|
||||
# ★ 추가: 도메인 용어 사전
|
||||
self.domain_terms = ""
|
||||
|
||||
# HWP 보안 모듈 후보 목록 추가
|
||||
self.hwp_security_modules = [
|
||||
"FilePathCheckerModuleExample",
|
||||
"SecurityModule",
|
||||
""
|
||||
]
|
||||
|
||||
# 지원 파일 확장자 정의
|
||||
self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
|
||||
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'}
|
||||
self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'}
|
||||
self.text_extensions = {'.txt', '.csv', '.log', '.md'}
|
||||
self.pdf_extension = {'.pdf'}
|
||||
self.dwg_extensions = {'.dwg', '.dxf'}
|
||||
|
||||
# DWG TrueView 경로 설정 (설치 버전에 맞게 조정)
|
||||
self.trueview_path = self._find_trueview()
|
||||
|
||||
def _find_trueview(self):
|
||||
"""DWG TrueView 설치 경로 자동 탐색"""
|
||||
possible_paths = [
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe",
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe",
|
||||
r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe",
|
||||
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe",
|
||||
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_all_files(self):
|
||||
"""하위 모든 폴더의 파일 목록 가져오기"""
|
||||
all_files = []
|
||||
for file_path in self.source_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
all_files.append(file_path)
|
||||
return all_files
|
||||
|
||||
def extract_audio_from_video(self, video_path, audio_output_path):
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
||||
self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}")
|
||||
self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}")
|
||||
self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}")
|
||||
self._dbg(f"DEBUG extract out path: {audio_output_path}")
|
||||
|
||||
cmd = [
|
||||
ffmpeg_exe,
|
||||
"-i", str(video_path),
|
||||
"-vn",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-y",
|
||||
str(audio_output_path),
|
||||
]
|
||||
self._dbg("DEBUG extract cmd: " + " ".join(cmd))
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True)
|
||||
self._dbg(f"DEBUG extract returncode: {result.returncode}")
|
||||
self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}")
|
||||
self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._dbg(f"DEBUG extract exception: {e}")
|
||||
return False
|
||||
|
||||
def transcribe_audio_with_whisper(self, audio_path):
|
||||
try:
|
||||
self._ensure_ffmpeg_on_path()
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}")
|
||||
|
||||
if not ffmpeg_path:
|
||||
if self.ffmpeg_exe:
|
||||
import os
|
||||
os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
audio_file = Path(audio_path)
|
||||
self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}")
|
||||
self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}")
|
||||
|
||||
if not audio_file.exists() or audio_file.stat().st_size == 0:
|
||||
return "[오디오 파일이 비어있거나 존재하지 않음]"
|
||||
|
||||
import whisper
|
||||
model = whisper.load_model("medium") # ★ base → medium 변경
|
||||
|
||||
# ★ domain_terms를 initial_prompt로 사용
|
||||
result = model.transcribe(
|
||||
str(audio_path),
|
||||
language="ko",
|
||||
task="transcribe",
|
||||
initial_prompt=self.domain_terms if self.domain_terms else None,
|
||||
condition_on_previous_text=True, # ★ 다시 True로
|
||||
)
|
||||
|
||||
# ★ 후처리: 반복 및 이상한 텍스트 제거
|
||||
text = result["text"]
|
||||
text = self.clean_transcript(text)
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}")
|
||||
return f"[음성 인식 실패: {str(e)}]"
|
||||
|
||||
def clean_transcript(self, text):
|
||||
"""Whisper 결과 후처리 - 반복/환각 제거"""
|
||||
import re
|
||||
|
||||
# 1. 영어/일본어/중국어 환각 제거
|
||||
text = re.sub(r'[A-Za-z]{3,}', '', text) # 3글자 이상 영어 제거
|
||||
text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 일본어 제거
|
||||
text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시)
|
||||
|
||||
# 2. 반복 문장 제거
|
||||
sentences = text.split('.')
|
||||
seen = set()
|
||||
unique_sentences = []
|
||||
for s in sentences:
|
||||
s_clean = s.strip()
|
||||
if s_clean and s_clean not in seen:
|
||||
seen.add(s_clean)
|
||||
unique_sentences.append(s_clean)
|
||||
|
||||
text = '. '.join(unique_sentences)
|
||||
|
||||
# 3. 이상한 문자 정리
|
||||
text = re.sub(r'\s+', ' ', text) # 다중 공백 제거
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
|
||||
def get_video_transcript(self, video_path):
|
||||
"""동영상 파일의 음성을 텍스트로 변환"""
|
||||
try:
|
||||
# 임시 오디오 파일 경로
|
||||
temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav"
|
||||
|
||||
# 1. 동영상에서 오디오 추출
|
||||
if not self.extract_audio_from_video(video_path, temp_audio):
|
||||
return self.get_basic_file_info(video_path) + "\n\n[오디오 추출 실패]"
|
||||
if (not temp_audio.exists()) or temp_audio.stat().st_size == 0:
|
||||
return self.get_basic_file_info(video_path) + "\n\n[오디오 파일 생성 실패]"
|
||||
|
||||
# 2. Whisper로 음성 인식
|
||||
transcript = self.transcribe_audio_with_whisper(temp_audio)
|
||||
|
||||
# 3. 임시 오디오 파일 삭제
|
||||
if temp_audio.exists():
|
||||
temp_audio.unlink()
|
||||
|
||||
# 4. 결과 포맷팅
|
||||
stat = video_path.stat()
|
||||
lines = []
|
||||
lines.append(f"동영상 파일 음성 전사 (Speech-to-Text)")
|
||||
lines.append(f"=" * 60)
|
||||
lines.append(f"파일명: {video_path.name}")
|
||||
lines.append(f"경로: {video_path}")
|
||||
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
|
||||
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append("")
|
||||
lines.append("=" * 60)
|
||||
lines.append("음성 내용:")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
lines.append(transcript)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
return self.get_basic_file_info(video_path) + f"\n\n[음성 인식 오류: {str(e)}]"
|
||||
|
||||
def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path):
|
||||
"""DWG TrueView를 사용한 DWG → PDF 변환"""
|
||||
if not self.trueview_path:
|
||||
return False, "DWG TrueView가 설치되지 않음"
|
||||
|
||||
try:
|
||||
# AutoCAD 스크립트 생성
|
||||
script_content = f"""_-EXPORT_PDF{pdf_path}_Y"""
|
||||
script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr"
|
||||
with open(script_path, 'w') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# TrueView 실행
|
||||
cmd = [
|
||||
self.trueview_path,
|
||||
str(dwg_path.absolute()),
|
||||
"/b", str(script_path.absolute()),
|
||||
"/nologo"
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, timeout=120, capture_output=True)
|
||||
|
||||
# 스크립트 파일 삭제
|
||||
if script_path.exists():
|
||||
try:
|
||||
script_path.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
# PDF 생성 확인
|
||||
if pdf_path.exists():
|
||||
return True, "성공"
|
||||
else:
|
||||
return False, "PDF 생성 실패"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "변환 시간 초과"
|
||||
except Exception as e:
|
||||
return False, f"DWG 변환 실패: {str(e)}"
|
||||
|
||||
def get_basic_file_info(self, file_path):
|
||||
"""기본 파일 정보 반환"""
|
||||
stat = file_path.stat()
|
||||
lines = []
|
||||
lines.append(f"파일 정보")
|
||||
lines.append(f"=" * 60)
|
||||
lines.append(f"파일명: {file_path.name}")
|
||||
lines.append(f"경로: {file_path}")
|
||||
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
|
||||
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"수정일: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_file_size(self, size_bytes):
|
||||
"""파일 크기를 읽기 쉬운 형식으로 변환"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.2f} TB"
|
||||
|
||||
def convert_image_to_pdf(self, image_path, output_path):
|
||||
"""이미지 파일을 PDF로 변환"""
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
# RGB 모드로 변환 (RGBA나 다른 모드 처리)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
# 흰색 배경 생성
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
img.save(output_path, 'PDF', resolution=100.0)
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"이미지 변환 실패: {str(e)}"
|
||||
|
||||
def convert_office_to_pdf(self, file_path, output_path):
|
||||
"""Office 문서를 PDF로 변환"""
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext in {'.hwp', '.hwpx'}:
|
||||
return self.convert_hwp_to_pdf(file_path, output_path)
|
||||
elif ext in {'.doc', '.docx'}:
|
||||
return self.convert_word_to_pdf(file_path, output_path)
|
||||
elif ext in {'.xls', '.xlsx'}:
|
||||
return self.convert_excel_to_pdf(file_path, output_path)
|
||||
elif ext in {'.ppt', '.pptx'}:
|
||||
return self.convert_ppt_to_pdf(file_path, output_path)
|
||||
else:
|
||||
return False, "지원하지 않는 Office 형식"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Office 변환 실패: {str(e)}"
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
def convert_word_to_pdf(self, file_path, output_path):
|
||||
"""Word 문서를 PDF로 변환"""
|
||||
try:
|
||||
word = win32com.client.Dispatch("Word.Application")
|
||||
word.Visible = False
|
||||
doc = word.Documents.Open(str(file_path.absolute()))
|
||||
doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF
|
||||
doc.Close()
|
||||
word.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"Word 변환 실패: {str(e)}"
|
||||
|
||||
def convert_excel_to_pdf(self, file_path, output_path):
|
||||
"""Excel 파일을 PDF로 변환 - 열 너비에 맞춰 출력"""
|
||||
try:
|
||||
excel = win32com.client.Dispatch("Excel.Application")
|
||||
excel.Visible = False
|
||||
wb = excel.Workbooks.Open(str(file_path.absolute()))
|
||||
|
||||
# 모든 시트에 대해 페이지 설정
|
||||
for ws in wb.Worksheets:
|
||||
# 페이지 설정
|
||||
ws.PageSetup.Zoom = False # 자동 크기 조정 비활성화
|
||||
ws.PageSetup.FitToPagesWide = 1 # 너비를 1페이지에 맞춤
|
||||
ws.PageSetup.FitToPagesTall = False # 높이는 자동 (내용에 따라)
|
||||
|
||||
# 여백 최소화 (단위: 포인트, 1cm ≈ 28.35 포인트)
|
||||
ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.RightMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.TopMargin = excel.CentimetersToPoints(1)
|
||||
ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1)
|
||||
|
||||
# 용지 방향 자동 결정 (가로가 긴 경우 가로 방향)
|
||||
used_range = ws.UsedRange
|
||||
if used_range.Columns.Count > used_range.Rows.Count:
|
||||
ws.PageSetup.Orientation = 2 # xlLandscape (가로)
|
||||
else:
|
||||
ws.PageSetup.Orientation = 1 # xlPortrait (세로)
|
||||
|
||||
# PDF로 저장
|
||||
wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF
|
||||
wb.Close()
|
||||
excel.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"Excel 변환 실패: {str(e)}"
|
||||
|
||||
|
||||
def convert_ppt_to_pdf(self, file_path, output_path):
|
||||
"""PowerPoint 파일을 PDF로 변환"""
|
||||
try:
|
||||
ppt = win32com.client.Dispatch("PowerPoint.Application")
|
||||
ppt.Visible = True
|
||||
presentation = ppt.Presentations.Open(str(file_path.absolute()))
|
||||
presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF
|
||||
presentation.Close()
|
||||
ppt.Quit()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"PowerPoint 변환 실패: {str(e)}"
|
||||
|
||||
def convert_hwp_to_pdf(self, file_path, output_path):
|
||||
hwp = None
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
|
||||
except Exception:
|
||||
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
|
||||
|
||||
registered = False
|
||||
last_reg_error = None
|
||||
|
||||
for module_name in getattr(self, "hwp_security_modules", [""]):
|
||||
try:
|
||||
hwp.RegisterModule("FilePathCheckDLL", module_name)
|
||||
registered = True
|
||||
break
|
||||
except Exception as e:
|
||||
last_reg_error = e
|
||||
|
||||
if not registered:
|
||||
return False, f"HWP 보안 모듈 등록 실패: {last_reg_error}"
|
||||
|
||||
hwp.Open(str(file_path.absolute()), "", "")
|
||||
|
||||
hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
|
||||
hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute())
|
||||
hwp.HParameterSet.HFileOpenSave.Format = "PDF"
|
||||
hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
|
||||
|
||||
if output_path.exists() and output_path.stat().st_size > 0:
|
||||
return True, "성공"
|
||||
return False, "PDF 생성 확인 실패"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"HWP 변환 실패: {str(e)}"
|
||||
finally:
|
||||
try:
|
||||
if hwp:
|
||||
try:
|
||||
hwp.Clear(1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
hwp.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def convert_text_to_pdf(self, text_path, output_path):
|
||||
"""텍스트 파일을 PDF로 변환 (reportlab 사용)"""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
# 한글 폰트 등록 (시스템에 설치된 폰트 사용)
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf'))
|
||||
font_name = 'Malgun'
|
||||
except:
|
||||
font_name = 'Helvetica'
|
||||
|
||||
# 텍스트 읽기
|
||||
with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# PDF 생성
|
||||
c = canvas.Canvas(str(output_path), pagesize=A4)
|
||||
width, height = A4
|
||||
|
||||
c.setFont(font_name, 10)
|
||||
|
||||
# 여백 설정
|
||||
margin = 50
|
||||
y = height - margin
|
||||
line_height = 14
|
||||
|
||||
# 줄 단위로 처리
|
||||
for line in content.split('\n'):
|
||||
if y < margin: # 페이지 넘김
|
||||
c.showPage()
|
||||
c.setFont(font_name, 10)
|
||||
y = height - margin
|
||||
|
||||
# 긴 줄은 자동으로 줄바꿈
|
||||
if len(line) > 100:
|
||||
chunks = [line[i:i+100] for i in range(0, len(line), 100)]
|
||||
for chunk in chunks:
|
||||
c.drawString(margin, y, chunk)
|
||||
y -= line_height
|
||||
else:
|
||||
c.drawString(margin, y, line)
|
||||
y -= line_height
|
||||
|
||||
c.save()
|
||||
return True, "성공"
|
||||
except Exception as e:
|
||||
return False, f"텍스트 변환 실패: {str(e)}"
|
||||
|
||||
def process_file(self, file_path):
|
||||
"""개별 파일 처리"""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# 출력 파일명 생성 (원본 경로 구조 유지)
|
||||
relative_path = file_path.relative_to(self.source_dir)
|
||||
output_subdir = self.output_dir / relative_path.parent
|
||||
output_subdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# PDF 파일명
|
||||
output_pdf = output_subdir / f"{file_path.stem}.pdf"
|
||||
|
||||
success = False
|
||||
message = ""
|
||||
|
||||
try:
|
||||
# 이미 PDF인 경우
|
||||
if ext in self.pdf_extension:
|
||||
shutil.copy2(file_path, output_pdf)
|
||||
success = True
|
||||
message = "PDF 복사 완료"
|
||||
|
||||
# DWG/DXF 파일
|
||||
elif ext in self.dwg_extensions:
|
||||
success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf)
|
||||
|
||||
# 이미지 파일
|
||||
elif ext in self.image_extensions:
|
||||
success, message = self.convert_image_to_pdf(file_path, output_pdf)
|
||||
|
||||
# Office 문서
|
||||
elif ext in self.office_extensions:
|
||||
success, message = self.convert_office_to_pdf(file_path, output_pdf)
|
||||
|
||||
# 동영상 파일 - 음성을 텍스트로 변환 후 PDF 생성
|
||||
elif ext in self.video_extensions:
|
||||
# 음성→텍스트 변환
|
||||
transcript_text = self.get_video_transcript(file_path)
|
||||
|
||||
# 임시 txt 파일 생성
|
||||
temp_txt = output_subdir / f"{file_path.stem}_transcript.txt"
|
||||
with open(temp_txt, 'w', encoding='utf-8') as f:
|
||||
f.write(transcript_text)
|
||||
|
||||
# txt를 PDF로 변환
|
||||
success, message = self.convert_text_to_pdf(temp_txt, output_pdf)
|
||||
|
||||
if success:
|
||||
message = "성공 (음성 인식 완료)"
|
||||
|
||||
# 임시 txt 파일은 남겨둠 (참고용)
|
||||
|
||||
# 텍스트 파일
|
||||
elif ext in self.text_extensions:
|
||||
success, message = self.convert_text_to_pdf(file_path, output_pdf)
|
||||
|
||||
else:
|
||||
message = f"지원하지 않는 파일 형식: {ext}"
|
||||
|
||||
except Exception as e:
|
||||
message = f"처리 중 오류: {str(e)}"
|
||||
|
||||
# 로그 기록
|
||||
self.conversion_log.append({
|
||||
'원본 경로': str(file_path),
|
||||
'파일명': file_path.name,
|
||||
'파일 형식': ext,
|
||||
'변환 PDF 경로': str(output_pdf) if success else "",
|
||||
'상태': "성공" if success else "실패",
|
||||
'메시지': message,
|
||||
'처리 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
return success, message
|
||||
|
||||
def create_excel_report(self, excel_path):
|
||||
"""변환 결과를 엑셀로 저장"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "변환 결과"
|
||||
|
||||
# 헤더 스타일
|
||||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
# 헤더 작성
|
||||
headers = ['번호', '원본 경로', '파일명', '파일 형식', '변환 PDF 경로', '상태', '메시지', '처리 시간']
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
# 데이터 작성
|
||||
for idx, log in enumerate(self.conversion_log, 2):
|
||||
ws.cell(row=idx, column=1, value=idx-1)
|
||||
ws.cell(row=idx, column=2, value=log['원본 경로'])
|
||||
ws.cell(row=idx, column=3, value=log['파일명'])
|
||||
ws.cell(row=idx, column=4, value=log['파일 형식'])
|
||||
ws.cell(row=idx, column=5, value=log['변환 PDF 경로'])
|
||||
|
||||
# 상태에 따라 색상 표시
|
||||
status_cell = ws.cell(row=idx, column=6, value=log['상태'])
|
||||
if log['상태'] == "성공":
|
||||
status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||
status_cell.font = Font(color="006100")
|
||||
else:
|
||||
status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
||||
status_cell.font = Font(color="9C0006")
|
||||
|
||||
ws.cell(row=idx, column=7, value=log['메시지'])
|
||||
ws.cell(row=idx, column=8, value=log['처리 시간'])
|
||||
|
||||
# 열 너비 자동 조정
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# 요약 시트 추가
|
||||
summary_ws = wb.create_sheet(title="요약")
|
||||
|
||||
total_files = len(self.conversion_log)
|
||||
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
|
||||
fail_count = total_files - success_count
|
||||
|
||||
summary_data = [
|
||||
['항목', '값'],
|
||||
['총 파일 수', total_files],
|
||||
['변환 성공', success_count],
|
||||
['변환 실패', fail_count],
|
||||
['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"],
|
||||
['', ''],
|
||||
['원본 폴더', str(self.source_dir)],
|
||||
['출력 폴더', str(self.output_dir)],
|
||||
['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
|
||||
]
|
||||
|
||||
for row_idx, row_data in enumerate(summary_data, 1):
|
||||
for col_idx, value in enumerate(row_data, 1):
|
||||
cell = summary_ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
if row_idx == 1:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left')
|
||||
|
||||
summary_ws.column_dimensions['A'].width = 20
|
||||
summary_ws.column_dimensions['B'].width = 60
|
||||
|
||||
# 저장
|
||||
wb.save(excel_path)
|
||||
print(f"\n엑셀 보고서 생성 완료: {excel_path}")
|
||||
|
||||
def run(self):
|
||||
"""전체 변환 작업 실행"""
|
||||
print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"원본 폴더: {self.source_dir}")
|
||||
print(f"출력 폴더: {self.output_dir}")
|
||||
|
||||
# DWG TrueView 확인
|
||||
if self.trueview_path:
|
||||
print(f"DWG TrueView 발견: {self.trueview_path}")
|
||||
else:
|
||||
print("경고: DWG TrueView를 찾을 수 없습니다. DWG 파일 변환이 불가능합니다.")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# 모든 파일 가져오기
|
||||
all_files = self.get_all_files()
|
||||
total_files = len(all_files)
|
||||
|
||||
# ★ 파일 분류: 동영상 vs 나머지
|
||||
video_files = []
|
||||
other_files = []
|
||||
|
||||
for file_path in all_files:
|
||||
if file_path.suffix.lower() in self.video_extensions:
|
||||
video_files.append(file_path)
|
||||
else:
|
||||
other_files.append(file_path)
|
||||
|
||||
print(f"\n총 {total_files}개 파일 발견")
|
||||
print(f" - 문서/이미지 등: {len(other_files)}개")
|
||||
print(f" - 동영상: {len(video_files)}개")
|
||||
print("\n[1단계] 문서 파일 변환 시작...\n")
|
||||
|
||||
# ★ 1단계: 문서 파일 먼저 처리
|
||||
for idx, file_path in enumerate(other_files, 1):
|
||||
print(f"[{idx}/{len(other_files)}] {file_path.name} 처리 중...", end=' ')
|
||||
success, message = self.process_file(file_path)
|
||||
print(f"{'✓' if success else '✗'} {message}")
|
||||
|
||||
# ★ 2단계: domain.txt 로드
|
||||
domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테스트 중(측량)\domain.txt
|
||||
if domain_path.exists():
|
||||
self.domain_terms = domain_path.read_text(encoding='utf-8')
|
||||
print(f"\n[2단계] 도메인 용어 사전 로드 완료: {domain_path}")
|
||||
print(f" - 용어 수: 약 {len(self.domain_terms.split())}개 단어")
|
||||
else:
|
||||
print(f"\n[2단계] 도메인 용어 사전 없음: {domain_path}")
|
||||
print(" - 기본 음성 인식으로 진행합니다.")
|
||||
|
||||
# ★ 3단계: 동영상 파일 처리
|
||||
if video_files:
|
||||
print(f"\n[3단계] 동영상 음성 인식 시작...\n")
|
||||
for idx, file_path in enumerate(video_files, 1):
|
||||
print(f"[{idx}/{len(video_files)}] {file_path.name} 처리 중...", end=' ')
|
||||
success, message = self.process_file(file_path)
|
||||
print(f"{'✓' if success else '✗'} {message}")
|
||||
|
||||
# 엑셀 보고서 생성
|
||||
excel_path = self.output_dir / f"변환_결과_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
self.create_excel_report(excel_path)
|
||||
|
||||
# 최종 요약
|
||||
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
|
||||
print("\n" + "=" * 80)
|
||||
print(f"작업 완료!")
|
||||
print(f"총 파일: {total_files}개")
|
||||
print(f"성공: {success_count}개")
|
||||
print(f"실패: {total_files - success_count}개")
|
||||
print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%")
|
||||
print("=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 경로 설정
|
||||
SOURCE_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\in"
|
||||
OUTPUT_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out"
|
||||
|
||||
# 변환기 실행
|
||||
converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR)
|
||||
converter.run()
|
||||
789
03. Code/geulbeot_8th/converters/pipeline/step2_extract.py
Normal file
@@ -0,0 +1,789 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
extract_1_v2.py
|
||||
|
||||
PDF에서 텍스트(md)와 이미지(png)를 추출
|
||||
- 하위 폴더 구조 유지
|
||||
- 이미지 메타데이터 JSON 생성 (폴더경로, 파일명, 페이지, 위치, 캡션 등)
|
||||
"""
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# ===== OCR 설정 (선택적) =====
|
||||
try:
|
||||
import pytesseract
|
||||
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
TESSERACT_AVAILABLE = True
|
||||
except ImportError:
|
||||
TESSERACT_AVAILABLE = False
|
||||
print("[INFO] pytesseract 미설치 - 텍스트 잘림 필터 비활성화")
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
BASE_DIR = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") # PDF 원본 위치
|
||||
OUTPUT_BASE = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
|
||||
CAPTION_PATTERN = re.compile(
|
||||
r'^\s*(?:[<\[\(\{]\s*)?(그림|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def get_figure_rects(page):
|
||||
"""
|
||||
Identifies figure regions based on '<그림 N>' captions and vector drawings.
|
||||
Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index}
|
||||
"""
|
||||
drawings = page.get_drawings()
|
||||
|
||||
blocks = page.get_text("blocks")
|
||||
captions = []
|
||||
|
||||
for i, b in enumerate(blocks):
|
||||
text = b[4]
|
||||
if CAPTION_PATTERN.search(text):
|
||||
captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []})
|
||||
|
||||
if not captions:
|
||||
return []
|
||||
|
||||
filtered_drawings_rects = []
|
||||
for d in drawings:
|
||||
r = d["rect"]
|
||||
if r.height > page.rect.height / 3 and r.width < 5:
|
||||
continue
|
||||
if r.width > page.rect.width * 0.9:
|
||||
continue
|
||||
filtered_drawings_rects.append(r)
|
||||
|
||||
page_area = page.rect.get_area()
|
||||
img_rects = []
|
||||
for b in page.get_text("dict")["blocks"]:
|
||||
if b.get("type") == 1:
|
||||
ir = fitz.Rect(b["bbox"])
|
||||
if ir.get_area() < page_area * 0.01:
|
||||
continue
|
||||
img_rects.append(ir)
|
||||
|
||||
remaining_drawings = filtered_drawings_rects + img_rects
|
||||
caption_clusters = {cap['index']: [cap['rect']] for cap in captions}
|
||||
|
||||
def is_text_between(r1, r2, text_blocks):
|
||||
if r1.intersects(r2):
|
||||
return False
|
||||
union = r1 | r2
|
||||
for b in text_blocks:
|
||||
b_rect = fitz.Rect(b[:4])
|
||||
text_content = b[4]
|
||||
if len(text_content.strip()) < 20:
|
||||
continue
|
||||
if not b_rect.intersects(union):
|
||||
continue
|
||||
if b_rect.intersects(r1) or b_rect.intersects(r2):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
to_remove = []
|
||||
|
||||
for d_rect in remaining_drawings:
|
||||
best_cluster_key = None
|
||||
min_dist = float('inf')
|
||||
|
||||
for cap_index, cluster_rects in caption_clusters.items():
|
||||
for r in cluster_rects:
|
||||
dist = 0
|
||||
if d_rect.intersects(r):
|
||||
dist = 0
|
||||
else:
|
||||
x_dist = 0
|
||||
if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1
|
||||
elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1
|
||||
|
||||
y_dist = 0
|
||||
if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1
|
||||
elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1
|
||||
|
||||
if x_dist < 150 and y_dist < 150:
|
||||
dist = max(x_dist, y_dist) + 0.1
|
||||
else:
|
||||
dist = float('inf')
|
||||
|
||||
if dist < min_dist:
|
||||
if not is_text_between(r, d_rect, blocks):
|
||||
min_dist = dist
|
||||
best_cluster_key = cap_index
|
||||
|
||||
if min_dist == 0:
|
||||
break
|
||||
|
||||
if best_cluster_key is not None and min_dist < 150:
|
||||
caption_clusters[best_cluster_key].append(d_rect)
|
||||
to_remove.append(d_rect)
|
||||
changed = True
|
||||
|
||||
for r in to_remove:
|
||||
remaining_drawings.remove(r)
|
||||
|
||||
figure_regions = []
|
||||
|
||||
for cap in captions:
|
||||
cluster_rects = caption_clusters[cap['index']]
|
||||
content_rects = cluster_rects[1:]
|
||||
|
||||
if not content_rects:
|
||||
continue
|
||||
|
||||
union_rect = content_rects[0]
|
||||
for r in content_rects[1:]:
|
||||
union_rect = union_rect | r
|
||||
|
||||
union_rect.x0 = max(0, union_rect.x0 - 5)
|
||||
union_rect.x1 = min(page.rect.width, union_rect.x1 + 5)
|
||||
union_rect.y0 = max(0, union_rect.y0 - 5)
|
||||
union_rect.y1 = min(page.rect.height, union_rect.y1 + 5)
|
||||
|
||||
cap_rect = cap['rect']
|
||||
|
||||
if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2:
|
||||
if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2
|
||||
else:
|
||||
if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2
|
||||
|
||||
area = union_rect.get_area()
|
||||
page_area = page.rect.get_area()
|
||||
|
||||
if area < page_area * 0.01:
|
||||
continue
|
||||
|
||||
if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6:
|
||||
continue
|
||||
if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6:
|
||||
continue
|
||||
|
||||
text_blocks = page.get_text("blocks")
|
||||
text_count = 0
|
||||
|
||||
for b in text_blocks:
|
||||
b_rect = fitz.Rect(b[:4])
|
||||
if not b_rect.intersects(union_rect):
|
||||
continue
|
||||
text = b[4].strip()
|
||||
if len(text) < 5:
|
||||
continue
|
||||
text_count += 1
|
||||
|
||||
if text_count < 0:
|
||||
continue
|
||||
|
||||
figure_regions.append({
|
||||
'rect': union_rect,
|
||||
'caption_index': cap['index'],
|
||||
'caption_rect': cap['rect'],
|
||||
'caption_text': cap['text'].strip() # ★ 캡션 텍스트 저장
|
||||
})
|
||||
|
||||
return figure_regions
|
||||
|
||||
|
||||
def pixmap_metrics(pix):
|
||||
arr = np.frombuffer(pix.samples, dtype=np.uint8)
|
||||
c = 4 if pix.alpha else 3
|
||||
arr = arr.reshape(pix.height, pix.width, c)[:, :, :3]
|
||||
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8)
|
||||
white = gray > 245
|
||||
nonwhite_ratio = float(1.0 - white.mean())
|
||||
gx = np.abs(np.diff(gray.astype(np.int16), axis=1))
|
||||
gy = np.abs(np.diff(gray.astype(np.int16), axis=0))
|
||||
edge = (gx[:-1, :] + gy[:, :-1]) > 40
|
||||
edge_ratio = float(edge.mean())
|
||||
var = float(gray.var())
|
||||
return nonwhite_ratio, edge_ratio, var
|
||||
|
||||
|
||||
def keep_figure(pix):
|
||||
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
|
||||
if nonwhite_ratio < 0.004:
|
||||
return False, nonwhite_ratio, edge_ratio, var
|
||||
if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20:
|
||||
return False, nonwhite_ratio, edge_ratio, var
|
||||
return True, nonwhite_ratio, edge_ratio, var
|
||||
|
||||
|
||||
# ===== 추가 이미지 필터 함수들 (v2.1) =====
|
||||
|
||||
def pix_to_pil(pix):
|
||||
"""PyMuPDF Pixmap을 PIL Image로 변환"""
|
||||
img_data = pix.tobytes("png")
|
||||
return Image.open(io.BytesIO(img_data))
|
||||
|
||||
|
||||
def has_cut_text_at_boundary(pix, margin=5):
|
||||
"""
|
||||
이미지 경계에서 텍스트가 잘렸는지 감지
|
||||
- 이미지 테두리 근처에 텍스트 박스가 있으면 잘린 것으로 판단
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
margin: 경계로부터의 여유 픽셀 (기본 5px)
|
||||
|
||||
Returns:
|
||||
bool: 텍스트가 잘렸으면 True
|
||||
"""
|
||||
if not TESSERACT_AVAILABLE:
|
||||
return False # OCR 없으면 필터 비활성화
|
||||
|
||||
try:
|
||||
img = pix_to_pil(pix)
|
||||
width, height = img.size
|
||||
|
||||
# OCR로 텍스트 위치 추출
|
||||
data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT)
|
||||
|
||||
for i, text in enumerate(data['text']):
|
||||
text = str(text).strip()
|
||||
if len(text) < 2: # 너무 짧은 텍스트는 무시
|
||||
continue
|
||||
|
||||
x = data['left'][i]
|
||||
y = data['top'][i]
|
||||
w = data['width'][i]
|
||||
h = data['height'][i]
|
||||
|
||||
# 텍스트가 이미지 경계에 너무 가까우면 = 잘린 것
|
||||
# 왼쪽 경계
|
||||
if x <= margin:
|
||||
return True
|
||||
# 오른쪽 경계
|
||||
if x + w >= width - margin:
|
||||
return True
|
||||
# 상단 경계 (헤더 제외를 위해 좀 더 여유)
|
||||
if y <= margin and h < height * 0.3:
|
||||
return True
|
||||
# 하단 경계
|
||||
if y + h >= height - margin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# OCR 실패 시 필터 통과 (이미지 유지)
|
||||
return False
|
||||
|
||||
|
||||
def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500):
|
||||
"""
|
||||
배경 패턴 + 텍스트만 있는 장식용 이미지인지 감지
|
||||
- 엣지가 적고 (복잡한 도표/사진이 아님)
|
||||
- 색상 다양성이 낮으면 (단순 그라데이션 배경)
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
edge_threshold: 엣지 비율 임계값 (기본 0.02 = 2%)
|
||||
color_var_threshold: 색상 분산 임계값
|
||||
|
||||
Returns:
|
||||
bool: 장식용 배경이면 True
|
||||
"""
|
||||
try:
|
||||
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
|
||||
|
||||
# 엣지가 거의 없고 (단순한 이미지)
|
||||
# 색상 분산도 낮으면 (배경 패턴)
|
||||
if edge_ratio < edge_threshold and var < color_var_threshold:
|
||||
# 추가 확인: 텍스트만 있는지 OCR로 체크
|
||||
if TESSERACT_AVAILABLE:
|
||||
try:
|
||||
img = pix_to_pil(pix)
|
||||
text = pytesseract.image_to_string(img, lang='kor+eng').strip()
|
||||
|
||||
# 텍스트가 있고, 이미지가 단순하면 = 텍스트 배경
|
||||
if len(text) > 3 and edge_ratio < 0.015:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_header_footer_region(rect, page_rect, height_threshold=0.12):
|
||||
"""
|
||||
헤더/푸터 영역에 있는 이미지인지 감지
|
||||
- 페이지 상단 12% 또는 하단 12%에 위치
|
||||
- 높이가 낮은 strip 형태
|
||||
|
||||
Args:
|
||||
rect: 이미지 영역 (fitz.Rect)
|
||||
page_rect: 페이지 전체 영역 (fitz.Rect)
|
||||
height_threshold: 헤더/푸터 영역 비율 (기본 12%)
|
||||
|
||||
Returns:
|
||||
bool: 헤더/푸터 영역이면 True
|
||||
"""
|
||||
page_height = page_rect.height
|
||||
img_height = rect.height
|
||||
|
||||
# 상단 영역 체크
|
||||
if rect.y0 < page_height * height_threshold:
|
||||
# 높이가 페이지의 15% 미만인 strip이면 헤더
|
||||
if img_height < page_height * 0.15:
|
||||
return True
|
||||
|
||||
# 하단 영역 체크
|
||||
if rect.y1 > page_height * (1 - height_threshold):
|
||||
# 높이가 페이지의 15% 미만인 strip이면 푸터
|
||||
if img_height < page_height * 0.15:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_filter_image(pix, rect, page_rect):
|
||||
"""
|
||||
이미지를 필터링해야 하는지 종합 판단
|
||||
|
||||
Args:
|
||||
pix: PyMuPDF Pixmap
|
||||
rect: 이미지 영역
|
||||
page_rect: 페이지 전체 영역
|
||||
|
||||
Returns:
|
||||
tuple: (필터링 여부, 필터링 사유)
|
||||
"""
|
||||
# 1. 헤더/푸터 영역 체크
|
||||
if is_header_footer_region(rect, page_rect):
|
||||
return True, "header_footer"
|
||||
|
||||
# 2. 텍스트 잘림 체크
|
||||
if has_cut_text_at_boundary(pix):
|
||||
return True, "cut_text"
|
||||
|
||||
# 3. 장식용 배경 체크
|
||||
if is_decorative_background(pix):
|
||||
return True, "decorative_background"
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata):
|
||||
"""
|
||||
PDF 내용 추출
|
||||
|
||||
Args:
|
||||
pdf_path: PDF 파일 경로
|
||||
output_md_path: 출력 MD 파일 경로
|
||||
img_dir: 이미지 저장 폴더
|
||||
metadata: 메타데이터 딕셔너리 (폴더 경로, 파일명 등)
|
||||
|
||||
Returns:
|
||||
image_metadata_list: 추출된 이미지들의 메타데이터 리스트
|
||||
"""
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
|
||||
image_metadata_list = [] # ★ 이미지 메타데이터 수집
|
||||
|
||||
doc = fitz.open(pdf_path)
|
||||
total_pages = len(doc)
|
||||
|
||||
with open(output_md_path, "w", encoding="utf-8") as md_file:
|
||||
# ★ 메타데이터 헤더 추가
|
||||
md_file.write(f"---\n")
|
||||
md_file.write(f"source_pdf: {metadata['pdf_name']}\n")
|
||||
md_file.write(f"source_folder: {metadata['relative_folder']}\n")
|
||||
md_file.write(f"total_pages: {total_pages}\n")
|
||||
md_file.write(f"extracted_at: {datetime.now().isoformat()}\n")
|
||||
md_file.write(f"---\n\n")
|
||||
md_file.write(f"# {metadata['pdf_name']}\n\n")
|
||||
|
||||
for page_num, page in enumerate(doc):
|
||||
md_file.write(f"\n## Page {page_num + 1}\n\n")
|
||||
img_rel_dir = os.path.basename(img_dir)
|
||||
|
||||
figure_regions = get_figure_rects(page)
|
||||
|
||||
kept_figures = []
|
||||
for i, fig in enumerate(figure_regions):
|
||||
rect = fig['rect']
|
||||
pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB)
|
||||
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB)
|
||||
|
||||
# ★ 추가 필터 적용 (v2.1)
|
||||
should_filter, filter_reason = should_filter_image(pix, rect, page.rect)
|
||||
if should_filter:
|
||||
continue
|
||||
|
||||
img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png"
|
||||
img_path = os.path.join(img_dir, img_name)
|
||||
pix.save(img_path)
|
||||
|
||||
fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/")
|
||||
fig['img_name'] = img_name
|
||||
kept_figures.append(fig)
|
||||
|
||||
# ★ 이미지 메타데이터 수집
|
||||
image_metadata_list.append({
|
||||
"image_file": img_name,
|
||||
"image_path": str(Path(img_dir) / img_name),
|
||||
"type": "figure",
|
||||
"source_pdf": metadata['pdf_name'],
|
||||
"source_folder": metadata['relative_folder'],
|
||||
"full_path": metadata['full_path'],
|
||||
"page": page_num + 1,
|
||||
"total_pages": total_pages,
|
||||
"caption": fig.get('caption_text', ''),
|
||||
"rect": {
|
||||
"x0": round(rect.x0, 2),
|
||||
"y0": round(rect.y0, 2),
|
||||
"x1": round(rect.x1, 2),
|
||||
"y1": round(rect.y1, 2)
|
||||
}
|
||||
})
|
||||
|
||||
figure_regions = kept_figures
|
||||
|
||||
caption_present = any(
|
||||
CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks")
|
||||
)
|
||||
uncaptioned_idx = 0
|
||||
|
||||
items = []
|
||||
|
||||
def inside_any_figure(block_rect, figures):
|
||||
for fig in figures:
|
||||
intersect = block_rect & fig["rect"]
|
||||
if intersect.get_area() > 0.5 * block_rect.get_area():
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_full_width_rect(r, page_rect):
|
||||
return r.width >= page_rect.width * 0.78
|
||||
|
||||
def figure_anchor_rect(fig, page_rect):
|
||||
cap = fig["caption_rect"]
|
||||
rect = fig["rect"]
|
||||
if cap.y0 >= rect.y0:
|
||||
y = max(0.0, cap.y0 - 0.02)
|
||||
else:
|
||||
y = min(page_rect.height - 0.02, cap.y1 + 0.02)
|
||||
return fitz.Rect(cap.x0, y, cap.x1, y + 0.02)
|
||||
|
||||
for fig in figure_regions:
|
||||
anchor = figure_anchor_rect(fig, page.rect)
|
||||
md = (
|
||||
f"\n\n"
|
||||
f"*{fig.get('caption_text', '')}*\n\n"
|
||||
)
|
||||
items.append({
|
||||
"kind": "figure",
|
||||
"rect": anchor,
|
||||
"kind_order": 0,
|
||||
"md": md,
|
||||
})
|
||||
|
||||
raw_blocks = page.get_text("dict")["blocks"]
|
||||
|
||||
for block in raw_blocks:
|
||||
block_rect = fitz.Rect(block["bbox"])
|
||||
|
||||
if block.get("type") == 0:
|
||||
if inside_any_figure(block_rect, figure_regions):
|
||||
continue
|
||||
items.append({
|
||||
"kind": "text",
|
||||
"rect": block_rect,
|
||||
"kind_order": 2,
|
||||
"block": block,
|
||||
})
|
||||
continue
|
||||
|
||||
if block.get("type") == 1:
|
||||
if inside_any_figure(block_rect, figure_regions):
|
||||
continue
|
||||
if caption_present:
|
||||
continue
|
||||
|
||||
page_area = page.rect.get_area()
|
||||
if block_rect.get_area() < page_area * 0.005:
|
||||
continue
|
||||
|
||||
ratio = block_rect.width / max(1.0, block_rect.height)
|
||||
if ratio < 0.25 or ratio > 4.0:
|
||||
continue
|
||||
|
||||
pix_preview = page.get_pixmap(
|
||||
clip=block_rect, dpi=80, colorspace=fitz.csRGB
|
||||
)
|
||||
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
pix = page.get_pixmap(
|
||||
clip=block_rect, dpi=150, colorspace=fitz.csRGB
|
||||
)
|
||||
|
||||
# ★ 추가 필터 적용 (v2.1)
|
||||
should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect)
|
||||
if should_filter:
|
||||
continue
|
||||
|
||||
img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png"
|
||||
img_path = os.path.join(img_dir, img_name)
|
||||
pix.save(img_path)
|
||||
|
||||
rel = os.path.join(img_rel_dir, img_name).replace("\\", "/")
|
||||
r = block_rect
|
||||
md = (
|
||||
f'\n\n'
|
||||
f'*Page {page_num + 1} Photo*\n\n'
|
||||
)
|
||||
|
||||
items.append({
|
||||
"kind": "raster",
|
||||
"rect": block_rect,
|
||||
"kind_order": 1,
|
||||
"md": md,
|
||||
})
|
||||
|
||||
# ★ 캡션 없는 이미지 메타데이터
|
||||
image_metadata_list.append({
|
||||
"image_file": img_name,
|
||||
"image_path": str(Path(img_dir) / img_name),
|
||||
"type": "photo",
|
||||
"source_pdf": metadata['pdf_name'],
|
||||
"source_folder": metadata['relative_folder'],
|
||||
"full_path": metadata['full_path'],
|
||||
"page": page_num + 1,
|
||||
"total_pages": total_pages,
|
||||
"caption": "",
|
||||
"rect": {
|
||||
"x0": round(r.x0, 2),
|
||||
"y0": round(r.y0, 2),
|
||||
"x1": round(r.x1, 2),
|
||||
"y1": round(r.y1, 2)
|
||||
}
|
||||
})
|
||||
|
||||
uncaptioned_idx += 1
|
||||
continue
|
||||
|
||||
# 읽기 순서 정렬
|
||||
text_items = [it for it in items if it["kind"] == "text"]
|
||||
page_w = page.rect.width
|
||||
mid = page_w / 2.0
|
||||
|
||||
candidates = []
|
||||
for it in text_items:
|
||||
r = it["rect"]
|
||||
if is_full_width_rect(r, page.rect):
|
||||
continue
|
||||
if r.width < page_w * 0.2:
|
||||
continue
|
||||
candidates.append(it)
|
||||
|
||||
left = [it for it in candidates if it["rect"].x0 < mid * 0.95]
|
||||
right = [it for it in candidates if it["rect"].x0 > mid * 1.05]
|
||||
two_cols = len(left) >= 3 and len(right) >= 3
|
||||
|
||||
col_y0 = None
|
||||
col_y1 = None
|
||||
seps = []
|
||||
|
||||
if two_cols and left and right:
|
||||
col_y0 = min(
|
||||
min(it["rect"].y0 for it in left),
|
||||
min(it["rect"].y0 for it in right),
|
||||
)
|
||||
col_y1 = max(
|
||||
max(it["rect"].y1 for it in left),
|
||||
max(it["rect"].y1 for it in right),
|
||||
)
|
||||
for it in text_items:
|
||||
r = it["rect"]
|
||||
if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect):
|
||||
seps.append(r.y0)
|
||||
seps = sorted(set(seps))
|
||||
|
||||
def seg_index(y0, separators):
|
||||
if not separators:
|
||||
return 0
|
||||
n = 0
|
||||
for s in separators:
|
||||
if y0 >= s:
|
||||
n += 1
|
||||
else:
|
||||
break
|
||||
return n
|
||||
|
||||
def order_key(it):
|
||||
r = it["rect"]
|
||||
if not two_cols:
|
||||
return (r.y0, r.x0, it["kind_order"])
|
||||
if col_y0 is not None and r.y1 <= col_y0:
|
||||
return (0, r.y0, r.x0, it["kind_order"])
|
||||
if col_y1 is not None and r.y0 >= col_y1:
|
||||
return (2, r.y0, r.x0, it["kind_order"])
|
||||
seg = seg_index(r.y0, seps)
|
||||
if is_full_width_rect(r, page.rect):
|
||||
col = 2
|
||||
else:
|
||||
col = 0 if r.x0 < mid else 1
|
||||
return (1, seg, col, r.y0, r.x0, it["kind_order"])
|
||||
|
||||
items.sort(key=order_key)
|
||||
|
||||
for it in items:
|
||||
if it["kind"] in ("figure", "raster"):
|
||||
md_file.write(it["md"])
|
||||
continue
|
||||
|
||||
block = it["block"]
|
||||
for line in block.get("lines", []):
|
||||
for span in line.get("spans", []):
|
||||
md_file.write(span.get("text", "") + " ")
|
||||
md_file.write("\n")
|
||||
md_file.write("\n")
|
||||
|
||||
doc.close()
|
||||
return image_metadata_list
|
||||
|
||||
|
||||
def process_all_pdfs():
|
||||
"""
|
||||
BASE_DIR 하위의 모든 PDF를 재귀적으로 처리
|
||||
폴더 구조를 유지하면서 OUTPUT_BASE에 저장
|
||||
"""
|
||||
# 출력 폴더 생성
|
||||
OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 전체 이미지 메타데이터 수집
|
||||
all_image_metadata = []
|
||||
|
||||
# 처리 통계
|
||||
stats = {
|
||||
"total_pdfs": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"total_images": 0
|
||||
}
|
||||
|
||||
# 실패 로그
|
||||
failed_files = []
|
||||
|
||||
print(f"=" * 60)
|
||||
print(f"PDF 추출 시작")
|
||||
print(f"원본 폴더: {BASE_DIR}")
|
||||
print(f"출력 폴더: {OUTPUT_BASE}")
|
||||
print(f"=" * 60)
|
||||
|
||||
# 모든 PDF 파일 찾기
|
||||
pdf_files = list(BASE_DIR.rglob("*.pdf"))
|
||||
stats["total_pdfs"] = len(pdf_files)
|
||||
|
||||
print(f"\n총 {len(pdf_files)}개 PDF 발견\n")
|
||||
|
||||
for idx, pdf_path in enumerate(pdf_files, 1):
|
||||
try:
|
||||
# 상대 경로 계산
|
||||
relative_path = pdf_path.relative_to(BASE_DIR)
|
||||
relative_folder = str(relative_path.parent)
|
||||
if relative_folder == ".":
|
||||
relative_folder = ""
|
||||
|
||||
pdf_name = pdf_path.name
|
||||
pdf_stem = pdf_path.stem
|
||||
|
||||
# 출력 경로 설정 (폴더 구조 유지)
|
||||
output_folder = OUTPUT_BASE / relative_path.parent
|
||||
output_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_md = output_folder / f"{pdf_stem}.md"
|
||||
img_folder = output_folder / f"{pdf_stem}_img"
|
||||
|
||||
# 메타데이터 준비
|
||||
metadata = {
|
||||
"pdf_name": pdf_name,
|
||||
"pdf_stem": pdf_stem,
|
||||
"relative_folder": relative_folder,
|
||||
"full_path": str(relative_path),
|
||||
}
|
||||
|
||||
print(f"[{idx}/{len(pdf_files)}] {relative_path}")
|
||||
|
||||
# PDF 처리
|
||||
image_metas = extract_pdf_content(
|
||||
str(pdf_path),
|
||||
str(output_md),
|
||||
str(img_folder),
|
||||
metadata
|
||||
)
|
||||
|
||||
all_image_metadata.extend(image_metas)
|
||||
stats["success"] += 1
|
||||
stats["total_images"] += len(image_metas)
|
||||
|
||||
print(f" ✓ 완료 (이미지 {len(image_metas)}개)")
|
||||
|
||||
except Exception as e:
|
||||
stats["failed"] += 1
|
||||
failed_files.append({
|
||||
"file": str(pdf_path),
|
||||
"error": str(e)
|
||||
})
|
||||
print(f" ✗ 실패: {e}")
|
||||
|
||||
# 전체 이미지 메타데이터 저장
|
||||
meta_output_path = OUTPUT_BASE / "image_metadata.json"
|
||||
with open(meta_output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(all_image_metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 처리 요약 저장
|
||||
summary = {
|
||||
"processed_at": datetime.now().isoformat(),
|
||||
"source_dir": str(BASE_DIR),
|
||||
"output_dir": str(OUTPUT_BASE),
|
||||
"statistics": stats,
|
||||
"failed_files": failed_files
|
||||
}
|
||||
|
||||
summary_path = OUTPUT_BASE / "extraction_summary.json"
|
||||
with open(summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 결과 출력
|
||||
print(f"\n" + "=" * 60)
|
||||
print(f"추출 완료!")
|
||||
print(f"=" * 60)
|
||||
print(f"총 PDF: {stats['total_pdfs']}개")
|
||||
print(f"성공: {stats['success']}개")
|
||||
print(f"실패: {stats['failed']}개")
|
||||
print(f"추출된 이미지: {stats['total_images']}개")
|
||||
print(f"\n이미지 메타데이터: {meta_output_path}")
|
||||
print(f"처리 요약: {summary_path}")
|
||||
|
||||
if failed_files:
|
||||
print(f"\n실패한 파일:")
|
||||
for f in failed_files:
|
||||
print(f" - {f['file']}: {f['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process_all_pdfs()
|
||||
265
03. Code/geulbeot_8th/converters/pipeline/step3_domain.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
domain_prompt.py
|
||||
|
||||
기능:
|
||||
- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파일들의
|
||||
파일명과 내용 일부를 샘플링한다.
|
||||
- 이 샘플을 기반으로, 문서 묶음의 분야/업무 맥락을 파악하고
|
||||
"너는 ~~ 분야의 전문가이다. 나는 ~~를 하고 싶다..." 형식의
|
||||
도메인 전용 시스템 프롬프트를 자동 생성한다.
|
||||
- 결과는 output/context/domain_prompt.txt 로 저장된다.
|
||||
|
||||
이 domain_prompt.txt 내용은 이후 모든 GPT 호출(system role)에 공통으로 붙여 사용할 수 있다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
import pandas as pd
|
||||
from openai import OpenAI
|
||||
import pytesseract
|
||||
from api_config import API_KEYS
|
||||
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조만 유지, 키는 마스터가 직접 입력) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== OCR 설정 =====
|
||||
OCR_LANG = "kor+eng"
|
||||
|
||||
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"}
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
|
||||
def safe_rel(p: Path) -> str:
|
||||
try:
|
||||
return str(p.relative_to(DATA_ROOT))
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
|
||||
def ocr_image(img_path: Path) -> str:
|
||||
try:
|
||||
return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip()
|
||||
except Exception as e:
|
||||
log(f"[WARN] OCR 실패: {safe_rel(img_path)} | {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def sample_from_pdf(p: Path, max_chars: int = 1000) -> str:
|
||||
texts = []
|
||||
try:
|
||||
with pdfplumber.open(str(p)) as pdf:
|
||||
# 앞쪽 몇 페이지만 샘플링
|
||||
for page in pdf.pages[:3]:
|
||||
t = page.extract_text() or ""
|
||||
if t:
|
||||
texts.append(t)
|
||||
if sum(len(x) for x in texts) >= max_chars:
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"[WARN] PDF 샘플 추출 실패: {safe_rel(p)} | {e}")
|
||||
joined = "\n".join(texts)
|
||||
return joined[:max_chars]
|
||||
|
||||
|
||||
def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str:
|
||||
texts = [f"[파일명] {p.name}"]
|
||||
try:
|
||||
xls = pd.ExcelFile(str(p))
|
||||
for sheet_name in xls.sheet_names[:3]:
|
||||
try:
|
||||
df = xls.parse(sheet_name)
|
||||
except Exception as e:
|
||||
log(f"[WARN] 시트 로딩 실패: {safe_rel(p)} | {sheet_name} | {e}")
|
||||
continue
|
||||
texts.append(f"\n[시트] {sheet_name}")
|
||||
texts.append("컬럼: " + ", ".join(map(str, df.columns)))
|
||||
head = df.head(5)
|
||||
texts.append(head.to_string(index=False))
|
||||
if sum(len(x) for x in texts) >= max_chars:
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"[WARN] XLSX 샘플 추출 실패: {safe_rel(p)} | {e}")
|
||||
joined = "\n".join(texts)
|
||||
return joined[:max_chars]
|
||||
|
||||
|
||||
def sample_from_text_file(p: Path, max_chars: int = 1000) -> str:
|
||||
try:
|
||||
t = p.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
t = p.read_text(encoding="cp949", errors="ignore")
|
||||
return t[:max_chars]
|
||||
|
||||
|
||||
def gather_file_samples(
|
||||
max_files_per_type: int = 100,
|
||||
max_total_samples: int = 300,
|
||||
max_chars_per_sample: int = 1000,
|
||||
):
|
||||
|
||||
file_names = []
|
||||
samples = []
|
||||
|
||||
count_pdf = 0
|
||||
count_xlsx = 0
|
||||
count_img = 0
|
||||
count_txt = 0
|
||||
|
||||
for root, dirs, files in os.walk(DATA_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
|
||||
cur_dir = Path(root)
|
||||
|
||||
for fname in files:
|
||||
fpath = cur_dir / fname
|
||||
ext = fpath.suffix.lower()
|
||||
|
||||
# 파일명은 전체 다 모으되, 샘플 추출은 제한
|
||||
file_names.append(safe_rel(fpath))
|
||||
|
||||
if len(samples) >= max_total_samples:
|
||||
continue
|
||||
|
||||
try:
|
||||
if ext == ".pdf" and count_pdf < max_files_per_type:
|
||||
s = sample_from_pdf(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[PDF] {safe_rel(fpath)}\n{s}")
|
||||
count_pdf += 1
|
||||
continue
|
||||
|
||||
if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type:
|
||||
s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}")
|
||||
count_xlsx += 1
|
||||
continue
|
||||
|
||||
if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type:
|
||||
s = ocr_image(fpath)
|
||||
if s.strip():
|
||||
samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}")
|
||||
count_img += 1
|
||||
continue
|
||||
|
||||
if ext in {".txt", ".md"} and count_txt < max_files_per_type:
|
||||
s = sample_from_text_file(fpath, max_chars=max_chars_per_sample)
|
||||
if s.strip():
|
||||
samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}")
|
||||
count_txt += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
log(f"[WARN] 샘플 추출 실패: {safe_rel(fpath)} | {e}")
|
||||
continue
|
||||
|
||||
return file_names, samples
|
||||
|
||||
|
||||
def build_domain_prompt():
|
||||
"""
|
||||
파일명 + 내용 샘플을 GPT에게 넘겨
|
||||
'너는 ~~ 분야의 전문가이다...' 형태의 시스템 프롬프트를 생성한다.
|
||||
"""
|
||||
log("도메인 프롬프트 생성을 위한 샘플 수집 중...")
|
||||
file_names, samples = gather_file_samples()
|
||||
|
||||
if not file_names and not samples:
|
||||
log("파일 샘플이 없어 도메인 프롬프트를 생성할 수 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
file_names_text = "\n".join(file_names[:80])
|
||||
sample_text = "\n\n".join(samples[:30])
|
||||
|
||||
prompt = f"""
|
||||
다음은 한 기업의 '이슈 리포트 및 시스템 관련 자료'로 추정되는 파일들의 목록과,
|
||||
각 파일에서 일부 추출한 내용 샘플이다.
|
||||
|
||||
[파일명 목록]
|
||||
{file_names_text}
|
||||
|
||||
[내용 샘플]
|
||||
{sample_text}
|
||||
|
||||
위 자료를 바탕으로 다음을 수행하라.
|
||||
|
||||
1) 이 문서 묶음이 어떤 산업, 업무, 분야에 대한 것인지,
|
||||
핵심 키워드를 포함해 2~3줄 정도로 설명하라.
|
||||
|
||||
2) 이후, 이 문서들을 다루는 AI에게 사용할 "프롬프트 머리말"을 작성하라.
|
||||
이 머리말은 모든 후속 프롬프트 앞에 항상 붙일 예정이며,
|
||||
다음 조건을 만족해야 한다.
|
||||
|
||||
- 첫 문단: "너는 ~~ 분야의 전문가이다." 형식으로, 이 문서 묶음의 분야와 역할을 정의한다.
|
||||
- 두 번째 문단 이후: "나는 ~~을 하고 싶다.", "우리는 ~~ 의 문제를 분석하고 개선방안을 찾고자 한다." 등
|
||||
사용자가 AI에게 요구하는 전반적 목적과 관점을 정리한다.
|
||||
- 총 5~7줄 정도의 한국어 문장으로 작성한다.
|
||||
- 이후에 붙을 프롬프트(청킹, 요약, RAG, 보고서 작성 등)와 자연스럽게 연결될 수 있도록,
|
||||
역할(role), 목적, 기준(추측 금지, 사실 기반, 근거 명시 등)을 모두 포함한다.
|
||||
|
||||
출력 형식:
|
||||
- 설명과 머리말을 한 번에 출력하되,
|
||||
별도의 마크다운 없이 순수 텍스트로만 작성하라.
|
||||
- 이 출력 전체를 domain_prompt.txt에 그대로 저장할 것이다.
|
||||
"""
|
||||
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "너는 문서 묶음의 분야를 식별하고, 그에 맞는 AI 시스템 프롬프트와 컨텍스트를 설계하는 컨설턴트이다."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
content = (resp.choices[0].message.content or "").strip()
|
||||
out_path = CONTEXT_DIR / "domain_prompt.txt"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
|
||||
log(f"도메인 프롬프트 생성 완료: {out_path}")
|
||||
return content
|
||||
|
||||
|
||||
def main():
|
||||
log("=== 도메인 프롬프트 생성 시작 ===")
|
||||
out_path = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if out_path.exists():
|
||||
log(f"이미 domain_prompt.txt가 존재합니다: {out_path}")
|
||||
log("기존 파일을 사용하려면 종료하고, 재생성이 필요하면 파일을 삭제한 뒤 다시 실행하십시오.")
|
||||
else:
|
||||
build_domain_prompt()
|
||||
log("=== 도메인 프롬프트 작업 종료 ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
357
03. Code/geulbeot_8th/converters/pipeline/step4_chunk.py
Normal file
@@ -0,0 +1,357 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
chunk_and_summary_v2.py
|
||||
|
||||
기능:
|
||||
- 정리중 폴더 아래의 .md 파일들을 대상으로
|
||||
1) domain_prompt.txt 기반 GPT 의미 청킹
|
||||
2) 청크별 요약 생성
|
||||
3) 청크 내 이미지 참조 보존
|
||||
4) JSON 저장 (원문+청크+요약+이미지)
|
||||
5) RAG용 *_chunks.json 저장
|
||||
|
||||
전제:
|
||||
- extract_1_v2.py 실행 후 .md 파일들이 존재할 것
|
||||
- step1_domainprompt.py 실행 후 domain_prompt.txt가 존재할 것
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 =====
|
||||
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
|
||||
TEXT_DIR = OUTPUT_ROOT / "text"
|
||||
JSON_DIR = OUTPUT_ROOT / "json"
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 스킵할 폴더 =====
|
||||
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"}
|
||||
|
||||
# ===== 이미지 참조 패턴 =====
|
||||
IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
|
||||
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log(f"domain_prompt.txt가 없습니다: {p}")
|
||||
log("먼저 step1_domainprompt.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def safe_rel(p: Path) -> str:
|
||||
"""DATA_ROOT 기준 상대 경로 반환"""
|
||||
try:
|
||||
return str(p.relative_to(DATA_ROOT))
|
||||
except Exception:
|
||||
return str(p)
|
||||
|
||||
|
||||
def extract_text_md(p: Path) -> str:
|
||||
"""마크다운 파일 텍스트 읽기"""
|
||||
try:
|
||||
return p.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return p.read_text(encoding="cp949", errors="ignore")
|
||||
|
||||
|
||||
def find_images_in_text(text: str) -> list:
|
||||
"""텍스트에서 이미지 참조 찾기"""
|
||||
matches = IMAGE_PATTERN.findall(text)
|
||||
return [{"alt": m[0], "path": m[1]} for m in matches]
|
||||
|
||||
|
||||
def semantic_chunk(domain_prompt: str, text: str, source_name: str):
|
||||
"""GPT 기반 의미 청킹"""
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
# 텍스트가 너무 짧으면 그냥 하나의 청크로
|
||||
if len(text) < 500:
|
||||
return [{
|
||||
"title": "전체 내용",
|
||||
"keywords": "",
|
||||
"content": text
|
||||
}]
|
||||
|
||||
user_prompt = f"""
|
||||
아래 문서를 의미 단위(문단/항목/섹션 등)로 분리하고,
|
||||
각 청크는 title / keywords / content 를 포함한 JSON 배열로 출력하라.
|
||||
|
||||
규칙:
|
||||
1. 추측 금지, 문서 내용 기반으로만 분리
|
||||
2. 이미지 참조()는 관련 텍스트와 같은 청크에 포함
|
||||
3. 각 청크는 최소 100자 이상
|
||||
4. keywords는 쉼표로 구분된 핵심 키워드 3~5개
|
||||
|
||||
문서:
|
||||
{text[:12000]}
|
||||
|
||||
JSON 배열만 출력하라. 다른 설명 없이.
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 의미 기반 청킹 전문가이다. JSON 배열만 출력한다."},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
)
|
||||
data = resp.choices[0].message.content.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
# ```json ... ``` 형식 처리
|
||||
if "```json" in data:
|
||||
data = data.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in data:
|
||||
data = data.split("```")[1].split("```")[0].strip()
|
||||
|
||||
if data.startswith("["):
|
||||
return json.loads(data)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"[WARN] JSON 파싱 실패 ({source_name}): {e}")
|
||||
except Exception as e:
|
||||
log(f"[WARN] semantic_chunk API 실패 ({source_name}): {e}")
|
||||
|
||||
# fallback: 페이지/섹션 기반 분리
|
||||
log(f"[INFO] Fallback 청킹 적용: {source_name}")
|
||||
return fallback_chunk(text)
|
||||
|
||||
|
||||
def fallback_chunk(text: str) -> list:
|
||||
"""GPT 실패 시 대체 청킹 (페이지/섹션 기반)"""
|
||||
chunks = []
|
||||
|
||||
# 페이지 구분자로 분리 시도
|
||||
if "## Page " in text:
|
||||
pages = re.split(r'\n## Page \d+\n', text)
|
||||
for i, page_content in enumerate(pages):
|
||||
if page_content.strip():
|
||||
chunks.append({
|
||||
"title": f"Page {i+1}",
|
||||
"keywords": "",
|
||||
"content": page_content.strip()
|
||||
})
|
||||
else:
|
||||
# 빈 줄 2개 이상으로 분리
|
||||
sections = re.split(r'\n{3,}', text)
|
||||
for i, section in enumerate(sections):
|
||||
if section.strip() and len(section.strip()) > 50:
|
||||
chunks.append({
|
||||
"title": f"섹션 {i+1}",
|
||||
"keywords": "",
|
||||
"content": section.strip()
|
||||
})
|
||||
|
||||
# 청크가 없으면 전체를 하나로
|
||||
if not chunks:
|
||||
chunks.append({
|
||||
"title": "전체 내용",
|
||||
"keywords": "",
|
||||
"content": text.strip()
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str:
|
||||
"""청크 요약 생성"""
|
||||
if not text.strip():
|
||||
return ""
|
||||
|
||||
# 이미지 참조 제거 후 요약 (텍스트만)
|
||||
text_only = IMAGE_PATTERN.sub('', text).strip()
|
||||
|
||||
if len(text_only) < 100:
|
||||
return text_only
|
||||
|
||||
prompt = f"""
|
||||
아래 텍스트를 {limit}자 이내로 사실 기반으로 요약하라.
|
||||
추측 금지, 고유명사와 수치는 보존.
|
||||
|
||||
{text_only[:8000]}
|
||||
"""
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 사실만 요약하는 전문가이다."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
log(f"[WARN] summary 실패: {e}")
|
||||
return text_only[:limit]
|
||||
|
||||
|
||||
def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int:
|
||||
"""
|
||||
의미 청킹 → 요약 → JSON 저장
|
||||
|
||||
Returns:
|
||||
생성된 청크 수
|
||||
"""
|
||||
stem = src.stem
|
||||
folder_ctx = safe_rel(src.parent)
|
||||
|
||||
# 원문 저장
|
||||
(TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore")
|
||||
|
||||
# 의미 청킹
|
||||
chunks = semantic_chunk(domain_prompt, text, src.name)
|
||||
|
||||
if not chunks:
|
||||
log(f"[WARN] 청크 없음: {src.name}")
|
||||
return 0
|
||||
|
||||
rag_items = []
|
||||
|
||||
for idx, ch in enumerate(chunks, start=1):
|
||||
content = ch.get("content", "")
|
||||
|
||||
# 요약 생성
|
||||
summ = summary_chunk(domain_prompt, content, 300)
|
||||
|
||||
# 이 청크에 포함된 이미지 찾기
|
||||
images_in_chunk = find_images_in_text(content)
|
||||
|
||||
rag_items.append({
|
||||
"source": src.name,
|
||||
"source_path": safe_rel(src),
|
||||
"chunk": idx,
|
||||
"total_chunks": len(chunks),
|
||||
"title": ch.get("title", ""),
|
||||
"keywords": ch.get("keywords", ""),
|
||||
"text": content,
|
||||
"summary": summ,
|
||||
"folder_context": folder_ctx,
|
||||
"images": images_in_chunk,
|
||||
"has_images": len(images_in_chunk) > 0
|
||||
})
|
||||
|
||||
# JSON 저장
|
||||
(JSON_DIR / f"{stem}.json").write_text(
|
||||
json.dumps(rag_items, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# RAG용 JSON 저장
|
||||
(RAG_DIR / f"{stem}_chunks.json").write_text(
|
||||
json.dumps(rag_items, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
return len(chunks)
|
||||
|
||||
|
||||
def main():
|
||||
log("=" * 60)
|
||||
log("청킹/요약 파이프라인 시작")
|
||||
log(f"데이터 폴더: {DATA_ROOT}")
|
||||
log(f"출력 폴더: {OUTPUT_ROOT}")
|
||||
log("=" * 60)
|
||||
|
||||
# 도메인 프롬프트 로드
|
||||
domain_prompt = load_domain_prompt()
|
||||
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
|
||||
|
||||
# 통계
|
||||
stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0}
|
||||
|
||||
# .md 파일 찾기
|
||||
md_files = []
|
||||
for root, dirs, files in os.walk(DATA_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
|
||||
for fname in files:
|
||||
if fname.lower().endswith(".md"):
|
||||
md_files.append(Path(root) / fname)
|
||||
|
||||
log(f"\n총 {len(md_files)}개 .md 파일 발견\n")
|
||||
|
||||
for idx, fpath in enumerate(md_files, 1):
|
||||
try:
|
||||
rel_path = safe_rel(fpath)
|
||||
log(f"[{idx}/{len(md_files)}] {rel_path}")
|
||||
|
||||
# 텍스트 읽기
|
||||
text = extract_text_md(fpath)
|
||||
|
||||
if not text.strip():
|
||||
log(f" ⚠ 빈 파일, 스킵")
|
||||
continue
|
||||
|
||||
# 이미지 개수 확인
|
||||
images = find_images_in_text(text)
|
||||
stats["images"] += len(images)
|
||||
|
||||
# 청킹 및 저장
|
||||
chunk_count = save_chunk_files(fpath, text, domain_prompt)
|
||||
|
||||
stats["docs"] += 1
|
||||
stats["chunks"] += chunk_count
|
||||
|
||||
log(f" ✓ {chunk_count}개 청크, {len(images)}개 이미지")
|
||||
|
||||
except Exception as e:
|
||||
stats["errors"] += 1
|
||||
log(f" ✗ 오류: {e}")
|
||||
|
||||
# 전체 통계 저장
|
||||
summary = {
|
||||
"processed_at": datetime.now().isoformat(),
|
||||
"data_root": str(DATA_ROOT),
|
||||
"output_root": str(OUTPUT_ROOT),
|
||||
"statistics": stats
|
||||
}
|
||||
|
||||
(LOG_DIR / "chunk_summary_stats.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# 결과 출력
|
||||
log("\n" + "=" * 60)
|
||||
log("청킹/요약 완료!")
|
||||
log("=" * 60)
|
||||
log(f"처리된 문서: {stats['docs']}개")
|
||||
log(f"생성된 청크: {stats['chunks']}개")
|
||||
log(f"포함된 이미지: {stats['images']}개")
|
||||
log(f"오류: {stats['errors']}개")
|
||||
log(f"\n결과 저장 위치:")
|
||||
log(f" - 원문: {TEXT_DIR}")
|
||||
log(f" - JSON: {JSON_DIR}")
|
||||
log(f" - RAG: {RAG_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
141
03. Code/geulbeot_8th/converters/pipeline/step5_rag.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
build_rag.py
|
||||
|
||||
기능:
|
||||
- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파일들을 읽어서
|
||||
text + summary 를 임베딩(text-embedding-3-small)한다.
|
||||
- FAISS IndexFlatIP 인덱스를 구축하여
|
||||
output/rag/faiss.index, meta.json, vectors.npy 를 생성한다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import faiss
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [RAG_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조 유지) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
EMBED_MODEL = "text-embedding-3-small"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
|
||||
def embed_texts(texts):
|
||||
if not texts:
|
||||
return np.zeros((0, 1536), dtype="float32")
|
||||
embs = []
|
||||
B = 96
|
||||
for i in range(0, len(texts), B):
|
||||
batch = texts[i:i+B]
|
||||
resp = client.embeddings.create(model=EMBED_MODEL, input=batch)
|
||||
for d in resp.data:
|
||||
embs.append(np.array(d.embedding, dtype="float32"))
|
||||
return np.vstack(embs)
|
||||
|
||||
|
||||
def _build_embed_input(u: dict) -> str:
|
||||
"""
|
||||
text + summary 를 합쳐 임베딩 입력을 만든다.
|
||||
- text, summary 중 없는 것은 생략
|
||||
- 공백 정리
|
||||
- 최대 길이 제한
|
||||
"""
|
||||
sum_ = (u.get("summary") or "").strip()
|
||||
txt = (u.get("text") or "").strip()
|
||||
|
||||
if txt and sum_:
|
||||
merged = txt + "\n\n요약: " + sum_[:1000]
|
||||
else:
|
||||
merged = txt or sum_
|
||||
|
||||
merged = " ".join(merged.split())
|
||||
if not merged:
|
||||
return ""
|
||||
if len(merged) > 4000:
|
||||
merged = merged[:4000]
|
||||
return merged
|
||||
|
||||
|
||||
def build_faiss_index():
|
||||
docs = []
|
||||
metas = []
|
||||
|
||||
rag_files = list(RAG_DIR.glob("*_chunks.json"))
|
||||
if not rag_files:
|
||||
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
|
||||
for f in rag_files:
|
||||
try:
|
||||
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
|
||||
except Exception as e:
|
||||
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
|
||||
continue
|
||||
|
||||
for u in units:
|
||||
embed_input = _build_embed_input(u)
|
||||
if not embed_input:
|
||||
continue
|
||||
if len(embed_input) < 40:
|
||||
continue
|
||||
docs.append(embed_input)
|
||||
metas.append({
|
||||
"source": u.get("source", ""),
|
||||
"chunk": int(u.get("chunk", 0)),
|
||||
"folder_context": u.get("folder_context", "")
|
||||
})
|
||||
|
||||
if not docs:
|
||||
log("임베딩할 텍스트가 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
log(f"임베딩 대상 텍스트 수: {len(docs)}")
|
||||
|
||||
E = embed_texts(docs)
|
||||
if E.shape[0] != len(docs):
|
||||
log(f"[WARN] 임베딩 수 불일치: E={E.shape[0]}, docs={len(docs)}")
|
||||
|
||||
faiss.normalize_L2(E)
|
||||
index = faiss.IndexFlatIP(E.shape[1])
|
||||
index.add(E)
|
||||
|
||||
np.save(str(RAG_DIR / "vectors.npy"), E)
|
||||
(RAG_DIR / "meta.json").write_text(
|
||||
json.dumps(metas, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
faiss.write_index(index, str(RAG_DIR / "faiss.index"))
|
||||
|
||||
log(f"FAISS 인덱스 구축 완료: 벡터 수={len(metas)}")
|
||||
|
||||
|
||||
def main():
|
||||
log("=== FAISS RAG 인덱스 구축 시작 ===")
|
||||
build_faiss_index()
|
||||
log("=== FAISS RAG 인덱스 구축 종료 ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
232
03. Code/geulbeot_8th/converters/pipeline/step6_corpus.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
make_corpus_v2.py
|
||||
|
||||
기능:
|
||||
- output/rag/*_chunks.json 에서 모든 청크의 summary를 모아
|
||||
- AI가 CEL 목적(교육+자사솔루션 홍보)에 맞게 압축 정리
|
||||
- 중복은 빈도 표시, 희귀하지만 중요한 건 [핵심] 표시
|
||||
- 결과를 output/context/corpus.txt 로 저장
|
||||
|
||||
전제:
|
||||
- chunk_and_summary.py 실행 후 *_chunks.json 들이 존재해야 한다.
|
||||
- domain_prompt.txt가 존재해야 한다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
RAG_DIR = OUTPUT_ROOT / "rag"
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 압축 설정 =====
|
||||
BATCH_SIZE = 80 # 한 번에 처리할 요약 개수
|
||||
MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결과 글자수
|
||||
MAX_FINAL_CHARS = 8000 # 최종 corpus 글자수
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
|
||||
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log("domain_prompt.txt가 없습니다. 먼저 step1을 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def load_all_summaries() -> list:
|
||||
"""모든 청크의 summary + 출처 정보 수집"""
|
||||
summaries = []
|
||||
rag_files = sorted(RAG_DIR.glob("*_chunks.json"))
|
||||
|
||||
if not rag_files:
|
||||
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
|
||||
for f in rag_files:
|
||||
try:
|
||||
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
|
||||
except Exception as e:
|
||||
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
|
||||
continue
|
||||
|
||||
for u in units:
|
||||
summ = (u.get("summary") or "").strip()
|
||||
source = (u.get("source") or "").strip()
|
||||
keywords = (u.get("keywords") or "")
|
||||
|
||||
if summ:
|
||||
# 출처와 키워드 포함
|
||||
entry = f"[{source}] {summ}"
|
||||
if keywords:
|
||||
entry += f" (키워드: {keywords})"
|
||||
summaries.append(entry)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str:
|
||||
"""배치 단위로 요약들을 AI가 압축"""
|
||||
|
||||
batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)])
|
||||
|
||||
prompt = f"""
|
||||
아래는 문서에서 추출한 요약 {len(batch)}개이다. (배치 {batch_num}/{total_batches})
|
||||
|
||||
[요약 목록]
|
||||
{batch_text}
|
||||
|
||||
다음 기준으로 이 요약들을 압축 정리하라:
|
||||
|
||||
1) 중복/유사 내용: 하나로 통합하되, 여러 문서에서 언급되면 "(N회 언급)" 표시
|
||||
2) domain_prompt에 명시된 핵심 솔루션/시스템: 반드시 보존하고 [솔루션] 표시
|
||||
3) domain_prompt의 목적에 중요한 내용 우선 보존:
|
||||
- 해당 분야의 기초 개념
|
||||
- 기존 방식의 한계점과 문제점
|
||||
- 새로운 기술/방식의 장점
|
||||
4) 단순 나열/절차만 있는 내용: 과감히 축약
|
||||
5) 희귀하지만 핵심적인 인사이트: [핵심] 표시
|
||||
|
||||
출력 형식:
|
||||
- 주제별로 그룹핑
|
||||
- 각 항목은 1~2문장으로 간결하게
|
||||
- 전체 {MAX_CHARS_PER_BATCH}자 이내
|
||||
- 마크다운 없이 순수 텍스트로
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 문서 요약을 주제별로 압축 정리하는 전문가이다."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
result = resp.choices[0].message.content.strip()
|
||||
log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)")
|
||||
return result
|
||||
except Exception as e:
|
||||
log(f"[ERROR] 배치 {batch_num} 압축 실패: {e}")
|
||||
# 실패 시 원본 일부 반환
|
||||
return "\n".join(batch[:10])
|
||||
|
||||
|
||||
def merge_compressed_parts(domain_prompt: str, parts: list) -> str:
|
||||
"""배치별 압축 결과를 최종 통합"""
|
||||
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
|
||||
all_parts = "\n\n---\n\n".join([f"[파트 {i+1}]\n{p}" for i, p in enumerate(parts)])
|
||||
|
||||
prompt = f"""
|
||||
아래는 대량의 문서 요약을 배치별로 압축한 결과이다.
|
||||
이것을 최종 corpus로 통합하라.
|
||||
|
||||
[배치별 압축 결과]
|
||||
{all_parts}
|
||||
|
||||
통합 기준:
|
||||
1) 파트 간 중복 내용 제거 및 통합
|
||||
2) domain_prompt에 명시된 목적과 흐름에 맞게 재구성
|
||||
3) [솔루션], [핵심], (N회 언급) 표시는 유지
|
||||
4) 전체 {MAX_FINAL_CHARS}자 이내
|
||||
|
||||
출력: 주제별로 정리된 최종 corpus (마크다운 없이)
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 콘텐츠 기획을 위한 corpus를 설계하는 전문가이다."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
log(f"[ERROR] 최종 통합 실패: {e}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
log("=" * 60)
|
||||
log("corpus 생성 시작 (AI 압축 버전)")
|
||||
log("=" * 60)
|
||||
|
||||
# 도메인 프롬프트 로드
|
||||
domain_prompt = load_domain_prompt()
|
||||
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
|
||||
|
||||
# 모든 요약 수집
|
||||
summaries = load_all_summaries()
|
||||
if not summaries:
|
||||
log("summary가 없습니다. corpus를 생성할 수 없습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
log(f"원본 요약 수집 완료: {len(summaries)}개")
|
||||
|
||||
# 원본 저장 (백업)
|
||||
raw_corpus = "\n".join(summaries)
|
||||
raw_path = CONTEXT_DIR / "corpus_raw.txt"
|
||||
raw_path.write_text(raw_corpus, encoding="utf-8")
|
||||
log(f"원본 corpus 백업: {raw_path} ({len(raw_corpus)}자)")
|
||||
|
||||
# 배치별 압축
|
||||
total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE
|
||||
log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 총 {total_batches}배치)")
|
||||
|
||||
compressed_parts = []
|
||||
for i in range(0, len(summaries), BATCH_SIZE):
|
||||
batch = summaries[i:i+BATCH_SIZE]
|
||||
batch_num = (i // BATCH_SIZE) + 1
|
||||
|
||||
compressed = compress_batch(domain_prompt, batch, batch_num, total_batches)
|
||||
compressed_parts.append(compressed)
|
||||
|
||||
# 최종 통합
|
||||
log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파트)")
|
||||
final_corpus = merge_compressed_parts(domain_prompt, compressed_parts)
|
||||
|
||||
# 저장
|
||||
out_path = CONTEXT_DIR / "corpus.txt"
|
||||
out_path.write_text(final_corpus, encoding="utf-8")
|
||||
|
||||
# 통계
|
||||
log("\n" + "=" * 60)
|
||||
log("corpus 생성 완료!")
|
||||
log("=" * 60)
|
||||
log(f"원본 요약: {len(summaries)}개 ({len(raw_corpus)}자)")
|
||||
log(f"압축 corpus: {len(final_corpus)}자")
|
||||
log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%")
|
||||
log(f"\n저장 위치:")
|
||||
log(f" - 원본: {raw_path}")
|
||||
log(f" - 압축: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
504
03. Code/geulbeot_8th/converters/pipeline/step7_index.py
Normal file
@@ -0,0 +1,504 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
make_outline.py
|
||||
|
||||
기능:
|
||||
- output_context/context/domain_prompt.txt
|
||||
- output_context/context/corpus.txt
|
||||
을 기반으로 목차를 생성하고,
|
||||
|
||||
1) outline_issue_report.txt 저장
|
||||
2) outline_issue_report.html 저장 (테스트.html 레이아웃 기반 표 형태)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from openai import OpenAI
|
||||
from api_config import API_KEYS
|
||||
|
||||
# ===== 경로 설정 =====
|
||||
DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out")
|
||||
OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치
|
||||
CONTEXT_DIR = OUTPUT_ROOT / "context"
|
||||
LOG_DIR = OUTPUT_ROOT / "logs"
|
||||
|
||||
for d in [CONTEXT_DIR, LOG_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ===== OpenAI 설정 (구조 유지) =====
|
||||
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
|
||||
GPT_MODEL = "gpt-5-2025-08-07"
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
# ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
|
||||
RE_KEYWORDS = re.compile(r"(#\S+)")
|
||||
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
|
||||
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
|
||||
RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$")
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, flush=True)
|
||||
with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
def load_domain_prompt() -> str:
|
||||
p = CONTEXT_DIR / "domain_prompt.txt"
|
||||
if not p.exists():
|
||||
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
def load_corpus() -> str:
|
||||
p = CONTEXT_DIR / "corpus.txt"
|
||||
if not p.exists():
|
||||
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
|
||||
sys.exit(1)
|
||||
return p.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
# 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
|
||||
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$")
|
||||
RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$")
|
||||
|
||||
def generate_outline(domain_prompt: str, corpus: str) -> str:
|
||||
sys_msg = {
|
||||
"role": "system",
|
||||
"content": (
|
||||
domain_prompt + "\n\n"
|
||||
"너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
|
||||
"주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
|
||||
),
|
||||
}
|
||||
|
||||
user_msg = {
|
||||
"role": "user",
|
||||
"content": f"""
|
||||
아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
|
||||
|
||||
[corpus]
|
||||
{corpus}
|
||||
|
||||
요구 사항:
|
||||
1) 첫 줄에 보고서 제목 1개를 작성하라.
|
||||
2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
|
||||
- 대목차: 1. / 2. / 3. ...
|
||||
- 중목차: 1.1 / 1.2 / ...
|
||||
- 소목차: 1.1.1 / 1.1.2 / ...
|
||||
3) **수량 제약 (중요)**:
|
||||
- 대목차(1.)는 5~8개로 구성하라.
|
||||
- **중목차(1.1) 하나당 소목차(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사이로 구성하라.** (절대 1개만 만들지 말 것)
|
||||
- 소목차(1.1.1) 하나당 '핵심주제(꼭지)'는 반드시 2개에서 3개 사이로 구성하라.
|
||||
|
||||
[소목차 작성 형식]
|
||||
1.1.1 소목차 제목
|
||||
- 핵심주제 1 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
|
||||
- 핵심주제 2 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
|
||||
|
||||
5) [유형] 분류 가이드:
|
||||
- [비교형]: 기존 vs DX 방식의 비교표(Table)가 필수적인 경우
|
||||
- [기술형]: RMSE, GSD, 중복도 등 정밀 수치와 사양 설명이 핵심인 경우
|
||||
- [절차형]: 단계별 워크플로 및 체크리스트가 중심인 경우
|
||||
- [인사이트형]: 한계점 분석 및 전문가 제언(☞)이 중심인 경우
|
||||
6) 집필가이드는 50자 내외로, "어떤 데이터를 검색해서 어떤 표를 그려라"와 같이 구체적으로 지시하라.
|
||||
7) 대목차는 최대 8개 이내로 구성하라.
|
||||
"""
|
||||
}
|
||||
resp = client.chat.completions.create(
|
||||
model=GPT_MODEL,
|
||||
messages=[sys_msg, user_msg],
|
||||
)
|
||||
return (resp.choices[0].message.content or "").strip()
|
||||
|
||||
|
||||
|
||||
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()]
|
||||
if not lines: return "", []
|
||||
|
||||
title = lines[0].strip() # 첫 줄은 보고서 제목
|
||||
rows = []
|
||||
current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
|
||||
|
||||
for ln in lines[1:]:
|
||||
raw = ln.strip()
|
||||
|
||||
# 1. 소목차 헤더(1.1.1 제목) 발견 시
|
||||
m3_head = RE_L3_HEAD.match(raw)
|
||||
if m3_head:
|
||||
num, s_title = m3_head.groups()
|
||||
current_section = {
|
||||
"depth": 3,
|
||||
"num": num,
|
||||
"title": s_title,
|
||||
"sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
|
||||
}
|
||||
rows.append(current_section)
|
||||
continue
|
||||
|
||||
# 2. 세부 꼭지(- 주제 | #키워드 | [유형] | 가이드) 발견 시
|
||||
m_topic = RE_L3_TOPIC.match(raw)
|
||||
if m_topic and current_section:
|
||||
t_title, kws_raw, t_type, guide = m_topic.groups()
|
||||
# 키워드 추출 (#키워드 형태)
|
||||
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
|
||||
|
||||
# 현재 소목차(current_section)의 리스트에 추가
|
||||
current_section["sub_topics"].append({
|
||||
"topic_title": t_title,
|
||||
"keywords": kws,
|
||||
"type": t_type,
|
||||
"guide": guide
|
||||
})
|
||||
continue
|
||||
|
||||
# 3. 대목차(1.) 처리
|
||||
m1 = RE_L1.match(raw)
|
||||
if m1:
|
||||
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
|
||||
current_section = None # 소목차 구간 종료
|
||||
continue
|
||||
|
||||
# 4. 중목차(1.1) 처리
|
||||
m2 = RE_L2.match(raw)
|
||||
if m2:
|
||||
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
|
||||
current_section = None # 소목차 구간 종료
|
||||
continue
|
||||
|
||||
return title, rows
|
||||
|
||||
def html_escape(s: str) -> str:
|
||||
s = s or ""
|
||||
return (s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'"))
|
||||
|
||||
def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
A4 1장에 표가 길어지면 넘치므로, 단순 행 개수로 페이지 분할한다.
|
||||
"""
|
||||
out = []
|
||||
cur = []
|
||||
for r in rows:
|
||||
cur.append(r)
|
||||
if len(cur) >= max_rows_per_page:
|
||||
out.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
|
||||
"""
|
||||
head = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>번호</th>
|
||||
<th>제목</th>
|
||||
<th>키워드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
body_parts = []
|
||||
for r in rows:
|
||||
depth = r["depth"]
|
||||
num = html_escape(r["num"])
|
||||
title = html_escape(r["title"])
|
||||
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
|
||||
kw = html_escape(kw)
|
||||
|
||||
if depth == 1:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">대목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
elif depth == 2:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">중목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
body_parts.append(
|
||||
f"""
|
||||
<tr>
|
||||
<td class="group-cell">소목차</td>
|
||||
<td>{num}</td>
|
||||
<td>{title}</td>
|
||||
<td>{kw}</td>
|
||||
</tr>
|
||||
"""
|
||||
)
|
||||
|
||||
tail = """
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
return head + "\n".join(body_parts) + tail
|
||||
|
||||
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
테스트.html 레이아웃 구조를 그대로 따라 A4 시트 형태로 HTML 생성
|
||||
"""
|
||||
css = r"""
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-blue: #3057B9;
|
||||
--gray-light: #F2F2F2;
|
||||
--gray-medium: #E6E6E6;
|
||||
--gray-dark: #666666;
|
||||
--border-light: #DDDDDD;
|
||||
--text-black: #000000;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1.5px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 4px;
|
||||
background-color: var(--primary-blue);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--gray-light);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.lead-notes {
|
||||
font-size: 8.5pt;
|
||||
color: #777;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.body-content { flex: 1; }
|
||||
|
||||
.section { margin-bottom: 22px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #999;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 9.5pt;
|
||||
border-top: 1.5px solid #333;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--gray-medium);
|
||||
font-weight: 700;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.group-cell {
|
||||
background-color: #F9F9F9;
|
||||
font-weight: 700;
|
||||
width: 16%;
|
||||
text-align: center;
|
||||
color: var(--primary-blue);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
margin-top: 15px;
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-dark);
|
||||
border-top: 1px solid #EEE;
|
||||
}
|
||||
|
||||
.footer-page { flex: 1; text-align: center; }
|
||||
"""
|
||||
|
||||
pages = chunk_rows(rows, max_rows_per_page=26)
|
||||
|
||||
html_pages = []
|
||||
total_pages = len(pages) if pages else 1
|
||||
for i, page_rows in enumerate(pages, start=1):
|
||||
table_html = build_outline_table_html(page_rows)
|
||||
|
||||
html_pages.append(f"""
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
보고서: 목차 자동 생성 결과
|
||||
</div>
|
||||
<div class="header-right">
|
||||
작성일자: {datetime.now().strftime("%Y. %m. %d.")}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{html_escape(report_title)}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>확정 목차 표 형태 정리본</div>
|
||||
</div>
|
||||
<div class="lead-notes">목차는 outline_issue_report.txt를 기반으로 표로 재구성됨</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">목차</div>
|
||||
{table_html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="page-footer">
|
||||
<div class="footer-slogan">Word Style v2 Outline</div>
|
||||
<div class="footer-page">- {i} / {total_pages} -</div>
|
||||
<div class="footer-info">outline_issue_report.html</div>
|
||||
</footer>
|
||||
</div>
|
||||
""")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{html_escape(report_title)} - Outline</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
{''.join(html_pages)}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def main():
|
||||
log("=== 목차 생성 시작 ===")
|
||||
domain_prompt = load_domain_prompt()
|
||||
corpus = load_corpus()
|
||||
|
||||
outline = generate_outline(domain_prompt, corpus)
|
||||
|
||||
# TXT 저장 유지
|
||||
out_txt = CONTEXT_DIR / "outline_issue_report.txt"
|
||||
out_txt.write_text(outline, encoding="utf-8")
|
||||
log(f"목차 TXT 저장 완료: {out_txt}")
|
||||
|
||||
# HTML 추가 저장
|
||||
title, rows = parse_outline(outline)
|
||||
out_html = CONTEXT_DIR / "outline_issue_report.html"
|
||||
out_html.write_text(build_outline_html(title, rows), encoding="utf-8")
|
||||
log(f"목차 HTML 저장 완료: {out_html}")
|
||||
|
||||
log("=== 목차 생성 종료 ===")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1021
03. Code/geulbeot_8th/converters/pipeline/step8_content.py
Normal file
1249
03. Code/geulbeot_8th/converters/pipeline/step9_html.py
Normal file
935
03. Code/geulbeot_8th/converters/style_analyzer.py
Normal file
@@ -0,0 +1,935 @@
|
||||
"""
|
||||
HTML 스타일 분석기 v3.0
|
||||
HTML 요소를 분석하여 역할(Role)을 자동 분류
|
||||
|
||||
✅ v3.0 변경사항:
|
||||
- 글벗 HTML 구조 완벽 지원 (.sheet, .body-content)
|
||||
- 머리말/꼬리말/페이지번호 제거
|
||||
- 강력한 중복 콘텐츠 필터링
|
||||
- 제목 계층 구조 정확한 인식
|
||||
"""
|
||||
|
||||
import re
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional, Tuple, Set
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DocumentSection(Enum):
|
||||
"""문서 섹션 유형"""
|
||||
COVER = "cover" # 표지
|
||||
TOC = "toc" # 목차
|
||||
CONTENT = "content" # 본문
|
||||
|
||||
|
||||
@dataclass
|
||||
class StyledElement:
|
||||
"""스타일이 지정된 요소"""
|
||||
role: str # 역할 (H1, BODY, TH 등)
|
||||
text: str # 텍스트 내용
|
||||
tag: str # 원본 HTML 태그
|
||||
html: str # 원본 HTML
|
||||
section: str # 섹션 (cover, toc, content)
|
||||
attributes: Dict # 추가 속성 (이미지 src 등)
|
||||
|
||||
def __repr__(self):
|
||||
preview = self.text[:30] + "..." if len(self.text) > 30 else self.text
|
||||
return f"<{self.role}> {preview}"
|
||||
|
||||
|
||||
class StyleAnalyzer:
|
||||
"""HTML 문서를 분석하여 역할 분류"""
|
||||
|
||||
# 번호 패턴 정의
|
||||
PATTERNS = {
|
||||
# 장 번호: "제1장", "제2장"
|
||||
"chapter": re.compile(r'^제\s*\d+\s*장'),
|
||||
# 1단계 제목: "1 ", "2 " (숫자+공백, 점 없음)
|
||||
"h1_num": re.compile(r'^(\d+)\s+[가-힣]'),
|
||||
# 대항목: "1.", "2."
|
||||
"h2_num": re.compile(r'^(\d+)\.\s'),
|
||||
# 중항목: "1.1 ", "1.2 "
|
||||
"h3_num": re.compile(r'^(\d+)\.(\d+)\s'),
|
||||
# 소항목: "1.1.1"
|
||||
"h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'),
|
||||
# 세부: "1)", "2)"
|
||||
"h5_paren": re.compile(r'^(\d+)\)\s*'),
|
||||
# 세세부: "(1)", "(2)"
|
||||
"h6_paren": re.compile(r'^\((\d+)\)\s*'),
|
||||
# 가나다: "가.", "나."
|
||||
"h4_korean": re.compile(r'^[가-하]\.\s'),
|
||||
# 가나다 괄호: "가)", "나)"
|
||||
"h5_korean": re.compile(r'^[가-하]\)\s'),
|
||||
# 원문자: "①", "②"
|
||||
"h6_circle": re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]'),
|
||||
# 목록: "•", "-", "○"
|
||||
"list_bullet": re.compile(r'^[•\-○]\s'),
|
||||
# 페이지 번호 패턴: "- 1 -", "- 12 -"
|
||||
"page_number": re.compile(r'^-\s*\d+\s*-$'),
|
||||
# 꼬리말 패턴: "문서제목- 1 -"
|
||||
"footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'),
|
||||
}
|
||||
|
||||
# 제거할 텍스트 패턴들
|
||||
REMOVE_PATTERNS = [
|
||||
re.compile(r'^-\s*\d+\s*-$'), # "- 1 -"
|
||||
re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "문서제목- 1 -"
|
||||
re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (이미지 크기)
|
||||
re.compile(r'^\[이미지 없음:.*\]$'), # "[이미지 없음: xxx]"
|
||||
re.compile(r'^\[그림\s*\d+-\d+\]$'), # "[그림 1-1]"
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.elements: List[StyledElement] = []
|
||||
self.current_section = DocumentSection.CONTENT
|
||||
self.seen_texts: Set[str] = set() # 중복 방지용
|
||||
self.document_title = "" # 문서 제목 (꼬리말 제거용)
|
||||
|
||||
def analyze(self, html: str) -> List[StyledElement]:
|
||||
"""HTML 문서 분석하여 역할 분류된 요소 리스트 반환"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
self.elements = []
|
||||
self.seen_texts = set()
|
||||
|
||||
# 1. 전처리: 불필요한 요소 제거
|
||||
self._preprocess(soup)
|
||||
|
||||
# 2. 문서 제목 추출 (꼬리말 패턴 감지용)
|
||||
self._extract_document_title(soup)
|
||||
|
||||
# 3. 섹션 감지 및 순회
|
||||
self._detect_and_process_sections(soup)
|
||||
|
||||
# 4. 후처리: 중복 및 불필요 요소 제거
|
||||
self._postprocess()
|
||||
|
||||
return self.elements
|
||||
|
||||
def _preprocess(self, soup: BeautifulSoup):
|
||||
"""HTML 전처리 - 불필요한 요소 제거"""
|
||||
print(" 🔧 HTML 전처리 중...")
|
||||
|
||||
# 1. 스크립트/스타일 태그 제거
|
||||
removed_count = 0
|
||||
for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']):
|
||||
tag.decompose()
|
||||
removed_count += 1
|
||||
|
||||
if removed_count > 0:
|
||||
print(f" - script/style 등 {removed_count}개 제거")
|
||||
|
||||
# 2. 머리말/꼬리말 영역 제거 (글벗 HTML 구조)
|
||||
header_footer_count = 0
|
||||
for selector in ['.page-header', '.page-footer', '.header', '.footer',
|
||||
'[class*="header"]', '[class*="footer"]',
|
||||
'.running-header', '.running-footer']:
|
||||
for elem in soup.select(selector):
|
||||
# 실제 콘텐츠 헤더가 아닌 페이지 헤더만 제거
|
||||
text = elem.get_text(strip=True)
|
||||
if self._is_header_footer_text(text):
|
||||
elem.decompose()
|
||||
header_footer_count += 1
|
||||
|
||||
if header_footer_count > 0:
|
||||
print(f" - 머리말/꼬리말 {header_footer_count}개 제거")
|
||||
|
||||
# 3. 숨겨진 요소 제거
|
||||
hidden_count = 0
|
||||
for elem in soup.select('[style*="display:none"], [style*="display: none"]'):
|
||||
elem.decompose()
|
||||
hidden_count += 1
|
||||
for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'):
|
||||
elem.decompose()
|
||||
hidden_count += 1
|
||||
|
||||
# 4. #raw-container 외부의 .sheet 제거 (글벗 구조)
|
||||
raw_container = soup.find(id='raw-container')
|
||||
if raw_container:
|
||||
print(" - 글벗 구조 감지: #raw-container 우선 사용")
|
||||
# raw-container 외부의 모든 .sheet 제거
|
||||
for sheet in soup.select('.sheet'):
|
||||
if not self._is_descendant_of(sheet, raw_container):
|
||||
sheet.decompose()
|
||||
|
||||
def _extract_document_title(self, soup: BeautifulSoup):
|
||||
"""문서 제목 추출 (꼬리말 패턴 감지용)"""
|
||||
# 표지에서 제목 찾기
|
||||
cover = soup.find(id='box-cover') or soup.find(class_='box-cover')
|
||||
if cover:
|
||||
h1 = cover.find('h1')
|
||||
if h1:
|
||||
self.document_title = h1.get_text(strip=True)
|
||||
print(f" - 문서 제목 감지: {self.document_title[:30]}...")
|
||||
|
||||
def _is_header_footer_text(self, text: str) -> bool:
|
||||
"""머리말/꼬리말 텍스트인지 판단"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
# 페이지 번호 패턴
|
||||
if self.PATTERNS['page_number'].match(text):
|
||||
return True
|
||||
|
||||
# "문서제목- 1 -" 패턴
|
||||
if self.PATTERNS['footer_pattern'].match(text):
|
||||
return True
|
||||
|
||||
# 문서 제목 + 페이지번호 조합
|
||||
if self.document_title and self.document_title in text:
|
||||
if re.search(r'[-–]\s*\d+\s*[-–]', text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _should_skip_text(self, text: str) -> bool:
|
||||
"""건너뛸 텍스트인지 판단"""
|
||||
if not text:
|
||||
return True
|
||||
|
||||
# 제거 패턴 체크
|
||||
for pattern in self.REMOVE_PATTERNS:
|
||||
if pattern.match(text):
|
||||
return True
|
||||
|
||||
# 머리말/꼬리말 체크
|
||||
if self._is_header_footer_text(text):
|
||||
return True
|
||||
|
||||
# 문서 제목만 있는 줄 (꼬리말에서 온 것)
|
||||
if self.document_title and text.strip() == self.document_title:
|
||||
# 이미 표지에서 처리했으면 스킵
|
||||
if any(e.role == 'COVER_TITLE' and self.document_title in e.text
|
||||
for e in self.elements):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool:
|
||||
"""element가 ancestor의 자손인지 확인"""
|
||||
parent = element.parent
|
||||
while parent:
|
||||
if parent == ancestor:
|
||||
return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
def _detect_and_process_sections(self, soup: BeautifulSoup):
|
||||
"""섹션 감지 및 처리"""
|
||||
|
||||
# 글벗 구조 (#raw-container) 우선 처리
|
||||
raw = soup.find(id='raw-container')
|
||||
if raw:
|
||||
self._process_geulbeot_structure(raw)
|
||||
return
|
||||
|
||||
# .sheet 구조 처리 (렌더링된 페이지)
|
||||
sheets = soup.select('.sheet')
|
||||
if sheets:
|
||||
self._process_sheet_structure(sheets)
|
||||
return
|
||||
|
||||
# 일반 HTML 구조 처리
|
||||
self._process_generic_html(soup)
|
||||
|
||||
def _process_geulbeot_structure(self, raw: Tag):
|
||||
"""글벗 HTML #raw-container 구조 처리"""
|
||||
print(" 📄 글벗 #raw-container 구조 처리 중...")
|
||||
|
||||
# 표지
|
||||
cover = raw.find(id='box-cover')
|
||||
if cover:
|
||||
print(" - 표지 섹션")
|
||||
self.current_section = DocumentSection.COVER
|
||||
self._process_cover(cover)
|
||||
|
||||
# 목차
|
||||
toc = raw.find(id='box-toc')
|
||||
if toc:
|
||||
print(" - 목차 섹션")
|
||||
self.current_section = DocumentSection.TOC
|
||||
self._process_toc(toc)
|
||||
|
||||
# 요약
|
||||
summary = raw.find(id='box-summary')
|
||||
if summary:
|
||||
print(" - 요약 섹션")
|
||||
self.current_section = DocumentSection.CONTENT
|
||||
self._process_content_element(summary)
|
||||
|
||||
# 본문
|
||||
content = raw.find(id='box-content')
|
||||
if content:
|
||||
print(" - 본문 섹션")
|
||||
self.current_section = DocumentSection.CONTENT
|
||||
self._process_content_element(content)
|
||||
|
||||
def _process_sheet_structure(self, sheets: List[Tag]):
|
||||
"""글벗 .sheet 페이지 구조 처리"""
|
||||
print(f" 📄 .sheet 페이지 구조 처리 중... ({len(sheets)}페이지)")
|
||||
|
||||
for i, sheet in enumerate(sheets):
|
||||
# 페이지 내 body-content만 추출
|
||||
body_content = sheet.select_one('.body-content')
|
||||
if body_content:
|
||||
self._process_content_element(body_content)
|
||||
else:
|
||||
# body-content가 없으면 머리말/꼬리말 제외하고 처리
|
||||
for child in sheet.children:
|
||||
if isinstance(child, Tag):
|
||||
classes = child.get('class', [])
|
||||
class_str = ' '.join(classes) if classes else ''
|
||||
|
||||
# 머리말/꼬리말 스킵
|
||||
if any(x in class_str.lower() for x in ['header', 'footer']):
|
||||
continue
|
||||
|
||||
self._process_content_element(child)
|
||||
|
||||
def _process_generic_html(self, soup: BeautifulSoup):
|
||||
"""일반 HTML 구조 처리"""
|
||||
print(" 📄 일반 HTML 구조 처리 중...")
|
||||
|
||||
# 표지
|
||||
cover = soup.find(class_=re.compile(r'cover|title-page|box-cover'))
|
||||
if cover:
|
||||
self.current_section = DocumentSection.COVER
|
||||
self._process_cover(cover)
|
||||
|
||||
# 목차
|
||||
toc = soup.find(class_=re.compile(r'toc|table-of-contents'))
|
||||
if toc:
|
||||
self.current_section = DocumentSection.TOC
|
||||
self._process_toc(toc)
|
||||
|
||||
# 본문
|
||||
self.current_section = DocumentSection.CONTENT
|
||||
main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup
|
||||
|
||||
for child in main_content.children:
|
||||
if isinstance(child, Tag):
|
||||
self._process_content_element(child)
|
||||
|
||||
def _process_cover(self, cover: Tag):
|
||||
"""표지 처리"""
|
||||
# H1 = 제목
|
||||
h1 = cover.find('h1')
|
||||
if h1:
|
||||
text = h1.get_text(strip=True)
|
||||
if text and not self._is_duplicate(text):
|
||||
self.elements.append(StyledElement(
|
||||
role="COVER_TITLE",
|
||||
text=text,
|
||||
tag="h1",
|
||||
html=str(h1)[:200],
|
||||
section="cover",
|
||||
attributes={}
|
||||
))
|
||||
|
||||
# H2 = 부제목
|
||||
h2 = cover.find('h2')
|
||||
if h2:
|
||||
text = h2.get_text(strip=True)
|
||||
if text and not self._is_duplicate(text):
|
||||
self.elements.append(StyledElement(
|
||||
role="COVER_SUBTITLE",
|
||||
text=text,
|
||||
tag="h2",
|
||||
html=str(h2)[:200],
|
||||
section="cover",
|
||||
attributes={}
|
||||
))
|
||||
|
||||
# P = 정보
|
||||
for p in cover.find_all('p'):
|
||||
text = p.get_text(strip=True)
|
||||
if text and not self._is_duplicate(text):
|
||||
self.elements.append(StyledElement(
|
||||
role="COVER_INFO",
|
||||
text=text,
|
||||
tag="p",
|
||||
html=str(p)[:200],
|
||||
section="cover",
|
||||
attributes={}
|
||||
))
|
||||
|
||||
def _process_toc(self, toc: Tag):
|
||||
"""목차 처리"""
|
||||
# UL/OL 기반 목차
|
||||
for li in toc.find_all('li'):
|
||||
text = li.get_text(strip=True)
|
||||
if not text or self._is_duplicate(text):
|
||||
continue
|
||||
|
||||
classes = li.get('class', [])
|
||||
class_str = ' '.join(classes) if classes else ''
|
||||
|
||||
# 레벨 판단 (구체적 → 일반 순서!)
|
||||
if 'lvl-1' in class_str or 'toc-lvl-1' in class_str:
|
||||
role = "TOC_H1"
|
||||
elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str:
|
||||
role = "TOC_H2"
|
||||
elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str:
|
||||
role = "TOC_H3"
|
||||
elif self.PATTERNS['h4_num'].match(text): # 1.1.1 먼저!
|
||||
role = "TOC_H3"
|
||||
elif self.PATTERNS['h3_num'].match(text): # 1.1 그다음
|
||||
role = "TOC_H2"
|
||||
elif self.PATTERNS['h2_num'].match(text): # 1. 그다음
|
||||
role = "TOC_H1"
|
||||
else:
|
||||
role = "TOC_H1"
|
||||
|
||||
self.elements.append(StyledElement(
|
||||
role=role,
|
||||
text=text,
|
||||
tag="li",
|
||||
html=str(li)[:200],
|
||||
section="toc",
|
||||
attributes={}
|
||||
))
|
||||
|
||||
def _process_content_element(self, element: Tag):
|
||||
"""본문 요소 재귀 처리"""
|
||||
if not isinstance(element, Tag):
|
||||
return
|
||||
|
||||
tag_name = element.name.lower() if element.name else ""
|
||||
classes = element.get('class', [])
|
||||
class_str = ' '.join(classes) if classes else ''
|
||||
|
||||
# 머리말/꼬리말 클래스 스킵
|
||||
if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']):
|
||||
return
|
||||
|
||||
# 테이블 특수 처리
|
||||
if tag_name == 'table':
|
||||
self._process_table(element)
|
||||
return
|
||||
|
||||
# 그림 특수 처리
|
||||
if tag_name in ['figure', 'img']:
|
||||
self._process_figure(element)
|
||||
return
|
||||
|
||||
# 텍스트 추출
|
||||
text = self._get_direct_text(element)
|
||||
|
||||
if text:
|
||||
# 건너뛸 텍스트 체크
|
||||
if self._should_skip_text(text):
|
||||
pass # 자식은 계속 처리
|
||||
elif not self._is_duplicate(text):
|
||||
role = self._classify_role(element, tag_name, classes, text)
|
||||
if role:
|
||||
self.elements.append(StyledElement(
|
||||
role=role,
|
||||
text=text,
|
||||
tag=tag_name,
|
||||
html=str(element)[:200],
|
||||
section=self.current_section.value,
|
||||
attributes=dict(element.attrs) if element.attrs else {}
|
||||
))
|
||||
|
||||
# 자식 요소 재귀 처리 (컨테이너 태그)
|
||||
if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body',
|
||||
'ul', 'ol', 'dl', 'blockquote']:
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag):
|
||||
self._process_content_element(child)
|
||||
|
||||
def _get_direct_text(self, element: Tag) -> str:
|
||||
"""요소의 직접 텍스트만 추출 (자식 컨테이너 제외)"""
|
||||
# 제목 태그는 전체 텍스트
|
||||
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']:
|
||||
return element.get_text(strip=True)
|
||||
|
||||
# 컨테이너 태그는 직접 텍스트만
|
||||
texts = []
|
||||
for child in element.children:
|
||||
if isinstance(child, NavigableString):
|
||||
t = str(child).strip()
|
||||
if t:
|
||||
texts.append(t)
|
||||
|
||||
return ' '.join(texts)
|
||||
|
||||
def _is_duplicate(self, text: str) -> bool:
|
||||
"""중복 텍스트인지 확인"""
|
||||
if not text:
|
||||
return True
|
||||
|
||||
# 정규화
|
||||
normalized = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
# 짧은 텍스트는 중복 허용 (번호 등)
|
||||
if len(normalized) < 10:
|
||||
return False
|
||||
|
||||
# 첫 50자로 체크
|
||||
key = normalized[:50]
|
||||
|
||||
if key in self.seen_texts:
|
||||
return True
|
||||
|
||||
self.seen_texts.add(key)
|
||||
return False
|
||||
|
||||
def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]:
|
||||
"""요소의 역할 분류
|
||||
|
||||
⚠️ 중요: 패턴 매칭은 반드시 구체적인 것 → 일반적인 것 순서로!
|
||||
1.1.1 → 1.1 → 1. → 1
|
||||
(1) → 1)
|
||||
가) → 가.
|
||||
"""
|
||||
|
||||
class_str = ' '.join(classes) if classes else ''
|
||||
|
||||
# ============ 제목 태그 (HTML 태그 우선) ============
|
||||
if tag == 'h1':
|
||||
return "H1"
|
||||
if tag == 'h2':
|
||||
return "H2"
|
||||
if tag == 'h3':
|
||||
return "H3"
|
||||
if tag == 'h4':
|
||||
return "H4"
|
||||
if tag == 'h5':
|
||||
return "H5"
|
||||
if tag == 'h6':
|
||||
return "H6"
|
||||
|
||||
# ============ 본문 (p, div 등) - 번호 패턴으로 분류 ============
|
||||
if tag in ['p', 'div', 'span']:
|
||||
|
||||
# ------ 숫자.숫자 패턴 (구체적 → 일반 순서!) ------
|
||||
|
||||
# "1.1.1" 패턴 (가장 구체적 - 먼저 체크!)
|
||||
if self.PATTERNS['h4_num'].match(text):
|
||||
if len(text) < 100:
|
||||
return "H3"
|
||||
return "BODY"
|
||||
|
||||
# "1.1 " 패턴
|
||||
if self.PATTERNS['h3_num'].match(text):
|
||||
if len(text) < 100:
|
||||
return "H2"
|
||||
return "BODY"
|
||||
|
||||
# "1." 패턴
|
||||
if self.PATTERNS['h2_num'].match(text):
|
||||
if len(text) < 100:
|
||||
return "H1"
|
||||
return "BODY"
|
||||
|
||||
# "1 가나다..." 패턴 (숫자+공백+한글)
|
||||
if self.PATTERNS['h1_num'].match(text):
|
||||
return "H1"
|
||||
|
||||
# ------ 괄호 패턴 (구체적 → 일반 순서!) ------
|
||||
|
||||
# "(1)" 패턴 (괄호로 감싼 게 더 구체적 - 먼저 체크!)
|
||||
if self.PATTERNS['h6_paren'].match(text):
|
||||
if element.find('strong') or len(text) < 80:
|
||||
return "H5"
|
||||
return "BODY"
|
||||
|
||||
# "1)" 패턴
|
||||
if self.PATTERNS['h5_paren'].match(text):
|
||||
if element.find('strong') or len(text) < 80:
|
||||
return "H4"
|
||||
return "BODY"
|
||||
|
||||
# ------ 한글 패턴 (구체적 → 일반 순서!) ------
|
||||
|
||||
# "가)" 패턴 (괄호가 더 구체적 - 먼저 체크!)
|
||||
if self.PATTERNS['h5_korean'].match(text):
|
||||
return "H5"
|
||||
|
||||
# "가." 패턴
|
||||
if self.PATTERNS['h4_korean'].match(text):
|
||||
return "H4"
|
||||
|
||||
# ------ 특수 기호 패턴 ------
|
||||
|
||||
# "①②③" 패턴
|
||||
if self.PATTERNS['h6_circle'].match(text):
|
||||
return "H6"
|
||||
|
||||
# ------ 기타 ------
|
||||
|
||||
# 강조 박스
|
||||
if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']):
|
||||
return "HIGHLIGHT_BOX"
|
||||
|
||||
# 일반 본문
|
||||
return "BODY"
|
||||
|
||||
# ============ 목록 ============
|
||||
if tag == 'li':
|
||||
return "LIST_ITEM"
|
||||
|
||||
# ============ 정의 목록 ============
|
||||
if tag == 'dt':
|
||||
return "H5"
|
||||
if tag == 'dd':
|
||||
return "BODY"
|
||||
|
||||
return "BODY"
|
||||
|
||||
def _process_table(self, table: Tag):
|
||||
"""테이블 처리 - 구조 데이터 포함"""
|
||||
|
||||
# 캡션
|
||||
caption = table.find('caption')
|
||||
caption_text = ""
|
||||
if caption:
|
||||
caption_text = caption.get_text(strip=True)
|
||||
if caption_text and not self._is_duplicate(caption_text):
|
||||
self.elements.append(StyledElement(
|
||||
role="TABLE_CAPTION",
|
||||
text=caption_text,
|
||||
tag="caption",
|
||||
html=str(caption)[:100],
|
||||
section=self.current_section.value,
|
||||
attributes={}
|
||||
))
|
||||
|
||||
# 🆕 표 구조 데이터 수집
|
||||
table_data = {'rows': [], 'caption': caption_text}
|
||||
|
||||
for tr in table.find_all('tr'):
|
||||
row = []
|
||||
for cell in tr.find_all(['th', 'td']):
|
||||
cell_info = {
|
||||
'text': cell.get_text(strip=True),
|
||||
'is_header': cell.name == 'th',
|
||||
'colspan': int(cell.get('colspan', 1)),
|
||||
'rowspan': int(cell.get('rowspan', 1)),
|
||||
'bg_color': self._extract_bg_color(cell),
|
||||
}
|
||||
row.append(cell_info)
|
||||
if row:
|
||||
table_data['rows'].append(row)
|
||||
|
||||
# 🆕 TABLE 요소로 추가 (개별 TH/TD 대신)
|
||||
if table_data['rows']:
|
||||
self.elements.append(StyledElement(
|
||||
role="TABLE",
|
||||
text=f"[표: {len(table_data['rows'])}행]",
|
||||
tag="table",
|
||||
html=str(table)[:200],
|
||||
section=self.current_section.value,
|
||||
attributes={'table_data': table_data}
|
||||
))
|
||||
|
||||
def _extract_bg_color(self, element: Tag) -> str:
|
||||
"""요소에서 배경색 추출"""
|
||||
style = element.get('style', '')
|
||||
|
||||
# background-color 추출
|
||||
match = re.search(r'background-color:\s*([^;]+)', style)
|
||||
if match:
|
||||
return self._normalize_color(match.group(1))
|
||||
|
||||
# bgcolor 속성
|
||||
bgcolor = element.get('bgcolor', '')
|
||||
if bgcolor:
|
||||
return self._normalize_color(bgcolor)
|
||||
|
||||
return ''
|
||||
|
||||
def _process_figure(self, element: Tag):
|
||||
"""그림 처리"""
|
||||
img = element.find('img') if element.name == 'figure' else element
|
||||
|
||||
if img and img.name == 'img':
|
||||
src = img.get('src', '')
|
||||
alt = img.get('alt', '')
|
||||
|
||||
if src: # src가 있을 때만 추가
|
||||
self.elements.append(StyledElement(
|
||||
role="FIGURE",
|
||||
text=alt or "이미지",
|
||||
tag="img",
|
||||
html=str(img)[:100],
|
||||
section=self.current_section.value,
|
||||
attributes={"src": src, "alt": alt}
|
||||
))
|
||||
|
||||
# 캡션
|
||||
if element.name == 'figure':
|
||||
figcaption = element.find('figcaption')
|
||||
if figcaption:
|
||||
text = figcaption.get_text(strip=True)
|
||||
if text and not self._should_skip_text(text):
|
||||
self.elements.append(StyledElement(
|
||||
role="FIGURE_CAPTION",
|
||||
text=text,
|
||||
tag="figcaption",
|
||||
html=str(figcaption)[:100],
|
||||
section=self.current_section.value,
|
||||
attributes={}
|
||||
))
|
||||
|
||||
def _postprocess(self):
|
||||
"""후처리: 불필요 요소 제거"""
|
||||
print(f" 🧹 후처리 중... (처리 전: {len(self.elements)}개)")
|
||||
|
||||
filtered = []
|
||||
for elem in self.elements:
|
||||
# 빈 텍스트 제거
|
||||
if not elem.text or not elem.text.strip():
|
||||
continue
|
||||
|
||||
# 머리말/꼬리말 텍스트 제거
|
||||
if self._is_header_footer_text(elem.text):
|
||||
continue
|
||||
|
||||
# 제거 패턴 체크
|
||||
skip = False
|
||||
for pattern in self.REMOVE_PATTERNS:
|
||||
if pattern.match(elem.text.strip()):
|
||||
skip = True
|
||||
break
|
||||
|
||||
if not skip:
|
||||
filtered.append(elem)
|
||||
|
||||
self.elements = filtered
|
||||
print(f" - 처리 후: {len(self.elements)}개")
|
||||
|
||||
def get_role_summary(self) -> Dict[str, int]:
|
||||
"""역할별 요소 수 요약"""
|
||||
summary = {}
|
||||
for elem in self.elements:
|
||||
summary[elem.role] = summary.get(elem.role, 0) + 1
|
||||
return dict(sorted(summary.items()))
|
||||
|
||||
|
||||
def extract_css_styles(self, html: str) -> Dict[str, Dict]:
|
||||
"""
|
||||
HTML에서 역할별 CSS 스타일 추출
|
||||
Returns: {역할: {font_size, color, bold, ...}}
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
role_styles = {}
|
||||
|
||||
# <style> 태그에서 CSS 파싱
|
||||
style_tag = soup.find('style')
|
||||
if style_tag:
|
||||
css_text = style_tag.string or ''
|
||||
role_styles.update(self._parse_css_rules(css_text))
|
||||
|
||||
# 인라인 스타일에서 추출 (요소별)
|
||||
for elem in self.elements:
|
||||
if elem.role not in role_styles:
|
||||
role_styles[elem.role] = self._extract_inline_style(elem.html)
|
||||
|
||||
return role_styles
|
||||
|
||||
def _parse_css_rules(self, css_text: str) -> Dict[str, Dict]:
|
||||
"""CSS 텍스트에서 규칙 파싱"""
|
||||
import re
|
||||
rules = {}
|
||||
|
||||
# h1, h2, .section-title 등의 패턴
|
||||
pattern = r'([^{]+)\{([^}]+)\}'
|
||||
for match in re.finditer(pattern, css_text):
|
||||
selector = match.group(1).strip()
|
||||
properties = match.group(2)
|
||||
|
||||
style = {}
|
||||
for prop in properties.split(';'):
|
||||
if ':' in prop:
|
||||
key, value = prop.split(':', 1)
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
|
||||
if key == 'font-size':
|
||||
style['font_size'] = self._parse_font_size(value)
|
||||
elif key == 'color':
|
||||
style['color'] = self._normalize_color(value)
|
||||
elif key == 'font-weight':
|
||||
style['bold'] = value in ['bold', '700', '800', '900']
|
||||
elif key == 'text-align':
|
||||
style['align'] = value
|
||||
|
||||
# 셀렉터 → 역할 매핑
|
||||
role = self._selector_to_role(selector)
|
||||
if role:
|
||||
rules[role] = style
|
||||
|
||||
return rules
|
||||
|
||||
def _selector_to_role(self, selector: str) -> str:
|
||||
"""CSS 셀렉터 → 역할 매핑"""
|
||||
selector = selector.lower().strip()
|
||||
mapping = {
|
||||
'h1': 'H1', 'h2': 'H2', 'h3': 'H3', 'h4': 'H4',
|
||||
'.cover-title': 'COVER_TITLE',
|
||||
'.section-title': 'H1',
|
||||
'th': 'TH', 'td': 'TD',
|
||||
'p': 'BODY',
|
||||
}
|
||||
for key, role in mapping.items():
|
||||
if key in selector:
|
||||
return role
|
||||
return None
|
||||
|
||||
def _parse_font_size(self, value: str) -> float:
|
||||
"""폰트 크기 파싱 (pt 단위로 변환)"""
|
||||
import re
|
||||
match = re.search(r'([\d.]+)(pt|px|em|rem)?', value)
|
||||
if match:
|
||||
size = float(match.group(1))
|
||||
unit = match.group(2) or 'pt'
|
||||
if unit == 'px':
|
||||
size = size * 0.75 # px → pt
|
||||
elif unit in ['em', 'rem']:
|
||||
size = size * 11 # 기본 11pt 기준
|
||||
return size
|
||||
return 11.0
|
||||
|
||||
def _normalize_color(self, value: str) -> str:
|
||||
"""색상값 정규화 (#RRGGBB)"""
|
||||
import re
|
||||
value = value.strip().lower()
|
||||
|
||||
# 이미 #rrggbb 형식
|
||||
if re.match(r'^#[0-9a-f]{6}$', value):
|
||||
return value.upper()
|
||||
|
||||
# #rgb → #rrggbb
|
||||
if re.match(r'^#[0-9a-f]{3}$', value):
|
||||
return f'#{value[1]*2}{value[2]*2}{value[3]*2}'.upper()
|
||||
|
||||
# rgb(r, g, b)
|
||||
match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', value)
|
||||
if match:
|
||||
r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
return f'#{r:02X}{g:02X}{b:02X}'
|
||||
|
||||
# 색상 이름
|
||||
color_names = {
|
||||
'black': '#000000', 'white': '#FFFFFF',
|
||||
'red': '#FF0000', 'green': '#008000', 'blue': '#0000FF',
|
||||
'navy': '#1A365D',
|
||||
}
|
||||
return color_names.get(value, '#000000')
|
||||
|
||||
def _extract_inline_style(self, html: str) -> Dict:
|
||||
"""HTML 요소에서 인라인 스타일 추출"""
|
||||
style = {}
|
||||
|
||||
# style 속성 찾기
|
||||
match = re.search(r'style\s*=\s*["\']([^"\']+)["\']', html)
|
||||
if match:
|
||||
style_str = match.group(1)
|
||||
for prop in style_str.split(';'):
|
||||
if ':' in prop:
|
||||
key, value = prop.split(':', 1)
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
|
||||
if key == 'font-size':
|
||||
style['font_size'] = self._parse_font_size(value)
|
||||
elif key == 'color':
|
||||
style['color'] = self._normalize_color(value)
|
||||
elif key == 'font-weight':
|
||||
style['bold'] = value in ['bold', '700', '800', '900']
|
||||
elif key == 'text-align':
|
||||
style['align'] = value
|
||||
elif key == 'background-color':
|
||||
style['bg_color'] = self._normalize_color(value)
|
||||
|
||||
return style
|
||||
|
||||
def _extract_bg_color(self, element) -> str:
|
||||
"""요소에서 배경색 추출"""
|
||||
if not hasattr(element, 'get'):
|
||||
return ''
|
||||
|
||||
style = element.get('style', '')
|
||||
|
||||
# background-color 추출
|
||||
match = re.search(r'background-color:\s*([^;]+)', style)
|
||||
if match:
|
||||
return self._normalize_color(match.group(1))
|
||||
|
||||
# bgcolor 속성
|
||||
bgcolor = element.get('bgcolor', '')
|
||||
if bgcolor:
|
||||
return self._normalize_color(bgcolor)
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def export_for_hwp(self) -> List[Dict]:
|
||||
"""HWP 변환용 데이터 내보내기"""
|
||||
return [
|
||||
{
|
||||
"role": e.role,
|
||||
"text": e.text,
|
||||
"tag": e.tag,
|
||||
"section": e.section,
|
||||
"attributes": e.attributes
|
||||
}
|
||||
for e in self.elements
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
test_html = """
|
||||
<html>
|
||||
<head>
|
||||
<script>var x = 1;</script>
|
||||
<style>.test { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<div class="page-header">건설·토목 측량 DX 실무지침</div>
|
||||
<div class="body-content">
|
||||
<h1>1 DX 개요와 기본 개념</h1>
|
||||
<h2>1.1 측량 DX 프레임</h2>
|
||||
<h3>1.1.1 측량 DX 발전 단계</h3>
|
||||
<p>1) <strong>Digitization 정의</strong></p>
|
||||
<p>본문 내용입니다. 이것은 충분히 긴 텍스트로 본문으로 인식되어야 합니다.</p>
|
||||
<p>(1) 단계별 정의 및 진화</p>
|
||||
<p>측량 기술의 발전은 장비의 변화와 성과물의 차원에 따라 구분된다.</p>
|
||||
</div>
|
||||
<div class="page-footer">건설·토목 측량 DX 실무지침- 1 -</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="page-header">건설·토목 측량 DX 실무지침</div>
|
||||
<div class="body-content">
|
||||
<p>① 첫 번째 항목</p>
|
||||
<table>
|
||||
<caption>표 1. 데이터 비교</caption>
|
||||
<tr><th>구분</th><th>내용</th></tr>
|
||||
<tr><td>항목1</td><td>설명1</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="page-footer">건설·토목 측량 DX 실무지침- 2 -</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(test_html)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("분석 결과")
|
||||
print("="*60)
|
||||
for elem in elements:
|
||||
print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("역할 요약")
|
||||
print("="*60)
|
||||
for role, count in analyzer.get_role_summary().items():
|
||||
print(f" {role}: {count}")
|
||||
0
03. Code/geulbeot_8th/domain/__init__.py
Normal file
0
03. Code/geulbeot_8th/domain/hwpx/__init__.py
Normal file
769
03. Code/geulbeot_8th/domain/hwpx/hwpx_domain_guide.md
Normal 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
03. Code/geulbeot_8th/domain/hwpx/hwpx_utils.py
Normal 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'
|
||||
7
03. Code/geulbeot_8th/handlers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
handlers 패키지
|
||||
문서 유형별 처리 로직을 분리하여 관리
|
||||
"""
|
||||
|
||||
from .doc_template_analyzer import DocTemplateAnalyzer
|
||||
5
03. Code/geulbeot_8th/handlers/briefing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 모듈
|
||||
"""
|
||||
from .processor import BriefingProcessor
|
||||
279
03. Code/geulbeot_8th/handlers/briefing/processor.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 로직
|
||||
- 1~2페이지 압축형 보고서
|
||||
- Navy 양식
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import jsonify, session
|
||||
|
||||
from handlers.common import call_claude, extract_json, extract_html, load_prompt, client
|
||||
|
||||
|
||||
class BriefingProcessor:
|
||||
"""기획서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def _get_step1_prompt(self) -> str:
|
||||
"""1단계: 구조 추출 프롬프트"""
|
||||
prompt = self._load_prompt('step1_extract.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
|
||||
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
|
||||
|
||||
def _get_step1_5_prompt(self) -> str:
|
||||
"""1.5단계: 배치 계획 프롬프트"""
|
||||
prompt = self._load_prompt('step1_5_plan.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
|
||||
|
||||
def _get_step2_prompt(self) -> str:
|
||||
"""2단계: HTML 생성 프롬프트"""
|
||||
prompt = self._load_prompt('step2_generate.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
|
||||
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
|
||||
|
||||
def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool:
|
||||
"""페이지당 콘텐츠 양 체크"""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
sheets = soup.find_all('div', class_='sheet')
|
||||
for sheet in sheets:
|
||||
sections = sheet.find_all('div', class_='section')
|
||||
if len(sections) > max_sections_per_page:
|
||||
return True
|
||||
|
||||
all_li = sheet.find_all('li')
|
||||
if len(all_li) > 12:
|
||||
return True
|
||||
|
||||
steps = sheet.find_all('div', class_='process-step')
|
||||
if len(steps) > 6:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""기획서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용을 입력하거나 파일을 업로드해주세요.'}
|
||||
|
||||
page_option = options.get('page_option', '1')
|
||||
department = options.get('department', '총괄기획실')
|
||||
additional_prompt = options.get('instruction', '')
|
||||
|
||||
# ============== 1단계: 구조 추출 ==============
|
||||
step1_prompt = self._get_step1_prompt()
|
||||
step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요.
|
||||
|
||||
## 원본 HTML
|
||||
{content}
|
||||
|
||||
---
|
||||
위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력."""
|
||||
|
||||
step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000)
|
||||
structure_json = extract_json(step1_response)
|
||||
|
||||
if not structure_json:
|
||||
structure_json = {"raw_content": content, "parse_failed": True}
|
||||
|
||||
# ============== 1.5단계: 배치 계획 ==============
|
||||
step1_5_prompt = self._get_step1_5_prompt()
|
||||
step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요.
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 수
|
||||
{page_option}페이지
|
||||
|
||||
---
|
||||
배치 계획 JSON만 출력하세요. 설명 없이 JSON만."""
|
||||
|
||||
step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000)
|
||||
page_plan = extract_json(step1_5_response)
|
||||
|
||||
if not page_plan:
|
||||
page_plan = {"page_plan": {}, "parse_failed": True}
|
||||
|
||||
# ============== 2단계: HTML 생성 ==============
|
||||
page_instructions = {
|
||||
'1': '1페이지로 핵심 내용만 압축하여 작성하세요.',
|
||||
'2': '2페이지로 작성하세요. 1페이지는 본문, 2페이지는 [첨부]입니다.',
|
||||
'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부] 형태로 분할합니다.'
|
||||
}
|
||||
|
||||
step2_prompt = self._get_step2_prompt()
|
||||
step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요.
|
||||
|
||||
## 배치 계획
|
||||
{json.dumps(page_plan, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 옵션
|
||||
{page_instructions.get(page_option, page_instructions['1'])}
|
||||
|
||||
## 부서명
|
||||
{department}
|
||||
|
||||
## 추가 요청사항
|
||||
{additional_prompt if additional_prompt else '없음'}
|
||||
|
||||
---
|
||||
위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요.
|
||||
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력."""
|
||||
|
||||
step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000)
|
||||
html_content = extract_html(step2_response)
|
||||
|
||||
# 후처리 검증
|
||||
if self._content_too_long(html_content):
|
||||
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
|
||||
각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요.
|
||||
|
||||
{html_content}
|
||||
|
||||
코드 블록 없이 압축된 완전한 HTML만 출력하세요."""
|
||||
|
||||
compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000)
|
||||
html_content = extract_html(compress_response)
|
||||
|
||||
# 세션에 저장
|
||||
session['original_html'] = content
|
||||
session['current_html'] = html_content
|
||||
session['structure_json'] = json.dumps(structure_json, ensure_ascii=False)
|
||||
session['conversation'] = []
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': html_content,
|
||||
'structure': structure_json
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. 나머지 구조와 스타일은 그대로 유지
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능
|
||||
|
||||
## 원본 HTML (참고용)
|
||||
{original_html[:3000] if original_html else '없음'}...
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등)
|
||||
|
||||
2. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용)
|
||||
|
||||
3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이)
|
||||
4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지)
|
||||
5. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
104
03. Code/geulbeot_8th/handlers/briefing/prompts/step1_5_plan.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
@@ -0,0 +1,122 @@
|
||||
당신은 HTML 문서 구조 분석 전문가입니다.
|
||||
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
|
||||
2. 문서의 논리적 구조를 정확히 파악
|
||||
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
|
||||
|
||||
## 출력 JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "문서 제목 (원문 그대로)",
|
||||
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
|
||||
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
|
||||
"lead": {
|
||||
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
|
||||
"highlight_keywords": ["강조할 키워드1", "키워드2"]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "섹션 제목 (원문 그대로)",
|
||||
"type": "list | table | grid | process | qa | text",
|
||||
"content": {
|
||||
// type에 따라 다름 (아래 참조)
|
||||
}
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"label": "라벨 (예: 핵심 결론, 요약 등)",
|
||||
"text": "결론 텍스트 (원문 그대로, 한 문장)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 섹션 type별 content 구조
|
||||
|
||||
### type: "list"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
|
||||
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "table"
|
||||
```json
|
||||
{
|
||||
"columns": ["컬럼1", "컬럼2", "컬럼3"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
|
||||
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
|
||||
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnote": "표 하단 주석 (있으면)"
|
||||
}
|
||||
```
|
||||
- badge 값: "safe" | "caution" | "risk" | null
|
||||
- highlight: true면 빨간색 강조
|
||||
|
||||
### type: "grid"
|
||||
```json
|
||||
{
|
||||
"columns": 2,
|
||||
"items": [
|
||||
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
|
||||
{"title": "② 항목 제목", "text": "설명", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "two-column"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
|
||||
{"title": "② 제목", "text": "내용", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "process"
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{"number": 1, "title": "단계명", "text": "설명"},
|
||||
{"number": 2, "title": "단계명", "text": "설명"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "qa"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"question": "질문?", "answer": "답변"},
|
||||
{"question": "질문?", "answer": "답변"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "text"
|
||||
```json
|
||||
{
|
||||
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
|
||||
}
|
||||
```
|
||||
|
||||
## 중요
|
||||
|
||||
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
|
||||
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
|
||||
3. **JSON만 출력** - 설명 없이 순수 JSON만
|
||||
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑
|
||||
@@ -0,0 +1,440 @@
|
||||
당신은 HTML 보고서 생성 전문가입니다.
|
||||
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
|
||||
|
||||
## 출력 규칙
|
||||
|
||||
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
|
||||
2. 코드 블록(```) 없이 **순수 HTML만** 출력
|
||||
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
|
||||
4. 아래 CSS를 **정확히** 사용
|
||||
|
||||
## 페이지 옵션
|
||||
|
||||
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
|
||||
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
|
||||
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
|
||||
|
||||
## HTML 템플릿 구조
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* 아래 CSS 전체 포함 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">{{department}}</div>
|
||||
<div class="header-right">{{title_en}}</div>
|
||||
</header>
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{{title}}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>{{lead.text}} - <b>키워드</b> 강조</div>
|
||||
</div>
|
||||
<!-- sections -->
|
||||
<div class="bottom-box">
|
||||
<div class="bottom-left">{{conclusion.label}}</div>
|
||||
<div class="bottom-right">{{conclusion.text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="page-footer">- 1 -</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 섹션 type별 HTML 변환
|
||||
|
||||
### list → ul/li
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<ul>
|
||||
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### table → data-table
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">{{col1}}</th>
|
||||
<th width="25%">{{col2}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{text}}</strong></td>
|
||||
<td class="highlight-red">{{text}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
|
||||
- highlight가 true면: `class="highlight-red"`
|
||||
|
||||
### grid → strategy-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### two-column → two-col
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="two-col">
|
||||
<div class="info-box">
|
||||
<div class="info-box-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### process → process-container
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="process-container">
|
||||
<div class="process-step">
|
||||
<div class="step-num">{{step.number}}</div>
|
||||
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
|
||||
</div>
|
||||
<div class="arrow">▼</div>
|
||||
<!-- 반복 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### qa → qa-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="qa-grid">
|
||||
<div class="qa-item">
|
||||
<strong>Q. {{question}}</strong><br>
|
||||
A. {{answer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 완전한 CSS (반드시 이대로 사용)
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-navy: #1a365d;
|
||||
--secondary-navy: #2c5282;
|
||||
--accent-navy: #3182ce;
|
||||
--dark-gray: #2d3748;
|
||||
--medium-gray: #4a5568;
|
||||
--light-gray: #e2e8f0;
|
||||
--bg-light: #f7fafc;
|
||||
--text-black: #1a202c;
|
||||
--border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; gap: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
font-size: 9pt;
|
||||
color: var(--medium-gray);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 23pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary-navy);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary-navy);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 11.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lead-box b { color: var(--primary-navy); font-weight: 700; }
|
||||
|
||||
.body-content { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-navy);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--secondary-navy);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 19pt;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--primary-navy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul { list-style: none; padding-left: 10px; }
|
||||
|
||||
li {
|
||||
font-size: 10.5pt;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--secondary-navy);
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.bottom-box {
|
||||
border: 1.5px solid var(--border-color);
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
min-height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
width: 18%;
|
||||
background-color: var(--primary-navy);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10.5pt;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
width: 82%;
|
||||
background-color: var(--bg-light);
|
||||
padding: 12px 18px;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.bottom-right b { display: inline; }
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 10mm;
|
||||
left: 20mm;
|
||||
right: 20mm;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 8.5pt;
|
||||
color: var(--medium-gray);
|
||||
border-top: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
b { font-weight: 700; color: var(--primary-navy); display: inline; }
|
||||
.keyword { font-weight: 600; color: var(--text-black); }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9.5pt;
|
||||
border-top: 2px solid var(--primary-navy);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--secondary-navy);
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 7px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight-red { color: #c53030; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
|
||||
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
|
||||
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
|
||||
|
||||
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
|
||||
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
|
||||
.qa-item strong { color: var(--primary-navy); }
|
||||
|
||||
.two-col { display: flex; gap: 12px; margin-top: 6px; }
|
||||
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
|
||||
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
|
||||
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
|
||||
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
|
||||
.step-content strong { color: var(--primary-navy); font-weight: 600; }
|
||||
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
|
||||
```
|
||||
|
||||
## 1페이지 본문 구성 논리
|
||||
|
||||
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
|
||||
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
|
||||
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
|
||||
|
||||
## 첨부 페이지 구성
|
||||
|
||||
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
|
||||
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
|
||||
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
|
||||
|
||||
## 중요 규칙
|
||||
|
||||
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
|
||||
- 위치 재편성, 통합/분할
|
||||
- 표 ↔ 본문 ↔ 리스트 형식 변환
|
||||
|
||||
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
|
||||
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
|
||||
- ❌ "~입니다", "~합니다", "~됩니다"
|
||||
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
|
||||
- 예시:
|
||||
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
|
||||
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
|
||||
|
||||
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
|
||||
|
||||
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
|
||||
|
||||
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
|
||||
|
||||
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
|
||||
|
||||
## 첨부 페이지 규칙
|
||||
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
|
||||
- 제목은 좌측 정렬, 16pt
|
||||
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함
|
||||
84
03. Code/geulbeot_8th/handlers/common.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
공통 유틸리티 함수
|
||||
- Claude API 호출
|
||||
- JSON/HTML 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import anthropic
|
||||
from api_config import API_KEYS
|
||||
|
||||
# Claude API 클라이언트
|
||||
client = anthropic.Anthropic(
|
||||
api_key=API_KEYS.get('CLAUDE_API_KEY', '')
|
||||
)
|
||||
|
||||
|
||||
def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str:
|
||||
"""Claude API 호출"""
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}]
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
|
||||
def extract_json(text: str) -> dict:
|
||||
"""텍스트에서 JSON 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```json' in text:
|
||||
text = text.split('```json')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
text = text.split('```')[1].split('```')[0]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 부분만 추출 시도
|
||||
match = re.search(r'\{[\s\S]*\}', text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def extract_html(text: str) -> str:
|
||||
"""텍스트에서 HTML 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```html' in text:
|
||||
text = text.split('```html')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
parts = text.split('```')
|
||||
if len(parts) >= 2:
|
||||
text = parts[1]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# <!DOCTYPE 또는 <html로 시작하는지 확인
|
||||
if not text.startswith('<!DOCTYPE') and not text.startswith('<html'):
|
||||
# HTML 부분만 추출
|
||||
match = re.search(r'(<!DOCTYPE html[\s\S]*</html>)', text, re.IGNORECASE)
|
||||
if match:
|
||||
text = match.group(1)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def load_prompt(prompts_dir: str, filename: str) -> str:
|
||||
"""프롬프트 파일 로드"""
|
||||
prompt_path = os.path.join(prompts_dir, filename)
|
||||
try:
|
||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
640
03. Code/geulbeot_8th/handlers/content_analyzer.py
Normal 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
03. Code/geulbeot_8th/handlers/custom_doc_type.py
Normal 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()}
|
||||
150
03. Code/geulbeot_8th/handlers/doc_template_analyzer.py
Normal 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
|
||||
1058
03. Code/geulbeot_8th/handlers/doc_type_analyzer.py
Normal file
5
03. Code/geulbeot_8th/handlers/report/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 모듈
|
||||
"""
|
||||
from .processor import ReportProcessor
|
||||
161
03. Code/geulbeot_8th/handlers/report/processor.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 로직
|
||||
- 다페이지 보고서
|
||||
- 원본 구조 유지
|
||||
- RAG 파이프라인 연동 (긴 문서)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from flask import session
|
||||
|
||||
from handlers.common import call_claude, extract_html, load_prompt, client
|
||||
from converters.pipeline.router import process_document, convert_image_paths
|
||||
|
||||
|
||||
class ReportProcessor:
|
||||
"""보고서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""보고서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용이 비어있습니다.'}
|
||||
|
||||
# ⭐ 템플릿 스타일 로드
|
||||
template_id = options.get('template_id')
|
||||
if template_id:
|
||||
from handlers.template import TemplateProcessor
|
||||
template_processor = TemplateProcessor()
|
||||
style = template_processor.get_style(template_id)
|
||||
if style and style.get('css'):
|
||||
options['template_css'] = style['css']
|
||||
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(content)
|
||||
|
||||
# router를 통해 분량에 따라 파이프라인 분기
|
||||
result = process_document(processed_html, options)
|
||||
|
||||
if result.get('success'):
|
||||
session['original_html'] = content
|
||||
session['current_html'] = result.get('html', '')
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. **페이지 구조(sheet, body-content, page-header 등)는 절대 변경하지 마세요**
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정 (보고서용 - 페이지 구조 보존)"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html[:5000]}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. **절대로 페이지 구조(sheet, body-content, page-header, page-footer)를 변경하지 마세요**
|
||||
2. 선택된 텍스트만 수정하고, 주변 HTML 태그는 그대로 유지
|
||||
3. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가 등)
|
||||
|
||||
4. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용만 - 선택된 텍스트의 수정본만)
|
||||
|
||||
5. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이, 선택된 텍스트의 수정본만)
|
||||
6. STRUCTURE인 경우: 해당 요소만 출력 (전체 페이지 구조 X)
|
||||
7. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
382
03. Code/geulbeot_8th/handlers/semantic_mapper.py
Normal 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
03. Code/geulbeot_8th/handlers/style_generator.py
Normal 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
|
||||
3
03. Code/geulbeot_8th/handlers/template/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .processor import TemplateProcessor
|
||||
|
||||
__all__ = ['TemplateProcessor']
|
||||
1442
03. Code/geulbeot_8th/handlers/template/html_table_template_css.txt
Normal file
625
03. Code/geulbeot_8th/handlers/template/processor.py
Normal file
@@ -0,0 +1,625 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
템플릿 처리 로직 (v3 - 실제 구조 정확 분석)
|
||||
- HWPX 파일의 실제 표 구조, 이미지 배경, 테두리 정확히 추출
|
||||
- ARGB 8자리 색상 정규화
|
||||
- NONE 테두리 색상 제외
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import shutil
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# 템플릿 저장 경로
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store'
|
||||
TEMPLATES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# HWP 명세서 기반 상수
|
||||
LINE_TYPES = {
|
||||
'NONE': '없음',
|
||||
'SOLID': '실선',
|
||||
'DASH': '긴 점선',
|
||||
'DOT': '점선',
|
||||
'DASH_DOT': '-.-.-.-.',
|
||||
'DASH_DOT_DOT': '-..-..-..',
|
||||
'DOUBLE_SLIM': '2중선',
|
||||
'SLIM_THICK': '가는선+굵은선',
|
||||
'THICK_SLIM': '굵은선+가는선',
|
||||
'SLIM_THICK_SLIM': '가는선+굵은선+가는선',
|
||||
'WAVE': '물결',
|
||||
'DOUBLE_WAVE': '물결 2중선',
|
||||
}
|
||||
|
||||
|
||||
class TemplateProcessor:
|
||||
"""템플릿 처리 클래스 (v3)"""
|
||||
|
||||
NS = {
|
||||
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
|
||||
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
|
||||
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
|
||||
'hs': 'http://www.hancom.co.kr/hwpml/2011/section',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = TEMPLATES_DIR
|
||||
self.templates_dir.mkdir(exist_ok=True)
|
||||
|
||||
# =========================================================================
|
||||
# 공개 API
|
||||
# =========================================================================
|
||||
|
||||
def get_list(self) -> Dict[str, Any]:
|
||||
"""저장된 템플릿 목록"""
|
||||
templates = []
|
||||
for item in self.templates_dir.iterdir():
|
||||
if item.is_dir():
|
||||
meta_path = item / 'meta.json'
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding='utf-8'))
|
||||
templates.append({
|
||||
'id': meta.get('id', item.name),
|
||||
'name': meta.get('name', item.name),
|
||||
'features': meta.get('features', []),
|
||||
'created_at': meta.get('created_at', '')
|
||||
})
|
||||
except:
|
||||
pass
|
||||
templates.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
return {'templates': templates}
|
||||
|
||||
def analyze(self, file, name: str) -> Dict[str, Any]:
|
||||
"""템플릿 파일 분석 및 저장"""
|
||||
filename = file.filename
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext not in ['.hwpx', '.hwp', '.pdf']:
|
||||
return {'error': f'지원하지 않는 파일 형식: {ext}'}
|
||||
|
||||
template_id = str(uuid.uuid4())[:8]
|
||||
template_dir = self.templates_dir / template_id
|
||||
template_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
original_path = template_dir / f'original{ext}'
|
||||
file.save(str(original_path))
|
||||
|
||||
if ext == '.hwpx':
|
||||
style_data = self._analyze_hwpx(original_path, template_dir)
|
||||
else:
|
||||
style_data = self._analyze_fallback(ext)
|
||||
|
||||
if 'error' in style_data:
|
||||
shutil.rmtree(template_dir)
|
||||
return style_data
|
||||
|
||||
# 특징 추출
|
||||
features = self._extract_features(style_data)
|
||||
|
||||
# 메타 저장
|
||||
meta = {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'original_file': filename,
|
||||
'file_type': ext,
|
||||
'features': features,
|
||||
'created_at': datetime.now().isoformat()
|
||||
}
|
||||
(template_dir / 'meta.json').write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# 스타일 저장
|
||||
(template_dir / 'style.json').write_text(
|
||||
json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# CSS 저장
|
||||
css = style_data.get('css', '')
|
||||
css_dir = template_dir / 'css'
|
||||
css_dir.mkdir(exist_ok=True)
|
||||
(css_dir / 'template.css').write_text(css, encoding='utf-8')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'template': {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'features': features,
|
||||
'created_at': meta['created_at']
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
if template_dir.exists():
|
||||
shutil.rmtree(template_dir)
|
||||
raise e
|
||||
|
||||
def delete(self, template_id: str) -> Dict[str, Any]:
|
||||
"""템플릿 삭제"""
|
||||
template_dir = self.templates_dir / template_id
|
||||
if not template_dir.exists():
|
||||
return {'error': '템플릿을 찾을 수 없습니다'}
|
||||
shutil.rmtree(template_dir)
|
||||
return {'success': True, 'deleted': template_id}
|
||||
|
||||
def get_style(self, template_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""템플릿 스타일 반환"""
|
||||
style_path = self.templates_dir / template_id / 'style.json'
|
||||
if not style_path.exists():
|
||||
return None
|
||||
return json.loads(style_path.read_text(encoding='utf-8'))
|
||||
|
||||
# =========================================================================
|
||||
# HWPX 분석 (핵심)
|
||||
# =========================================================================
|
||||
|
||||
def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]:
|
||||
"""HWPX 분석 - 실제 구조 정확히 추출"""
|
||||
extract_dir = template_dir / 'extracted'
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
result = {
|
||||
'version': 'v3',
|
||||
'fonts': {},
|
||||
'colors': {
|
||||
'background': [],
|
||||
'border': [],
|
||||
'text': []
|
||||
},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {},
|
||||
'css': ''
|
||||
}
|
||||
|
||||
# 1. header.xml 분석
|
||||
header_path = extract_dir / 'Contents' / 'header.xml'
|
||||
if header_path.exists():
|
||||
self._parse_header(header_path, result)
|
||||
|
||||
# 2. section0.xml 분석
|
||||
section_path = extract_dir / 'Contents' / 'section0.xml'
|
||||
if section_path.exists():
|
||||
self._parse_section(section_path, result)
|
||||
|
||||
# 3. 스타일 요약 생성
|
||||
result['style_summary'] = self._create_style_summary(result)
|
||||
|
||||
# 4. CSS 생성
|
||||
result['css'] = self._generate_css(result)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
def _parse_header(self, header_path: Path, result: Dict):
|
||||
"""header.xml 파싱 - 폰트, borderFill"""
|
||||
tree = ET.parse(header_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# 폰트
|
||||
for fontface in root.findall('.//hh:fontface', self.NS):
|
||||
if fontface.get('lang') == 'HANGUL':
|
||||
for font in fontface.findall('hh:font', self.NS):
|
||||
result['fonts'][font.get('id')] = font.get('face')
|
||||
|
||||
# borderFill
|
||||
for bf in root.findall('.//hh:borderFill', self.NS):
|
||||
bf_id = bf.get('id')
|
||||
bf_data = self._parse_border_fill(bf, result)
|
||||
result['border_fills'][bf_id] = bf_data
|
||||
|
||||
def _parse_border_fill(self, bf, result: Dict) -> Dict:
|
||||
"""개별 borderFill 파싱"""
|
||||
bf_id = bf.get('id')
|
||||
data = {
|
||||
'id': bf_id,
|
||||
'type': 'empty',
|
||||
'background': None,
|
||||
'image': None,
|
||||
'borders': {}
|
||||
}
|
||||
|
||||
# 이미지 배경
|
||||
img_brush = bf.find('.//hc:imgBrush', self.NS)
|
||||
if img_brush is not None:
|
||||
img = img_brush.find('hc:img', self.NS)
|
||||
if img is not None:
|
||||
data['type'] = 'image'
|
||||
data['image'] = {
|
||||
'ref': img.get('binaryItemIDRef'),
|
||||
'effect': img.get('effect')
|
||||
}
|
||||
|
||||
# 단색 배경
|
||||
win_brush = bf.find('.//hc:winBrush', self.NS)
|
||||
if win_brush is not None:
|
||||
face_color = self._normalize_color(win_brush.get('faceColor'))
|
||||
if face_color and face_color != 'none':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'solid'
|
||||
data['background'] = face_color
|
||||
if face_color not in result['colors']['background']:
|
||||
result['colors']['background'].append(face_color)
|
||||
|
||||
# 4방향 테두리
|
||||
for side in ['top', 'bottom', 'left', 'right']:
|
||||
border = bf.find(f'hh:{side}Border', self.NS)
|
||||
if border is not None:
|
||||
border_type = border.get('type', 'NONE')
|
||||
width = border.get('width', '0.1 mm')
|
||||
color = self._normalize_color(border.get('color', '#000000'))
|
||||
|
||||
data['borders'][side] = {
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'width_mm': self._parse_width(width),
|
||||
'color': color
|
||||
}
|
||||
|
||||
# 보이는 테두리만 색상 수집
|
||||
if border_type != 'NONE':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'border_only'
|
||||
if color and color not in result['colors']['border']:
|
||||
result['colors']['border'].append(color)
|
||||
|
||||
# 특수 테두리 수집
|
||||
if border_type not in ['SOLID', 'NONE']:
|
||||
result['special_borders'].append({
|
||||
'bf_id': bf_id,
|
||||
'side': side,
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'color': color
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def _parse_section(self, section_path: Path, result: Dict):
|
||||
"""section0.xml 파싱 - 표 구조"""
|
||||
tree = ET.parse(section_path)
|
||||
root = tree.getroot()
|
||||
|
||||
border_fills = result['border_fills']
|
||||
|
||||
for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'):
|
||||
table_data = {
|
||||
'rows': int(tbl.get('rowCnt', 0)),
|
||||
'cols': int(tbl.get('colCnt', 0)),
|
||||
'cells': [],
|
||||
'structure': {
|
||||
'header_row_style': None,
|
||||
'first_col_style': None,
|
||||
'body_style': None,
|
||||
'has_image_cells': False
|
||||
}
|
||||
}
|
||||
|
||||
# 셀별 분석
|
||||
cell_by_position = {}
|
||||
for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'):
|
||||
cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr')
|
||||
if cell_addr is None:
|
||||
continue
|
||||
|
||||
row = int(cell_addr.get('rowAddr', 0))
|
||||
col = int(cell_addr.get('colAddr', 0))
|
||||
bf_id = tc.get('borderFillIDRef')
|
||||
bf_info = border_fills.get(bf_id, {})
|
||||
|
||||
# 텍스트 추출
|
||||
text = ''
|
||||
for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'):
|
||||
if t.text:
|
||||
text += t.text
|
||||
|
||||
cell_data = {
|
||||
'row': row,
|
||||
'col': col,
|
||||
'bf_id': bf_id,
|
||||
'bf_type': bf_info.get('type'),
|
||||
'background': bf_info.get('background'),
|
||||
'image': bf_info.get('image'),
|
||||
'text_preview': text[:30] if text else ''
|
||||
}
|
||||
|
||||
table_data['cells'].append(cell_data)
|
||||
cell_by_position[(row, col)] = cell_data
|
||||
|
||||
if bf_info.get('type') == 'image':
|
||||
table_data['structure']['has_image_cells'] = True
|
||||
|
||||
# 구조 분석: 헤더행, 첫열 스타일
|
||||
self._analyze_table_structure(table_data, cell_by_position, border_fills)
|
||||
|
||||
result['tables'].append(table_data)
|
||||
|
||||
def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict):
|
||||
"""표 구조 분석 - 헤더행/첫열 스타일 파악"""
|
||||
rows = table_data['rows']
|
||||
cols = table_data['cols']
|
||||
|
||||
if rows == 0 or cols == 0:
|
||||
return
|
||||
|
||||
# 첫 행 (헤더) 분석
|
||||
header_styles = []
|
||||
for c in range(cols):
|
||||
cell = cells.get((0, c))
|
||||
if cell:
|
||||
header_styles.append(cell.get('bf_id'))
|
||||
|
||||
if header_styles:
|
||||
# 가장 많이 쓰인 스타일
|
||||
most_common = Counter(header_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['header_row_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background'),
|
||||
'borders': bf.get('borders', {})
|
||||
}
|
||||
|
||||
# 첫 열 분석 (행 1부터)
|
||||
first_col_styles = []
|
||||
for r in range(1, rows):
|
||||
cell = cells.get((r, 0))
|
||||
if cell:
|
||||
first_col_styles.append(cell.get('bf_id'))
|
||||
|
||||
if first_col_styles:
|
||||
most_common = Counter(first_col_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['first_col_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background')
|
||||
}
|
||||
|
||||
# 본문 셀 스타일 (첫열 제외)
|
||||
body_styles = []
|
||||
for r in range(1, rows):
|
||||
for c in range(1, cols):
|
||||
cell = cells.get((r, c))
|
||||
if cell:
|
||||
body_styles.append(cell.get('bf_id'))
|
||||
|
||||
if body_styles:
|
||||
most_common = Counter(body_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
table_data['structure']['body_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background') if bf else None
|
||||
}
|
||||
|
||||
def _create_style_summary(self, result: Dict) -> Dict:
|
||||
"""AI 프롬프트용 스타일 요약"""
|
||||
summary = {
|
||||
'폰트': list(result['fonts'].values())[:3],
|
||||
'색상': {
|
||||
'배경색': result['colors']['background'],
|
||||
'테두리색': result['colors']['border']
|
||||
},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
}
|
||||
|
||||
# 표별 스타일 요약
|
||||
for i, tbl in enumerate(result['tables']):
|
||||
tbl_summary = {
|
||||
'표번호': i + 1,
|
||||
'크기': f"{tbl['rows']}행 × {tbl['cols']}열",
|
||||
'이미지셀': tbl['structure']['has_image_cells']
|
||||
}
|
||||
|
||||
header = tbl['structure'].get('header_row_style')
|
||||
if header:
|
||||
tbl_summary['헤더행'] = f"배경={header.get('background')}"
|
||||
|
||||
first_col = tbl['structure'].get('first_col_style')
|
||||
if first_col:
|
||||
tbl_summary['첫열'] = f"배경={first_col.get('background')}"
|
||||
|
||||
body = tbl['structure'].get('body_style')
|
||||
if body:
|
||||
tbl_summary['본문'] = f"배경={body.get('background') or '없음'}"
|
||||
|
||||
summary['표_스타일'].append(tbl_summary)
|
||||
|
||||
# 특수 테두리 요약
|
||||
seen = set()
|
||||
for sb in result['special_borders']:
|
||||
key = f"{sb['type_name']} {sb['width']} {sb['color']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
summary['특수_테두리'].append(key)
|
||||
|
||||
return summary
|
||||
|
||||
def _generate_css(self, result: Dict) -> str:
|
||||
"""CSS 생성 - 실제 구조 반영"""
|
||||
fonts = list(result['fonts'].values())[:2]
|
||||
font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'"
|
||||
|
||||
bg_colors = result['colors']['background']
|
||||
header_bg = bg_colors[0] if bg_colors else '#D6D6D6'
|
||||
|
||||
# 특수 테두리에서 2중선 찾기
|
||||
double_border = None
|
||||
for sb in result['special_borders']:
|
||||
if 'DOUBLE' in sb['type']:
|
||||
double_border = sb
|
||||
break
|
||||
|
||||
css = f"""/* 템플릿 스타일 v3 - HWPX 구조 기반 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {{
|
||||
--font-primary: 'Noto Sans KR', {font_family}, sans-serif;
|
||||
--color-header-bg: {header_bg};
|
||||
--color-border: #000000;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: var(--font-primary);
|
||||
font-size: 10pt;
|
||||
line-height: 1.6;
|
||||
color: #000000;
|
||||
}}
|
||||
|
||||
.sheet {{
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 20mm;
|
||||
margin: 10px auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
|
||||
@media print {{
|
||||
.sheet {{ margin: 0; box-shadow: none; page-break-after: always; }}
|
||||
}}
|
||||
|
||||
/* 표 기본 */
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
border: 0.12mm solid var(--color-border);
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}}
|
||||
|
||||
/* 헤더 행 */
|
||||
thead th, tr:first-child th, tr:first-child td {{
|
||||
background-color: var(--color-header-bg);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
/* 첫 열 (구분 열) - 배경색 */
|
||||
td:first-child {{
|
||||
background-color: var(--color-header-bg);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
/* 본문 셀 - 배경 없음 */
|
||||
td:not(:first-child) {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* 2중선 테두리 (헤더 하단) */
|
||||
thead tr:last-child th,
|
||||
thead tr:last-child td,
|
||||
tr:first-child th,
|
||||
tr:first-child td {{
|
||||
border-bottom: 0.5mm double var(--color-border);
|
||||
}}
|
||||
"""
|
||||
return css
|
||||
|
||||
# =========================================================================
|
||||
# 유틸리티
|
||||
# =========================================================================
|
||||
|
||||
def _normalize_color(self, color: str) -> str:
|
||||
"""ARGB 8자리 → RGB 6자리"""
|
||||
if not color or color == 'none':
|
||||
return color
|
||||
color = color.strip()
|
||||
# #AARRGGBB → #RRGGBB
|
||||
if color.startswith('#') and len(color) == 9:
|
||||
return '#' + color[3:]
|
||||
return color
|
||||
|
||||
def _parse_width(self, width_str: str) -> float:
|
||||
"""너비 문자열 → mm"""
|
||||
if not width_str:
|
||||
return 0.1
|
||||
try:
|
||||
return float(width_str.split()[0])
|
||||
except:
|
||||
return 0.1
|
||||
|
||||
def _extract_features(self, data: Dict) -> List[str]:
|
||||
"""특징 목록"""
|
||||
features = []
|
||||
|
||||
fonts = list(data.get('fonts', {}).values())
|
||||
if fonts:
|
||||
features.append(f"폰트: {', '.join(fonts[:2])}")
|
||||
|
||||
bg_colors = data.get('colors', {}).get('background', [])
|
||||
if bg_colors:
|
||||
features.append(f"배경색: {', '.join(bg_colors[:2])}")
|
||||
|
||||
tables = data.get('tables', [])
|
||||
if tables:
|
||||
has_img = any(t['structure']['has_image_cells'] for t in tables)
|
||||
if has_img:
|
||||
features.append("이미지 배경 셀")
|
||||
|
||||
special = data.get('special_borders', [])
|
||||
if special:
|
||||
types = set(s['type_name'] for s in special)
|
||||
features.append(f"특수 테두리: {', '.join(list(types)[:2])}")
|
||||
|
||||
return features if features else ['기본 템플릿']
|
||||
|
||||
def _analyze_fallback(self, ext: str) -> Dict:
|
||||
"""HWP, PDF 기본 분석"""
|
||||
return {
|
||||
'version': 'v3',
|
||||
'fonts': {'0': '맑은 고딕'},
|
||||
'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {
|
||||
'폰트': ['맑은 고딕'],
|
||||
'색상': {'배경색': [], '테두리색': ['#000000']},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
},
|
||||
'css': self._get_default_css(),
|
||||
'note': f'{ext} 파일은 기본 분석만 지원. HWPX 권장.'
|
||||
}
|
||||
|
||||
def _get_default_css(self) -> str:
|
||||
return """/* 기본 스타일 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; }
|
||||
.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 0.5pt solid #000; padding: 8px; }
|
||||
th { background: #D6D6D6; }
|
||||
"""
|
||||
@@ -0,0 +1,28 @@
|
||||
당신은 문서 템플릿 분석 전문가입니다.
|
||||
|
||||
주어진 HWPX/HWP/PDF 템플릿의 구조를 분석하여 다음 정보를 추출해주세요:
|
||||
|
||||
1. 제목 스타일 (H1~H6)
|
||||
- 폰트명, 크기(pt), 굵기, 색상
|
||||
- 정렬 방식
|
||||
- 번호 체계 (제1장, 1.1, 가. 등)
|
||||
|
||||
2. 본문 스타일
|
||||
- 기본 폰트, 크기, 줄간격
|
||||
- 들여쓰기
|
||||
|
||||
3. 표 스타일
|
||||
- 헤더 배경색
|
||||
- 테두리 스타일 (선 두께, 색상)
|
||||
- 이중선 사용 여부
|
||||
|
||||
4. 그림/캡션 스타일
|
||||
- 캡션 위치 (상/하)
|
||||
- 캡션 형식
|
||||
|
||||
5. 페이지 구성
|
||||
- 표지 유무
|
||||
- 목차 유무
|
||||
- 머리말/꼬리말
|
||||
|
||||
분석 결과를 JSON 형식으로 출력해주세요.
|
||||
1008
03. Code/geulbeot_8th/handlers/template_manager.py
Normal file
51
03. Code/geulbeot_8th/handlers/tools/__init__.py
Normal 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"
|
||||
]
|
||||
127
03. Code/geulbeot_8th/handlers/tools/border_fill.py
Normal 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
|
||||
133
03. Code/geulbeot_8th/handlers/tools/char_style.py
Normal 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
|
||||
529
03. Code/geulbeot_8th/handlers/tools/content_order.py
Normal 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
03. Code/geulbeot_8th/handlers/tools/font.py
Normal 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
|
||||
200
03. Code/geulbeot_8th/handlers/tools/header_footer.py
Normal 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
03. Code/geulbeot_8th/handlers/tools/image.py
Normal 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
03. Code/geulbeot_8th/handlers/tools/numbering.py
Normal 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
|
||||
110
03. Code/geulbeot_8th/handlers/tools/page_setup.py
Normal 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
|
||||
185
03. Code/geulbeot_8th/handlers/tools/para_style.py
Normal 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
03. Code/geulbeot_8th/handlers/tools/section.py
Normal 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
|
||||
68
03. Code/geulbeot_8th/handlers/tools/style_def.py
Normal 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
03. Code/geulbeot_8th/handlers/tools/table.py
Normal 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
|
||||
BIN
03. Code/geulbeot_8th/output/assets/1_1_1_img01.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_1_img02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_1_img03.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_2_img01.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_2_img02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_2_img03.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_3_img01.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_1_3_img02.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_2_1_img03.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_2_2_img01.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_2_2_img02.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
03. Code/geulbeot_8th/output/assets/1_2_2_img03.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
104
03. Code/geulbeot_8th/prompts/step1_5_plan.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
122
03. Code/geulbeot_8th/prompts/step1_extract.txt
Normal file
@@ -0,0 +1,122 @@
|
||||
당신은 HTML 문서 구조 분석 전문가입니다.
|
||||
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
|
||||
2. 문서의 논리적 구조를 정확히 파악
|
||||
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
|
||||
|
||||
## 출력 JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "문서 제목 (원문 그대로)",
|
||||
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
|
||||
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
|
||||
"lead": {
|
||||
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
|
||||
"highlight_keywords": ["강조할 키워드1", "키워드2"]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "섹션 제목 (원문 그대로)",
|
||||
"type": "list | table | grid | process | qa | text",
|
||||
"content": {
|
||||
// type에 따라 다름 (아래 참조)
|
||||
}
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"label": "라벨 (예: 핵심 결론, 요약 등)",
|
||||
"text": "결론 텍스트 (원문 그대로, 한 문장)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 섹션 type별 content 구조
|
||||
|
||||
### type: "list"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
|
||||
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "table"
|
||||
```json
|
||||
{
|
||||
"columns": ["컬럼1", "컬럼2", "컬럼3"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
|
||||
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
|
||||
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnote": "표 하단 주석 (있으면)"
|
||||
}
|
||||
```
|
||||
- badge 값: "safe" | "caution" | "risk" | null
|
||||
- highlight: true면 빨간색 강조
|
||||
|
||||
### type: "grid"
|
||||
```json
|
||||
{
|
||||
"columns": 2,
|
||||
"items": [
|
||||
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
|
||||
{"title": "② 항목 제목", "text": "설명", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "two-column"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
|
||||
{"title": "② 제목", "text": "내용", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "process"
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{"number": 1, "title": "단계명", "text": "설명"},
|
||||
{"number": 2, "title": "단계명", "text": "설명"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "qa"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"question": "질문?", "answer": "답변"},
|
||||
{"question": "질문?", "answer": "답변"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "text"
|
||||
```json
|
||||
{
|
||||
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
|
||||
}
|
||||
```
|
||||
|
||||
## 중요
|
||||
|
||||
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
|
||||
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
|
||||
3. **JSON만 출력** - 설명 없이 순수 JSON만
|
||||
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑
|
||||
440
03. Code/geulbeot_8th/prompts/step2_generate.txt
Normal file
@@ -0,0 +1,440 @@
|
||||
당신은 HTML 보고서 생성 전문가입니다.
|
||||
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
|
||||
|
||||
## 출력 규칙
|
||||
|
||||
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
|
||||
2. 코드 블록(```) 없이 **순수 HTML만** 출력
|
||||
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
|
||||
4. 아래 CSS를 **정확히** 사용
|
||||
|
||||
## 페이지 옵션
|
||||
|
||||
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
|
||||
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
|
||||
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
|
||||
|
||||
## HTML 템플릿 구조
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* 아래 CSS 전체 포함 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">{{department}}</div>
|
||||
<div class="header-right">{{title_en}}</div>
|
||||
</header>
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{{title}}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>{{lead.text}} - <b>키워드</b> 강조</div>
|
||||
</div>
|
||||
<!-- sections -->
|
||||
<div class="bottom-box">
|
||||
<div class="bottom-left">{{conclusion.label}}</div>
|
||||
<div class="bottom-right">{{conclusion.text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="page-footer">- 1 -</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 섹션 type별 HTML 변환
|
||||
|
||||
### list → ul/li
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<ul>
|
||||
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### table → data-table
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">{{col1}}</th>
|
||||
<th width="25%">{{col2}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{text}}</strong></td>
|
||||
<td class="highlight-red">{{text}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
|
||||
- highlight가 true면: `class="highlight-red"`
|
||||
|
||||
### grid → strategy-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### two-column → two-col
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="two-col">
|
||||
<div class="info-box">
|
||||
<div class="info-box-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### process → process-container
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="process-container">
|
||||
<div class="process-step">
|
||||
<div class="step-num">{{step.number}}</div>
|
||||
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
|
||||
</div>
|
||||
<div class="arrow">▼</div>
|
||||
<!-- 반복 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### qa → qa-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="qa-grid">
|
||||
<div class="qa-item">
|
||||
<strong>Q. {{question}}</strong><br>
|
||||
A. {{answer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 완전한 CSS (반드시 이대로 사용)
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-navy: #1a365d;
|
||||
--secondary-navy: #2c5282;
|
||||
--accent-navy: #3182ce;
|
||||
--dark-gray: #2d3748;
|
||||
--medium-gray: #4a5568;
|
||||
--light-gray: #e2e8f0;
|
||||
--bg-light: #f7fafc;
|
||||
--text-black: #1a202c;
|
||||
--border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; gap: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
font-size: 9pt;
|
||||
color: var(--medium-gray);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 23pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary-navy);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary-navy);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 11.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lead-box b { color: var(--primary-navy); font-weight: 700; }
|
||||
|
||||
.body-content { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-navy);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--secondary-navy);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 19pt;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--primary-navy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul { list-style: none; padding-left: 10px; }
|
||||
|
||||
li {
|
||||
font-size: 10.5pt;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--secondary-navy);
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.bottom-box {
|
||||
border: 1.5px solid var(--border-color);
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
min-height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
width: 18%;
|
||||
background-color: var(--primary-navy);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10.5pt;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
width: 82%;
|
||||
background-color: var(--bg-light);
|
||||
padding: 12px 18px;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.bottom-right b { display: inline; }
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 10mm;
|
||||
left: 20mm;
|
||||
right: 20mm;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 8.5pt;
|
||||
color: var(--medium-gray);
|
||||
border-top: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
b { font-weight: 700; color: var(--primary-navy); display: inline; }
|
||||
.keyword { font-weight: 600; color: var(--text-black); }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9.5pt;
|
||||
border-top: 2px solid var(--primary-navy);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--secondary-navy);
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 7px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight-red { color: #c53030; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
|
||||
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
|
||||
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
|
||||
|
||||
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
|
||||
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
|
||||
.qa-item strong { color: var(--primary-navy); }
|
||||
|
||||
.two-col { display: flex; gap: 12px; margin-top: 6px; }
|
||||
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
|
||||
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
|
||||
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
|
||||
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
|
||||
.step-content strong { color: var(--primary-navy); font-weight: 600; }
|
||||
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
|
||||
```
|
||||
|
||||
## 1페이지 본문 구성 논리
|
||||
|
||||
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
|
||||
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
|
||||
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
|
||||
|
||||
## 첨부 페이지 구성
|
||||
|
||||
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
|
||||
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
|
||||
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
|
||||
|
||||
## 중요 규칙
|
||||
|
||||
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
|
||||
- 위치 재편성, 통합/분할
|
||||
- 표 ↔ 본문 ↔ 리스트 형식 변환
|
||||
|
||||
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
|
||||
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
|
||||
- ❌ "~입니다", "~합니다", "~됩니다"
|
||||
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
|
||||
- 예시:
|
||||
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
|
||||
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
|
||||
|
||||
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
|
||||
|
||||
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
|
||||
|
||||
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
|
||||
|
||||
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
|
||||
|
||||
## 첨부 페이지 규칙
|
||||
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
|
||||
- 제목은 좌측 정렬, 16pt
|
||||
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함
|
||||
5
03. Code/geulbeot_8th/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask==3.0.0
|
||||
anthropic==0.39.0
|
||||
gunicorn==21.2.0
|
||||
python-dotenv==1.0.0
|
||||
weasyprint==60.1
|
||||
297
03. Code/geulbeot_8th/static/css/editor.css
Normal file
@@ -0,0 +1,297 @@
|
||||
/* ===== 편집 바 스타일 ===== */
|
||||
.format-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--ui-panel);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.format-bar.active { display: flex; }
|
||||
|
||||
/* 편집 바 2줄 구조 */
|
||||
.format-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.format-row:first-child {
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ui-text);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-btn:hover { background: var(--ui-hover); }
|
||||
.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); }
|
||||
|
||||
.format-select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.format-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--ui-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* 툴팁 */
|
||||
.format-btn .tooltip {
|
||||
position: absolute;
|
||||
bottom: -28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.format-btn:hover .tooltip { opacity: 1; }
|
||||
|
||||
/* 페이지 버튼 스타일 */
|
||||
.format-btn.page-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* 페이지 브레이크 표시 */
|
||||
.page-break-forced {
|
||||
border-top: 3px solid #e65100 !important;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.move-to-prev-page {
|
||||
border-top: 3px dashed #1976d2 !important;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 색상 선택기 */
|
||||
.color-picker-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-picker-btn input[type="color"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 편집 모드 활성 블록 */
|
||||
.active-block {
|
||||
outline: 2px dashed var(--ui-accent) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 표 삽입 모달 */
|
||||
.table-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-modal.active { display: flex; }
|
||||
|
||||
.table-modal-content {
|
||||
background: var(--ui-panel);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 320px;
|
||||
border: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.table-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-modal-row label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--ui-dim);
|
||||
}
|
||||
|
||||
.table-modal-row input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-modal-row input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.table-modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.table-modal-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-modal-btn.primary {
|
||||
background: var(--ui-accent);
|
||||
color: #003300;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-modal-btn.secondary {
|
||||
background: var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
/* 토스트 메시지 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
||||
}
|
||||
|
||||
.resizable-container { position: relative; display: inline-block; max-width: 100%; }
|
||||
.resizable-container.block-type { display: block; }
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #00C853;
|
||||
cursor: se-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 100;
|
||||
border-radius: 3px 0 3px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: '⤡';
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.resizable-container:hover .resize-handle { opacity: 0.8; }
|
||||
.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); }
|
||||
.resizable-container.resizing { outline: 2px dashed #00C853 !important; }
|
||||
.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; }
|
||||
|
||||
/* 표 전용 */
|
||||
.resizable-container.table-resize .resize-handle { background: #2196F3; }
|
||||
.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; }
|
||||
|
||||
/* 이미지 전용 */
|
||||
.resizable-container.figure-resize img { display: block; }
|
||||
|
||||
/* 크기 표시 툴팁 */
|
||||
.size-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizable-container:hover .size-tooltip,
|
||||
.resizable-container.resizing .size-tooltip { opacity: 1; }
|
||||
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 인쇄 시 숨김 */
|
||||
@media print {
|
||||
.format-bar,
|
||||
.table-modal,
|
||||
.toast-container {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
1208
03. Code/geulbeot_8th/static/js/editor.js
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
302
03. Code/geulbeot_8th/templates/hwp_guide.md
Normal 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 계열)
|
||||
```
|
||||
116
03. Code/geulbeot_8th/templates/hwp_html_defaults.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3766
03. Code/geulbeot_8th/templates/index.html
Normal 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"
|
||||
}
|
||||
@@ -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...",
|
||||
"글벗 & 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||