v4:코드모듈화_20260123
This commit is contained in:
7
.env.sample
Normal file
7
.env.sample
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 글벗 API Keys
|
||||||
|
# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요
|
||||||
|
# cp .env.sample .env
|
||||||
|
|
||||||
|
CLAUDE_API_KEY=여기에_키값_입력
|
||||||
|
GEMINI_API_KEY=여기에_키값_입력
|
||||||
|
GPT_API_KEY=여기에_키값_입력
|
||||||
385
README.md
385
README.md
@@ -1,145 +1,308 @@
|
|||||||
# 글벗 Light v3.0
|
# 글벗 (Geulbeot) v4.0
|
||||||
|
|
||||||
AI 기반 문서 자동화 시스템 — 9단계 RAG 파이프라인 + 웹 편집기 + HWP 변환
|
**AI 기반 문서 자동화 시스템 — GPD 총괄기획실**
|
||||||
|
|
||||||
## 🎯 개요
|
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
|
||||||
|
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
|
||||||
|
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
|
||||||
|
|
||||||
다양한 형식의 입력 문서(PDF, HWP, 이미지, 동영상 등)를 분석하여 표준 HTML 보고서를 자동 생성하고, 웹 편집기로 수정한 뒤 HTML/PDF/HWP로 출력하는 시스템입니다.
|
---
|
||||||
|
|
||||||
## 📁 프로젝트 구조
|
## 🏗 아키텍처 (Architecture)
|
||||||
|
|
||||||
|
### 핵심 흐름
|
||||||
|
|
||||||
```
|
```
|
||||||
geulbeot_3rd/
|
자료 입력 (파일/폴더)
|
||||||
├── app.py # Flask 메인 서버 (579줄)
|
│
|
||||||
├── api_config.py # API 키 로더
|
▼
|
||||||
├── converters/
|
RAG 파이프라인 (9단계) ─── 공통 처리
|
||||||
│ ├── pipeline/ # 9단계 RAG 파이프라인
|
│
|
||||||
│ │ ├── router.py # 분기 판단 (긴/짧은 문서)
|
▼
|
||||||
│ │ ├── step1_convert.py # 파일→PDF 변환 (783줄)
|
문서 유형 선택
|
||||||
│ │ ├── step2_extract.py # 텍스트/이미지 추출 (788줄)
|
├─ 기획서 (기본)
|
||||||
│ │ ├── step3_domain.py # 도메인 분석 (265줄)
|
├─ 보고서 (기본)
|
||||||
│ │ ├── step4_chunk.py # 청킹 (356줄)
|
├─ 발표자료 (기본)
|
||||||
│ │ ├── step5_rag.py # RAG 임베딩 (141줄)
|
└─ 사용자 등록 (확장 가능)
|
||||||
│ │ ├── step6_corpus.py # 코퍼스 생성 (231줄)
|
│
|
||||||
│ │ ├── step7_index.py # 인덱싱 + 목차 생성 (504줄)
|
▼
|
||||||
│ │ ├── step8_content.py # 콘텐츠 생성 (1020줄)
|
글벗 표준 HTML 생성
|
||||||
│ │ └── step9_html.py # HTML 생성 (1248줄)
|
│
|
||||||
│ ├── html_to_hwp.py # 보고서→HWP 변환 (572줄)
|
▼
|
||||||
│ └── html_to_hwp_briefing.py # 기획서→HWP 변환 (572줄)
|
웹 편집기 (수기 편집 / AI 편집)
|
||||||
├── prompts/
|
│
|
||||||
│ ├── step1_extract.txt # 구조 추출 프롬프트
|
▼
|
||||||
│ ├── step1_5_plan.txt # 배치 계획 프롬프트
|
출력 (HTML / PDF / HWP)
|
||||||
│ └── step2_generate.txt # HTML 생성 프롬프트
|
|
||||||
├── static/
|
|
||||||
│ ├── css/editor.css # 편집기 스타일
|
|
||||||
│ └── js/editor.js # 편집기 기능
|
|
||||||
├── templates/
|
|
||||||
│ ├── index.html # 메인 UI
|
|
||||||
│ └── hwp_guide.html # HWP 변환 가이드
|
|
||||||
├── output/assets/ # 이미지 에셋
|
|
||||||
├── requirements.txt
|
|
||||||
├── Procfile
|
|
||||||
└── railway.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ 프로세스 플로우
|
### 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 파이프라인 (파일 변환 → 추출 → 도메인 분석 → 청킹 → 임베딩 → 코퍼스 → 인덱싱 → 콘텐츠 생성 → HTML 조립)
|
||||||
|
- 문서 유형별 생성: 기획서 (Claude 3단계), 보고서 (Gemini 2단계)
|
||||||
|
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
|
||||||
|
- HWP 변환: HTML 스타일 분석 → 역할 매핑 → HWPX 생성
|
||||||
|
- PDF 변환: WeasyPrint 기반
|
||||||
|
|
||||||
|
### 2. Frontend (순수 JavaScript)
|
||||||
|
|
||||||
|
- **Features**:
|
||||||
|
- 웹 WYSIWYG 편집기 — 브라우저에서 생성된 문서 직접 수정
|
||||||
|
- 페이지 넘김·들여쓰기·정렬 등 서식 도구
|
||||||
|
- HTML / PDF / HWP 다운로드
|
||||||
|
|
||||||
|
### 3. 변환 엔진 (Converters)
|
||||||
|
|
||||||
|
- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립
|
||||||
|
- **분량 자동 판단**: 5,000자 기준 — 긴 문서는 전체 파이프라인, 짧은 문서는 축약 파이프라인
|
||||||
|
- **HWP 변환**: pyhwpx 기반 + v4에서 추가된 스타일 분석기·HWPX 생성기·매핑 모듈
|
||||||
|
|
||||||
|
### 4. 주요 시나리오 (Core Scenarios)
|
||||||
|
|
||||||
|
1. **기획서 생성**: 텍스트 또는 파일을 입력하면, RAG 분석 후 Claude API가 구조 추출 → 페이지 배치 계획 → 글벗 표준 HTML 기획서를 생성. 1~N페이지 옵션 지원
|
||||||
|
2. **보고서 생성**: 폴더 경로의 자료들을 RAG 파이프라인으로 분석하고, Gemini API가 섹션별 콘텐츠 초안 → 표지·목차·간지·별첨이 포함된 다페이지 HTML 보고서를 생성
|
||||||
|
3. **AI 편집**: 생성된 문서를 웹 편집기에서 확인 후, "이 부분을 표로 바꿔줘" 같은 피드백으로 전체 또는 선택 부분을 AI가 수정
|
||||||
|
4. **HWP 내보내기**: 글벗 HTML을 스타일 분석기가 요소별 역할을 분류하고, HWP 스타일로 매핑하여 서식이 유지된 HWP 파일로 변환
|
||||||
|
|
||||||
|
### 프로세스 플로우
|
||||||
|
|
||||||
|
#### RAG 파이프라인 (공통)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TD
|
||||||
subgraph INPUT["📥 Input"]
|
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
|
||||||
direction TB
|
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||||
A["🗂️ 문서 입력\nPDF, HWP, 이미지, 동영상"] --> B["step1: 파일 변환\nPDF 통일"]
|
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
|
||||||
B --> C["step2: 텍스트/이미지 추출\n(GPT API)"]
|
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||||
C --> D{"router.py\n분량 판단\n5000자 기준"}
|
|
||||||
D -->|"긴 문서"| E["step3: 도메인 분석"]
|
|
||||||
D -->|"짧은 문서"| H
|
|
||||||
E --> F["step4: 청킹"]
|
|
||||||
F --> G["step5: RAG 임베딩"]
|
|
||||||
G --> H["step6: 코퍼스 생성"]
|
|
||||||
H --> I["step7: 인덱싱 + 목차 생성\n(GPT API)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph OUTPUT["📤 Output"]
|
A[/"📂 자료 입력 (파일/폴더)"/]:::process
|
||||||
direction TB
|
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
|
||||||
I --> J["step8: 콘텐츠 생성\n(Gemini API)"]
|
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
|
||||||
J --> K["step9: HTML 생성\n(Gemini API)"]
|
D{"분량 판단\n5,000자 기준"}:::decision
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EDIT["✏️ Edit"]
|
E["step3: 도메인 분석"]:::process
|
||||||
direction TB
|
F["step4: 의미 단위 청킹"]:::process
|
||||||
K --> M["웹 편집기\neditor.js"]
|
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
|
||||||
K --> N["AI 편집\n/refine (Claude API)"]
|
H["step6: 코퍼스 생성"]:::process
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EXPORT["📦 Export"]
|
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
|
||||||
direction TB
|
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
|
||||||
M & N --> P["HTML / PDF"]
|
|
||||||
M & N --> Q["HWP 변환\nhtml_to_hwp.py"]
|
A --> B --> C --> D
|
||||||
end
|
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
|
||||||
|
D -->|"< 5,000자"| I
|
||||||
|
I --> J
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌐 API 라우트
|
#### 문서 유형별 생성 → 편집 → 출력
|
||||||
|
|
||||||
| 라우트 | 메서드 | 기능 |
|
```mermaid
|
||||||
|--------|--------|------|
|
flowchart TD
|
||||||
| `/` | GET | 메인 페이지 |
|
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
|
||||||
| `/generate` | POST | 기획서 생성 (1단계→1.5단계→2단계) |
|
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
|
||||||
| `/generate-report` | POST | 보고서 생성 (9단계 파이프라인) |
|
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
|
||||||
| `/refine` | POST | AI 전체 수정 |
|
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
|
||||||
| `/refine-selection` | POST | AI 부분 수정 |
|
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
|
||||||
| `/export-hwp` | POST | HWP 변환 |
|
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
|
||||||
| `/download/html` | POST | HTML 다운로드 |
|
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
|
||||||
| `/download/pdf` | POST | PDF 다운로드 |
|
|
||||||
| `/health` | GET | 서버 상태 확인 |
|
|
||||||
|
|
||||||
## 🤖 활용 AI
|
A(["📋 RAG 분석 결과"]):::startEnd
|
||||||
|
B{"문서 유형 선택"}:::decision
|
||||||
|
|
||||||
| 단계 | AI | 역할 |
|
C["기획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude
|
||||||
|------|-----|------|
|
D["보고서 생성\n콘텐츠→HTML 조립\n⚡ Gemini API"]:::aiGemini
|
||||||
| step2 (추출) | GPT | PDF에서 텍스트/이미지 메타데이터 추출 |
|
E["발표자료 생성\n예정"]:::planned
|
||||||
| step7 (목차) | GPT | 인덱싱 및 목차 자동 생성 |
|
F["사용자 등록 유형\n확장 가능"]:::planned
|
||||||
| step8 (콘텐츠) | Gemini | 섹션별 본문 초안 생성 |
|
|
||||||
| step9 (HTML) | Gemini | 최종 HTML 보고서 생성 |
|
G["글벗 표준 HTML\nA4·Navy·Noto Sans KR"]:::startEnd
|
||||||
| 기획서 생성 | Claude | HTML 구조 추출 + 변환 |
|
|
||||||
| AI 편집 | Claude | 피드백 반영 수정 |
|
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 --> B
|
||||||
|
B -->|"기획서"| C --> G
|
||||||
|
B -->|"보고서"| D --> G
|
||||||
|
B -->|"발표자료"| E -.-> G
|
||||||
|
B -->|"확장"| F -.-> G
|
||||||
|
|
||||||
|
G --> H
|
||||||
|
H -->|"수기"| I --> K
|
||||||
|
H -->|"AI"| J --> K
|
||||||
|
K -->|"웹/인쇄"| L --> O
|
||||||
|
K -->|"HWP"| M --> O
|
||||||
|
K -->|"PPT"| N -.-> O
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 v3 → v4 변경사항
|
||||||
|
|
||||||
|
| 영역 | v3 | v4 |
|
||||||
|
|------|------|------|
|
||||||
|
| app.py | 모든 로직 포함 (579줄) | 라우팅 전담 (291줄) |
|
||||||
|
| 비즈니스 로직 | app.py 내부 | handlers/ 패키지로 분리 (briefing + report + common) |
|
||||||
|
| 프롬프트 | prompts/ 공용 1곳 | handlers/*/prompts/ 모듈별 분리 |
|
||||||
|
| HWP 변환 | pyhwpx 직접 변환만 | + 스타일 분석기·HWPX 생성기·매핑 모듈 추가 |
|
||||||
|
| 환경설정 | 없음 | .env + api_config.py (python-dotenv) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺 상태 및 로드맵 (Status & Roadmap)
|
||||||
|
|
||||||
|
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현 · 현재 버전)
|
||||||
|
- **Phase 2**: 문서 생성 — 기획서·보고서 AI 생성 + 글벗 표준 HTML 양식 (🔧 기본 구현)
|
||||||
|
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
|
||||||
|
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석기, HWPX 생성기, 역할→HWP 매핑 (🔧 기본 구현)
|
||||||
|
- **Phase 5**: 문서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정)
|
||||||
|
- **Phase 6**: HWPX 템플릿 관리 — 파싱·시맨틱 매핑·스타일 추출·표 매칭·콘텐츠 주입 (예정)
|
||||||
|
- **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, AI 편집 개선, 도메인 선택기 (예정)
|
||||||
|
- **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-v4.git
|
||||||
|
cd geulbeot-v4
|
||||||
|
|
||||||
|
# 가상환경
|
||||||
|
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_4th/
|
||||||
|
├── app.py # Flask 웹 서버 — API 라우팅
|
||||||
|
├── api_config.py # .env 환경변수 로더
|
||||||
|
│
|
||||||
|
├── handlers/ # 비즈니스 로직
|
||||||
|
│ ├── common.py # Claude API 호출, JSON/HTML 추출
|
||||||
|
│ ├── briefing/ # 기획서 처리
|
||||||
|
│ │ ├── processor.py # 구조추출 → 배치계획 → HTML 생성
|
||||||
|
│ │ └── prompts/ # 각 단계별 AI 프롬프트
|
||||||
|
│ └── report/ # 보고서 처리
|
||||||
|
│ ├── processor.py # RAG 파이프라인 연동 + AI 편집
|
||||||
|
│ └── prompts/
|
||||||
|
│
|
||||||
|
├── converters/ # 변환 엔진
|
||||||
|
│ ├── pipeline/ # 9단계 RAG 파이프라인
|
||||||
|
│ │ ├── router.py # 분량 판단 (5,000자 기준)
|
||||||
|
│ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→코퍼스→인덱싱→콘텐츠→HTML
|
||||||
|
│ ├── style_analyzer.py # HTML 요소 역할 분류 (v4 신규)
|
||||||
|
│ ├── hwpx_generator.py # HWPX 파일 직접 생성 (v4 신규)
|
||||||
|
│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑 (v4 신규)
|
||||||
|
│ ├── html_to_hwp.py # 보고서 → HWP 변환
|
||||||
|
│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환
|
||||||
|
│
|
||||||
|
├── static/
|
||||||
|
│ ├── js/editor.js # 웹 WYSIWYG 편집기
|
||||||
|
│ └── css/editor.css # 편집기 스타일
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # 메인 UI
|
||||||
|
│ └── hwp_guide.html # HWP 변환 가이드
|
||||||
|
│
|
||||||
|
├── .env / .env.sample # API 키 관리
|
||||||
|
├── .gitignore
|
||||||
|
├── requirements.txt
|
||||||
|
├── Procfile # 배포 설정 (Gunicorn)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎨 글벗 표준 HTML 양식
|
## 🎨 글벗 표준 HTML 양식
|
||||||
|
|
||||||
- A4 인쇄 최적화 (210mm × 297mm)
|
| 항목 | 사양 |
|
||||||
- Noto Sans KR 폰트
|
|------|------|
|
||||||
- Navy 계열 색상 (#1a365d 기본)
|
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
|
||||||
- 구성: page-header, lead-box, section, data-table, bottom-box, footer
|
| 폰트 | Noto Sans KR (Google Fonts) |
|
||||||
|
| 색상 | Navy 계열 (#1a365d 기본) |
|
||||||
|
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
|
||||||
|
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
|
||||||
|
|
||||||
## 🖥️ 로컬 실행
|
---
|
||||||
|
|
||||||
```bash
|
## ⚠️ 알려진 제한사항
|
||||||
pip install -r requirements.txt
|
|
||||||
python app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
http://localhost:5000 접속
|
- 로컬 경로 하드코딩: `D:\for python\...` 잔존 (router.py, app.py)
|
||||||
|
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
|
||||||
|
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
|
||||||
|
- 문서 유형: 기획서·보고서만 구현, 발표자료·사용자 등록 유형 미구현
|
||||||
|
- 레거시 잔존: prompts/ 디렉토리, dkdl.py 테스트 코드
|
||||||
|
|
||||||
## 🔑 API 키 설정
|
---
|
||||||
|
|
||||||
`api_keys.json` 파일을 프로젝트 루트에 생성:
|
## 📊 코드 규모
|
||||||
|
|
||||||
```json
|
| 영역 | 줄 수 |
|
||||||
{
|
|------|-------|
|
||||||
"CLAUDE_API_KEY": "sk-ant-...",
|
| Python 전체 | 9,780 |
|
||||||
"GPT_API_KEY": "sk-proj-...",
|
| 프론트엔드 (JS + CSS + HTML) | 3,859 |
|
||||||
"GEMINI_API_KEY": "AIzaSy..."
|
| **합계** | **~13,600** |
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ `api_keys.json`은 `.gitignore`에 포함되어 Git에 올라가지 않습니다.
|
---
|
||||||
|
|
||||||
## 📝 v1 → v3 변경 이력
|
## 📝 버전 이력
|
||||||
|
|
||||||
| 버전 | 변경 내용 |
|
| 버전 | 핵심 변경 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| v1 | Flask + Claude API 기획서 생성기 (12파일, 422줄) |
|
| v1 | Flask + Claude API 기획서 생성기 |
|
||||||
| v2 | 웹 편집기 추가 (editor.js, editor.css) |
|
| v2 | 웹 편집기 추가 |
|
||||||
| v3 | 9단계 RAG 파이프라인 + HWP 변환 추가 (40파일+, 6000줄+) |
|
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
|
||||||
|
| **v4** | **코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📝 라이선스
|
## 📝 라이선스
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
"""API 키 관리 - api_keys.json에서 읽기"""
|
"""API 키 관리 - .env 파일에서 읽기"""
|
||||||
import json
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
def load_api_keys():
|
def load_api_keys():
|
||||||
"""프로젝트 폴더의 api_keys.json에서 API 키 로딩"""
|
"""프로젝트 폴더의 .env에서 API 키 로딩"""
|
||||||
search_path = Path(__file__).resolve().parent
|
# python-dotenv 있으면 사용
|
||||||
for _ in range(5):
|
try:
|
||||||
key_file = search_path / 'api_keys.json'
|
from dotenv import load_dotenv
|
||||||
if key_file.exists():
|
env_path = Path(__file__).resolve().parent / '.env'
|
||||||
with open(key_file, 'r', encoding='utf-8') as f:
|
load_dotenv(env_path)
|
||||||
return json.load(f)
|
except ImportError:
|
||||||
search_path = search_path.parent
|
# python-dotenv 없으면 수동 파싱
|
||||||
print("warning: api_keys.json not found")
|
env_path = Path(__file__).resolve().parent / '.env'
|
||||||
return {}
|
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()
|
API_KEYS = load_api_keys()
|
||||||
|
|||||||
541
app.py
541
app.py
@@ -1,174 +1,31 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
글벗 Light v2.0
|
글벗 Light v2.0
|
||||||
2단계 API 변환 + 대화형 피드백 시스템
|
Flask 라우팅 + 공통 기능
|
||||||
|
|
||||||
Flask + Claude API + Railway
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import anthropic
|
|
||||||
from flask import Flask, render_template, request, jsonify, Response, session
|
|
||||||
from datetime import datetime
|
|
||||||
import io
|
import io
|
||||||
import re
|
|
||||||
from flask import send_file
|
|
||||||
from datetime import datetime
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from converters.pipeline.router import process_document
|
from datetime import datetime
|
||||||
from api_config import API_KEYS
|
from flask import Flask, render_template, request, jsonify, Response, session, send_file
|
||||||
|
|
||||||
|
# 문서 유형별 프로세서
|
||||||
|
from handlers.briefing import BriefingProcessor
|
||||||
|
from handlers.report import ReportProcessor
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
|
||||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
|
||||||
|
|
||||||
# Claude API 클라이언트
|
# 프로세서 인스턴스
|
||||||
client = anthropic.Anthropic(api_key=API_KEYS.get('CLAUDE_API_KEY', ''))
|
processors = {
|
||||||
|
'briefing': BriefingProcessor(),
|
||||||
|
'report': ReportProcessor()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============== 프롬프트 로드 ==============
|
# ============== 메인 페이지 ==============
|
||||||
|
|
||||||
def load_prompt(filename):
|
|
||||||
"""프롬프트 파일 로드"""
|
|
||||||
prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename)
|
|
||||||
try:
|
|
||||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
|
||||||
return f.read()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_step1_prompt():
|
|
||||||
"""1단계: 구조 추출 프롬프트"""
|
|
||||||
prompt = load_prompt('step1_extract.txt')
|
|
||||||
if prompt:
|
|
||||||
return prompt
|
|
||||||
# 기본 프롬프트 (파일 없을 경우)
|
|
||||||
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
|
|
||||||
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_step2_prompt():
|
|
||||||
"""2단계: HTML 생성 프롬프트"""
|
|
||||||
prompt = load_prompt('step2_generate.txt')
|
|
||||||
if prompt:
|
|
||||||
return prompt
|
|
||||||
# 기본 프롬프트 (파일 없을 경우)
|
|
||||||
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
|
|
||||||
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
|
|
||||||
|
|
||||||
def get_step1_5_prompt():
|
|
||||||
"""1.5단계: 배치 계획 프롬프트"""
|
|
||||||
prompt = load_prompt('step1_5_plan.txt')
|
|
||||||
if prompt:
|
|
||||||
return prompt
|
|
||||||
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
|
|
||||||
|
|
||||||
def get_refine_prompt():
|
|
||||||
"""피드백 반영 프롬프트"""
|
|
||||||
return """당신은 HTML 보고서 수정 전문가입니다.
|
|
||||||
|
|
||||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
|
||||||
|
|
||||||
## 규칙
|
|
||||||
1. 피드백에서 언급된 부분만 정확히 수정
|
|
||||||
2. 나머지 구조와 스타일은 그대로 유지
|
|
||||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
|
||||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
|
||||||
|
|
||||||
## 현재 HTML
|
|
||||||
{current_html}
|
|
||||||
|
|
||||||
## 사용자 피드백
|
|
||||||
{feedback}
|
|
||||||
|
|
||||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
|
||||||
|
|
||||||
# ============== API 호출 함수 ==============
|
|
||||||
|
|
||||||
def call_claude(system_prompt, user_message, max_tokens=8000):
|
|
||||||
"""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):
|
|
||||||
"""텍스트에서 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):
|
|
||||||
"""텍스트에서 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 content_too_long(html, max_sections_per_page=4):
|
|
||||||
"""페이지당 콘텐츠 양 체크"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ============== 라우트 ==============
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -176,263 +33,107 @@ def index():
|
|||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 생성 API ==============
|
||||||
|
|
||||||
@app.route('/generate', methods=['POST'])
|
@app.route('/generate', methods=['POST'])
|
||||||
def generate():
|
def generate():
|
||||||
"""보고서 생성 API (2단계 처리)"""
|
"""기획서 생성 API"""
|
||||||
try:
|
try:
|
||||||
# 입력 받기
|
|
||||||
content = ""
|
content = ""
|
||||||
|
|
||||||
if 'file' in request.files and request.files['file'].filename:
|
if 'file' in request.files and request.files['file'].filename:
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
content = file.read().decode('utf-8')
|
content = file.read().decode('utf-8')
|
||||||
elif 'content' in request.form:
|
elif 'content' in request.form:
|
||||||
content = request.form.get('content', '')
|
content = request.form.get('content', '')
|
||||||
|
|
||||||
if not content.strip():
|
options = {
|
||||||
return jsonify({'error': '내용을 입력하거나 파일을 업로드해주세요.'}), 400
|
'page_option': request.form.get('page_option', '1'),
|
||||||
|
'department': request.form.get('department', '총괄기획실'),
|
||||||
# 옵션
|
'instruction': request.form.get('instruction', '')
|
||||||
page_option = request.form.get('page_option', '1')
|
|
||||||
department = request.form.get('department', '총괄기획실')
|
|
||||||
additional_prompt = request.form.get('additional_prompt', '')
|
|
||||||
|
|
||||||
# ============== 1단계: 구조 추출 ==============
|
|
||||||
step1_prompt = 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:
|
|
||||||
# JSON 추출 실패 시 원본 그대로 전달
|
|
||||||
structure_json = {"raw_content": content, "parse_failed": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ============== 1.5단계: 배치 계획 ==============
|
|
||||||
step1_5_prompt = 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페이지는 본문, 나머지는 [첨부 1], [첨부 2] 형태로 분할합니다.'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
step2_prompt = get_step2_prompt()
|
result = processors['briefing'].generate(content, options)
|
||||||
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 'error' in result:
|
||||||
if content_too_long(html_content):
|
return jsonify(result), 400 if 'trace' not in result else 500
|
||||||
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
|
return jsonify(result)
|
||||||
각 페이지당 섹션 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 jsonify({
|
|
||||||
'success': True,
|
|
||||||
'html': html_content,
|
|
||||||
'structure': structure_json
|
|
||||||
})
|
|
||||||
|
|
||||||
except anthropic.APIError as e:
|
|
||||||
return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
return jsonify({'error': f'서버 오류: {str(e)}', 'trace': traceback.format_exc()}), 500
|
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', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
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'])
|
@app.route('/refine', methods=['POST'])
|
||||||
def refine():
|
def refine():
|
||||||
"""피드백 반영 API (대화형)"""
|
"""피드백 반영 API"""
|
||||||
try:
|
try:
|
||||||
feedback = request.json.get('feedback', '')
|
feedback = request.json.get('feedback', '')
|
||||||
current_html = request.json.get('current_html', '') or session.get('current_html', '')
|
current_html = request.json.get('current_html', '') or session.get('current_html', '')
|
||||||
|
|
||||||
if not feedback.strip():
|
|
||||||
return jsonify({'error': '피드백 내용을 입력해주세요.'}), 400
|
|
||||||
|
|
||||||
if not current_html:
|
|
||||||
return jsonify({'error': '수정할 HTML이 없습니다. 먼저 변환을 실행해주세요.'}), 400
|
|
||||||
|
|
||||||
# 원본 HTML도 컨텍스트에 포함
|
|
||||||
original_html = session.get('original_html', '')
|
original_html = session.get('original_html', '')
|
||||||
|
doc_type = request.json.get('doc_type', 'briefing')
|
||||||
|
|
||||||
# 피드백 반영 프롬프트
|
processor = processors.get(doc_type, processors['briefing'])
|
||||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
result = processor.refine(feedback, current_html, original_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)
|
|
||||||
|
|
||||||
# 세션 업데이트
|
if 'error' in result:
|
||||||
session['current_html'] = new_html
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
# 대화 히스토리 저장
|
|
||||||
conversation = session.get('conversation', [])
|
|
||||||
conversation.append({'role': 'user', 'content': feedback})
|
|
||||||
conversation.append({'role': 'assistant', 'content': '수정 완료'})
|
|
||||||
session['conversation'] = conversation
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'html': new_html
|
|
||||||
})
|
|
||||||
|
|
||||||
except anthropic.APIError as e:
|
|
||||||
return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': f'서버 오류: {str(e)}'}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/refine-selection', methods=['POST'])
|
@app.route('/refine-selection', methods=['POST'])
|
||||||
def refine_selection():
|
def refine_selection():
|
||||||
"""선택된 부분만 수정"""
|
"""선택 부분 수정 API"""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
current_html = data.get('current_html', '')
|
current_html = data.get('current_html', '')
|
||||||
selected_text = data.get('selected_text', '')
|
selected_text = data.get('selected_text', '')
|
||||||
user_request = data.get('request', '')
|
user_request = data.get('request', '')
|
||||||
|
doc_type = data.get('doc_type', 'briefing')
|
||||||
|
|
||||||
if not current_html or not selected_text or not user_request:
|
processor = processors.get(doc_type, processors['briefing'])
|
||||||
return jsonify({'error': '필수 데이터가 없습니다.'}), 400
|
result = processor.refine_selection(current_html, selected_text, user_request)
|
||||||
|
|
||||||
# Claude API 호출
|
if 'error' in result:
|
||||||
message = client.messages.create(
|
return jsonify(result), 400
|
||||||
model="claude-sonnet-4-20250514",
|
return jsonify(result)
|
||||||
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()
|
|
||||||
|
|
||||||
# TYPE과 CONTENT 파싱
|
|
||||||
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 jsonify({
|
|
||||||
'success': True,
|
|
||||||
'type': edit_type,
|
|
||||||
'html': content
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============== 다운로드 API ==============
|
||||||
|
|
||||||
@app.route('/download/html', methods=['POST'])
|
@app.route('/download/html', methods=['POST'])
|
||||||
def download_html():
|
def download_html():
|
||||||
"""HTML 파일 다운로드"""
|
"""HTML 파일 다운로드"""
|
||||||
@@ -473,48 +174,12 @@ def download_pdf():
|
|||||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({'error': 'PDF 변환 미지원. HTML 다운로드 후 브라우저에서 인쇄하세요.'}), 501
|
return jsonify({'error': 'PDF 변환 미지원'}), 501
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
|
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/hwp-script')
|
# ============== 기타 API ==============
|
||||||
def hwp_script():
|
|
||||||
"""HWP 변환 스크립트 안내"""
|
|
||||||
return render_template('hwp_guide.html')
|
|
||||||
|
|
||||||
@app.route('/generate-report', methods=['POST'])
|
|
||||||
def generate_report_api():
|
|
||||||
"""보고서 생성 API (router 기반)"""
|
|
||||||
try:
|
|
||||||
data = request.get_json() or {}
|
|
||||||
|
|
||||||
# HTML 내용 (폴더에서 읽거나 직접 입력)
|
|
||||||
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', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if not content.strip():
|
|
||||||
return jsonify({'error': '내용이 비어있습니다.'}), 400
|
|
||||||
|
|
||||||
# router로 처리
|
|
||||||
result = process_document(content, options)
|
|
||||||
|
|
||||||
if result.get('success'):
|
|
||||||
return jsonify(result)
|
|
||||||
else:
|
|
||||||
return jsonify({'error': result.get('error', '처리 실패')}), 500
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
|
|
||||||
|
|
||||||
@app.route('/assets/<path:filename>')
|
@app.route('/assets/<path:filename>')
|
||||||
def serve_assets(filename):
|
def serve_assets(filename):
|
||||||
@@ -523,43 +188,51 @@ def serve_assets(filename):
|
|||||||
return send_file(os.path.join(assets_dir, filename))
|
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')
|
@app.route('/health')
|
||||||
def health():
|
def health():
|
||||||
"""헬스 체크"""
|
"""헬스 체크"""
|
||||||
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
||||||
|
|
||||||
|
|
||||||
# ===== HWP 변환 =====
|
|
||||||
@app.route('/export-hwp', methods=['POST'])
|
@app.route('/export-hwp', methods=['POST'])
|
||||||
def export_hwp():
|
def export_hwp():
|
||||||
|
"""HWP 변환 (스타일 그루핑 지원)"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
html_content = data.get('html', '')
|
html_content = data.get('html', '')
|
||||||
doc_type = data.get('doc_type', 'briefing')
|
doc_type = data.get('doc_type', 'briefing')
|
||||||
|
use_style_grouping = data.get('style_grouping', False) # 새 옵션
|
||||||
|
|
||||||
if not html_content:
|
if not html_content:
|
||||||
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
|
||||||
|
|
||||||
# 임시 파일 생성
|
|
||||||
temp_dir = tempfile.gettempdir()
|
temp_dir = tempfile.gettempdir()
|
||||||
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
|
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
|
||||||
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
|
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
|
||||||
|
|
||||||
# HTML 저장
|
|
||||||
with open(html_path, 'w', encoding='utf-8') as f:
|
with open(html_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(html_content)
|
f.write(html_content)
|
||||||
|
|
||||||
# 변환기 import 및 실행
|
# 변환기 선택
|
||||||
if doc_type == 'briefing':
|
if doc_type == 'briefing':
|
||||||
from converters.html_to_hwp_briefing import HtmlToHwpConverter
|
from converters.html_to_hwp_briefing import HtmlToHwpConverter
|
||||||
else:
|
else:
|
||||||
from converters.html_to_hwp import HtmlToHwpConverter
|
from converters.html_to_hwp import HtmlToHwpConverter
|
||||||
|
|
||||||
converter = HtmlToHwpConverter(visible=False)
|
converter = HtmlToHwpConverter(visible=False)
|
||||||
converter.convert(html_path, hwp_path)
|
|
||||||
converter.close()
|
|
||||||
|
|
||||||
# 파일 전송
|
# 스타일 그루핑 사용 여부
|
||||||
|
if use_style_grouping:
|
||||||
|
converter.convert_with_styles(html_path, hwp_path)
|
||||||
|
else:
|
||||||
|
converter.convert(html_path, hwp_path)
|
||||||
|
|
||||||
return send_file(
|
return send_file(
|
||||||
hwp_path,
|
hwp_path,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
@@ -573,7 +246,47 @@ def export_hwp():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5000))
|
port = int(os.environ.get('PORT', 5000))
|
||||||
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||||
app.run(host='0.0.0.0', port=port, debug=debug)
|
app.run(host='0.0.0.0', port=port, debug=debug)
|
||||||
37
converters/dkdl.py
Normal file
37
converters/dkdl.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from pyhwpx import Hwp
|
||||||
|
|
||||||
|
hwp = Hwp()
|
||||||
|
hwp.FileNew()
|
||||||
|
|
||||||
|
# HTML 헤딩 레벨 → 한글 기본 스타일 매핑
|
||||||
|
heading_style_map = {
|
||||||
|
'h1': 1, # 개요 1
|
||||||
|
'h2': 2, # 개요 2
|
||||||
|
'h3': 3, # 개요 3
|
||||||
|
'h4': 4, # 개요 4
|
||||||
|
'h5': 5, # 개요 5
|
||||||
|
'h6': 6, # 개요 6
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_heading_style(text, tag):
|
||||||
|
"""HTML 태그에 맞는 스타일 적용"""
|
||||||
|
hwp.insert_text(text)
|
||||||
|
hwp.HAction.Run("MoveLineBegin")
|
||||||
|
hwp.HAction.Run("MoveSelLineEnd")
|
||||||
|
|
||||||
|
# 해당 태그의 스타일 번호로 적용
|
||||||
|
style_num = heading_style_map.get(tag, 0)
|
||||||
|
if style_num:
|
||||||
|
hwp.HAction.Run(f"StyleShortcut{style_num}")
|
||||||
|
|
||||||
|
hwp.HAction.Run("MoveLineEnd")
|
||||||
|
hwp.BreakPara()
|
||||||
|
|
||||||
|
# 테스트
|
||||||
|
apply_heading_style("1장 서론", 'h1')
|
||||||
|
apply_heading_style("1.1 연구의 배경", 'h2')
|
||||||
|
apply_heading_style("1.1.1 세부 내용", 'h3')
|
||||||
|
apply_heading_style("본문 텍스트", 'p') # 일반 텍스트
|
||||||
|
|
||||||
|
hwp.SaveAs(r"D:\test_output.hwp")
|
||||||
|
print("완료!")
|
||||||
@@ -13,6 +13,11 @@ from pyhwpx import Hwp
|
|||||||
from bs4 import BeautifulSoup, NavigableString
|
from bs4 import BeautifulSoup, NavigableString
|
||||||
import os, re
|
import os, re
|
||||||
|
|
||||||
|
# 스타일 그루핑 시스템 추가
|
||||||
|
from converters.style_analyzer import StyleAnalyzer, StyledElement
|
||||||
|
from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME
|
||||||
|
|
||||||
|
|
||||||
# PIL 선택적 import (이미지 크기 확인용)
|
# PIL 선택적 import (이미지 크기 확인용)
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -25,9 +30,12 @@ class Config:
|
|||||||
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
|
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
|
||||||
HEADER_LEN, FOOTER_LEN = 10, 10
|
HEADER_LEN, FOOTER_LEN = 10, 10
|
||||||
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
|
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
|
||||||
|
ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가
|
||||||
|
|
||||||
class StyleParser:
|
class StyleParser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.style_map = {} # 스타일 매핑 (역할 → HwpStyle)
|
||||||
|
self.sty_gen = None # 스타일 생성기
|
||||||
self.class_styles = {
|
self.class_styles = {
|
||||||
'h1': {'font-size': '20pt', 'color': '#008000'},
|
'h1': {'font-size': '20pt', 'color': '#008000'},
|
||||||
'h2': {'font-size': '16pt', 'color': '#03581d'},
|
'h2': {'font-size': '16pt', 'color': '#03581d'},
|
||||||
@@ -62,6 +70,34 @@ class StyleParser:
|
|||||||
|
|
||||||
def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900']
|
def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900']
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 번호 제거 유틸리티
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
class HtmlToHwpConverter:
|
class HtmlToHwpConverter:
|
||||||
def __init__(self, visible=True):
|
def __init__(self, visible=True):
|
||||||
@@ -71,6 +107,8 @@ class HtmlToHwpConverter:
|
|||||||
self.base_path = ""
|
self.base_path = ""
|
||||||
self.is_first_h1 = True
|
self.is_first_h1 = True
|
||||||
self.image_count = 0
|
self.image_count = 0
|
||||||
|
self.style_map = {} # 역할 → 스타일 이름 매핑
|
||||||
|
self.sty_path = None # .sty 파일 경로
|
||||||
|
|
||||||
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
|
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
|
||||||
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
|
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
|
||||||
@@ -155,6 +193,80 @@ class HtmlToHwpConverter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [경고] 구역 머리말: {e}")
|
print(f" [경고] 구역 머리말: {e}")
|
||||||
|
|
||||||
|
# 스타일 적용 관련 (🆕 NEW)
|
||||||
|
|
||||||
|
def _load_style_template(self, sty_path: str):
|
||||||
|
"""
|
||||||
|
.sty 스타일 템플릿 로드
|
||||||
|
HWP에서 스타일 불러오기 기능 사용
|
||||||
|
"""
|
||||||
|
if not os.path.exists(sty_path):
|
||||||
|
print(f" [경고] 스타일 파일 없음: {sty_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# HWP 스타일 불러오기
|
||||||
|
self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet)
|
||||||
|
self.hwp.HParameterSet.HStyleTemplate.filename = sty_path
|
||||||
|
self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet)
|
||||||
|
print(f" ✅ 스타일 템플릿 로드: {sty_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [경고] 스타일 로드 실패: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_style_by_name(self, style_name: str):
|
||||||
|
"""
|
||||||
|
현재 문단에 스타일 이름으로 적용
|
||||||
|
텍스트 삽입 후 호출
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 현재 문단 선택
|
||||||
|
self.hwp.HAction.Run("MoveLineBegin")
|
||||||
|
self.hwp.HAction.Run("MoveSelLineEnd")
|
||||||
|
|
||||||
|
# 스타일 적용
|
||||||
|
self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||||
|
self.hwp.HParameterSet.HStyle.StyleName = style_name
|
||||||
|
self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||||
|
|
||||||
|
# 커서 문단 끝으로
|
||||||
|
self.hwp.HAction.Run("MoveLineEnd")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [경고] 스타일 적용 실패 '{style_name}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dynamic_style_map(self, elements: list):
|
||||||
|
"""HTML 분석 결과 기반 동적 스타일 매핑 생성 (숫자)"""
|
||||||
|
roles = set(elem.role for elem in elements)
|
||||||
|
|
||||||
|
# 제목 역할 정렬 (H1, H2, H3...)
|
||||||
|
title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()],
|
||||||
|
key=lambda x: int(x[1:]))
|
||||||
|
|
||||||
|
# 기타 역할
|
||||||
|
other_roles = [r for r in roles if r not in title_roles]
|
||||||
|
|
||||||
|
# 순차 할당 (개요 1~10)
|
||||||
|
self.style_map = {}
|
||||||
|
style_num = 1
|
||||||
|
|
||||||
|
for role in title_roles:
|
||||||
|
if style_num <= 10:
|
||||||
|
self.style_map[role] = style_num
|
||||||
|
style_num += 1
|
||||||
|
|
||||||
|
for role in other_roles:
|
||||||
|
if style_num <= 10:
|
||||||
|
self.style_map[role] = style_num
|
||||||
|
style_num += 1
|
||||||
|
|
||||||
|
print(f" 📝 동적 스타일 매핑: {self.style_map}")
|
||||||
|
return self.style_map
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _set_font(self, size=11, bold=False, color='#000000'):
|
def _set_font(self, size=11, bold=False, color='#000000'):
|
||||||
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
|
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
|
||||||
@@ -372,16 +484,22 @@ class HtmlToHwpConverter:
|
|||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
def _insert_image(self, src, caption=""):
|
def _insert_image(self, src, caption=""):
|
||||||
self.image_count += 1
|
self.image_count += 1
|
||||||
print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}")
|
|
||||||
|
|
||||||
if not src:
|
if not src:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 상대경로 → 절대경로
|
# 🆕 assets 폴더에서 먼저 찾기
|
||||||
if not os.path.isabs(src):
|
filename = os.path.basename(src)
|
||||||
full_path = os.path.normpath(os.path.join(self.base_path, src))
|
full_path = os.path.join(self.cfg.ASSETS_PATH, filename)
|
||||||
else:
|
|
||||||
full_path = src
|
# assets에 없으면 기존 방식으로 fallback
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
if not os.path.isabs(src):
|
||||||
|
full_path = os.path.normpath(os.path.join(self.base_path, src))
|
||||||
|
else:
|
||||||
|
full_path = src
|
||||||
|
|
||||||
|
print(f" 📷 이미지 #{self.image_count}: {filename}")
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
print(f" ❌ 파일 없음: {full_path}")
|
print(f" ❌ 파일 없음: {full_path}")
|
||||||
@@ -450,7 +568,123 @@ class HtmlToHwpConverter:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ 오류: {e}")
|
print(f" ❌ 오류: {e}")
|
||||||
|
|
||||||
|
def _insert_table_from_element(self, elem: 'StyledElement'):
|
||||||
|
"""StyledElement에서 표 삽입 (수정됨)"""
|
||||||
|
table_data = elem.attributes.get('table_data', {})
|
||||||
|
if not table_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = table_data.get('rows', [])
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
num_rows = len(rows)
|
||||||
|
num_cols = max(len(row) for row in rows) if rows else 1
|
||||||
|
|
||||||
|
print(f" → 표 삽입: {num_rows}행 × {num_cols}열")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 표 앞에 문단 설정
|
||||||
|
self._set_para('left', 130, before=5, after=0)
|
||||||
|
|
||||||
|
# 2. 표 생성 (pyhwpx 내장 메서드 사용)
|
||||||
|
self.hwp.create_table(num_rows, num_cols, treat_as_char=True)
|
||||||
|
|
||||||
|
# 3. 셀별 데이터 입력
|
||||||
|
for row_idx, row in enumerate(rows):
|
||||||
|
for col_idx, cell in enumerate(row):
|
||||||
|
# 셀 건너뛰기 (병합된 셀)
|
||||||
|
if col_idx >= len(row):
|
||||||
|
self.hwp.HAction.Run("TableRightCell")
|
||||||
|
continue
|
||||||
|
|
||||||
|
cell_text = cell.get('text', '')
|
||||||
|
is_header = cell.get('is_header', False)
|
||||||
|
|
||||||
|
# 헤더 셀 스타일
|
||||||
|
if is_header:
|
||||||
|
self._set_cell_bg('#E8F5E9')
|
||||||
|
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
|
||||||
|
self._set_font(9, True, '#006400')
|
||||||
|
else:
|
||||||
|
self._set_font(9.5, False, '#333333')
|
||||||
|
|
||||||
|
# 텍스트 입력
|
||||||
|
self.hwp.insert_text(cell_text)
|
||||||
|
|
||||||
|
# 다음 셀로 (마지막 셀 제외)
|
||||||
|
if not (row_idx == num_rows - 1 and col_idx == num_cols - 1):
|
||||||
|
self.hwp.HAction.Run("TableRightCell")
|
||||||
|
|
||||||
|
# 4. ★ 표 빠져나오기 (핵심!)
|
||||||
|
self.hwp.HAction.Run("Cancel") # 선택 해제
|
||||||
|
self.hwp.HAction.Run("CloseEx") # 표 편집 종료
|
||||||
|
self.hwp.HAction.Run("MoveDocEnd") # 문서 끝으로
|
||||||
|
|
||||||
|
# 5. 표 뒤 문단
|
||||||
|
self._set_para('left', 130, before=5, after=5)
|
||||||
|
self.hwp.BreakPara()
|
||||||
|
|
||||||
|
print(f" ✅ 표 삽입 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [오류] 표 삽입 실패: {e}")
|
||||||
|
# 표 안에 갇혔을 경우 탈출 시도
|
||||||
|
try:
|
||||||
|
self.hwp.HAction.Run("Cancel")
|
||||||
|
self.hwp.HAction.Run("CloseEx")
|
||||||
|
self.hwp.HAction.Run("MoveDocEnd")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _move_to_cell(self, row: int, col: int):
|
||||||
|
"""표에서 특정 셀로 이동"""
|
||||||
|
# 첫 셀로 이동
|
||||||
|
self.hwp.HAction.Run("TableColBegin")
|
||||||
|
self.hwp.HAction.Run("TableRowBegin")
|
||||||
|
|
||||||
|
# row만큼 아래로
|
||||||
|
for _ in range(row):
|
||||||
|
self.hwp.HAction.Run("TableLowerCell")
|
||||||
|
|
||||||
|
# col만큼 오른쪽으로
|
||||||
|
for _ in range(col):
|
||||||
|
self.hwp.HAction.Run("TableRightCell")
|
||||||
|
|
||||||
|
def _apply_cell_style(self, bold=False, bg_color=None, align='left'):
|
||||||
|
"""현재 셀 스타일 적용"""
|
||||||
|
# 글자 굵기
|
||||||
|
if bold:
|
||||||
|
self.hwp.HAction.Run("CharShapeBold")
|
||||||
|
|
||||||
|
# 정렬
|
||||||
|
align_actions = {
|
||||||
|
'left': "ParagraphShapeAlignLeft",
|
||||||
|
'center': "ParagraphShapeAlignCenter",
|
||||||
|
'right': "ParagraphShapeAlignRight",
|
||||||
|
}
|
||||||
|
if align in align_actions:
|
||||||
|
self.hwp.HAction.Run(align_actions[align])
|
||||||
|
|
||||||
|
# 배경색
|
||||||
|
if bg_color:
|
||||||
|
self._apply_cell_bg(bg_color)
|
||||||
|
|
||||||
|
def _apply_cell_bg(self, color: str):
|
||||||
|
"""셀 배경색 적용"""
|
||||||
|
try:
|
||||||
|
color = color.lstrip('#')
|
||||||
|
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
|
||||||
|
|
||||||
|
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||||
|
self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 단색
|
||||||
|
self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b)
|
||||||
|
self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [경고] 셀 배경색: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _insert_highlight_box(self, elem):
|
def _insert_highlight_box(self, elem):
|
||||||
txt = elem.get_text(strip=True)
|
txt = elem.get_text(strip=True)
|
||||||
if not txt: return
|
if not txt: return
|
||||||
@@ -551,19 +785,225 @@ class HtmlToHwpConverter:
|
|||||||
print(f"\n✅ 저장: {output_path}")
|
print(f"\n✅ 저장: {output_path}")
|
||||||
print(f" 이미지: {self.image_count}개 처리")
|
print(f" 이미지: {self.image_count}개 처리")
|
||||||
|
|
||||||
|
def convert_with_styles(self, html_path, output_path, sty_path=None):
|
||||||
|
"""
|
||||||
|
스타일 그루핑이 적용된 HWP 변환
|
||||||
|
|
||||||
|
✅ 수정: 기존 convert() 로직 + 스타일 적용
|
||||||
|
"""
|
||||||
|
print("="*60)
|
||||||
|
print("HTML → HWP 변환기 v11 (스타일 그루핑)")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
self.base_path = os.path.dirname(os.path.abspath(html_path))
|
||||||
|
self.is_first_h1 = True
|
||||||
|
self.image_count = 0
|
||||||
|
|
||||||
|
# 1. HTML 파일 읽기
|
||||||
|
with open(html_path, 'r', encoding='utf-8') as f:
|
||||||
|
html_content = f.read()
|
||||||
|
|
||||||
|
# 2. 스타일 분석
|
||||||
|
from converters.style_analyzer import StyleAnalyzer
|
||||||
|
from converters.hwp_style_mapping import HwpStyGenerator
|
||||||
|
|
||||||
|
analyzer = StyleAnalyzer()
|
||||||
|
elements = analyzer.analyze(html_content)
|
||||||
|
html_styles = analyzer.extract_css_styles(html_content)
|
||||||
|
|
||||||
|
print(f"\n📊 분석 결과: {len(elements)}개 요소")
|
||||||
|
for role, count in analyzer.get_role_summary().items():
|
||||||
|
print(f" {role}: {count}")
|
||||||
|
|
||||||
|
# 3. 스타일 매핑 생성
|
||||||
|
sty_gen = HwpStyGenerator()
|
||||||
|
sty_gen.update_from_html(html_styles)
|
||||||
|
self.style_map = sty_gen.apply_to_hwp(self.hwp) # Dict[str, HwpStyle]
|
||||||
|
self.sty_gen = sty_gen # 나중에 사용
|
||||||
|
|
||||||
|
# 4. ★ 기존 convert() 로직 그대로 사용 ★
|
||||||
|
soup = BeautifulSoup(html_content, '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._setup_page()
|
||||||
|
self._create_footer(footer_title)
|
||||||
|
|
||||||
|
raw = soup.find(id='raw-container')
|
||||||
|
if raw:
|
||||||
|
cover = raw.find(id='box-cover')
|
||||||
|
if cover:
|
||||||
|
print(" → 표지")
|
||||||
|
for ch in cover.children:
|
||||||
|
self._process(ch)
|
||||||
|
self.hwp.HAction.Run("BreakPage")
|
||||||
|
|
||||||
|
toc = raw.find(id='box-toc')
|
||||||
|
if toc:
|
||||||
|
print(" → 목차")
|
||||||
|
self.is_first_h1 = True
|
||||||
|
self._underline_box("목 차", 20, '#008000')
|
||||||
|
self.hwp.BreakPara()
|
||||||
|
self.hwp.BreakPara()
|
||||||
|
self._insert_list(toc.find('ul') or toc)
|
||||||
|
self.hwp.HAction.Run("BreakPage")
|
||||||
|
|
||||||
|
summary = raw.find(id='box-summary')
|
||||||
|
if summary:
|
||||||
|
print(" → 요약")
|
||||||
|
self.is_first_h1 = True
|
||||||
|
self._process(summary)
|
||||||
|
self.hwp.HAction.Run("BreakPage")
|
||||||
|
|
||||||
|
content = raw.find(id='box-content')
|
||||||
|
if content:
|
||||||
|
print(" → 본문")
|
||||||
|
self.is_first_h1 = True
|
||||||
|
self._process(content)
|
||||||
|
else:
|
||||||
|
self._process(soup.find('body') or soup)
|
||||||
|
|
||||||
|
# 5. 저장
|
||||||
|
self.hwp.SaveAs(output_path)
|
||||||
|
print(f"\n✅ 저장: {output_path}")
|
||||||
|
print(f" 이미지: {self.image_count}개 처리")
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_styled_element(self, elem: 'StyledElement'):
|
||||||
|
"""스타일이 지정된 요소 삽입 (수정됨)"""
|
||||||
|
role = elem.role
|
||||||
|
text = elem.text
|
||||||
|
|
||||||
|
# ═══ 특수 요소 처리 ═══
|
||||||
|
|
||||||
|
# 그림
|
||||||
|
if role == 'FIGURE':
|
||||||
|
src = elem.attributes.get('src', '')
|
||||||
|
if src:
|
||||||
|
self._insert_image(src)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 표
|
||||||
|
if role == 'TABLE':
|
||||||
|
self._insert_table_from_element(elem)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 표 셀/캡션은 TABLE에서 처리
|
||||||
|
if role in ['TH', 'TD']:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 빈 텍스트 스킵
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ═══ 텍스트 요소 처리 ═══
|
||||||
|
|
||||||
|
# 번호 제거 (HWP 개요가 자동 생성하면)
|
||||||
|
# clean_text = strip_numbering(text, role) # 필요시 활성화
|
||||||
|
clean_text = text # 일단 원본 유지
|
||||||
|
|
||||||
|
# 1. 스타일 설정 가져오기
|
||||||
|
style_config = self._get_style_config(role)
|
||||||
|
|
||||||
|
# 2. 문단 모양 먼저 적용
|
||||||
|
self._set_para(
|
||||||
|
align=style_config.get('align', 'justify'),
|
||||||
|
lh=style_config.get('line_height', 160),
|
||||||
|
left=style_config.get('indent_left', 0),
|
||||||
|
indent=style_config.get('indent_first', 0),
|
||||||
|
before=style_config.get('space_before', 0),
|
||||||
|
after=style_config.get('space_after', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 글자 모양 적용
|
||||||
|
self._set_font(
|
||||||
|
size=style_config.get('font_size', 11),
|
||||||
|
bold=style_config.get('bold', False),
|
||||||
|
color=style_config.get('color', '#000000')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 텍스트 삽입
|
||||||
|
self.hwp.insert_text(clean_text)
|
||||||
|
|
||||||
|
# 5. 스타일 적용 (F6 목록에서 참조되도록)
|
||||||
|
style_name = self.style_map.get(role)
|
||||||
|
if style_name:
|
||||||
|
try:
|
||||||
|
self.hwp.HAction.Run("MoveLineBegin")
|
||||||
|
self.hwp.HAction.Run("MoveSelLineEnd")
|
||||||
|
self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||||
|
self.hwp.HParameterSet.HStyle.StyleName = style_name
|
||||||
|
self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||||
|
self.hwp.HAction.Run("MoveLineEnd")
|
||||||
|
except:
|
||||||
|
pass # 스타일 없으면 무시
|
||||||
|
|
||||||
|
# 6. 줄바꿈
|
||||||
|
self.hwp.BreakPara()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_style_config(self, role: str) -> dict:
|
||||||
|
"""역할에 따른 스타일 설정 반환"""
|
||||||
|
|
||||||
|
STYLE_CONFIGS = {
|
||||||
|
# 표지
|
||||||
|
'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10},
|
||||||
|
'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'},
|
||||||
|
'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'},
|
||||||
|
|
||||||
|
# 목차
|
||||||
|
'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0},
|
||||||
|
'TOC_H2': {'font_size': 11, 'indent_left': 5},
|
||||||
|
'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'},
|
||||||
|
|
||||||
|
# 제목 계층
|
||||||
|
'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8},
|
||||||
|
'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6},
|
||||||
|
'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5},
|
||||||
|
'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4},
|
||||||
|
'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3},
|
||||||
|
'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9},
|
||||||
|
'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12},
|
||||||
|
|
||||||
|
# 본문
|
||||||
|
'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3},
|
||||||
|
'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5},
|
||||||
|
'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3},
|
||||||
|
|
||||||
|
# 표
|
||||||
|
'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'},
|
||||||
|
'TD': {'font_size': 9.5, 'align': 'left'},
|
||||||
|
'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'},
|
||||||
|
|
||||||
|
# 그림
|
||||||
|
'FIGURE': {'align': 'center'},
|
||||||
|
'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'},
|
||||||
|
|
||||||
|
# 기타
|
||||||
|
'UNKNOWN': {'font_size': 11, 'align': 'left'},
|
||||||
|
}
|
||||||
|
|
||||||
|
return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN'])
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
try: self.hwp.Quit()
|
try: self.hwp.Quit()
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
html_path = r"D:\for python\survey_test\output\generated\report.html"
|
html_path = r"D:\for python\survey_test\output\generated\report.html"
|
||||||
output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp"
|
output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp"
|
||||||
|
sty_path = r"D:\for python\survey_test\교통영향평가스타일.sty" # 🆕 추가
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conv = HtmlToHwpConverter(visible=True)
|
conv = HtmlToHwpConverter(visible=True)
|
||||||
conv.convert(html_path, output_path)
|
conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가
|
||||||
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
|
input("\nEnter를 누르면 HWP가 닫힙니다...")
|
||||||
conv.close()
|
conv.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n[에러] {e}")
|
print(f"\n[에러] {e}")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
434
converters/hwp_style_mapping.py
Normal file
434
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
converters/hwpx_generator.py
Normal file
431
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)
|
||||||
935
converters/style_analyzer.py
Normal file
935
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}")
|
||||||
5
handlers/__init__.py
Normal file
5
handlers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
handlers 패키지
|
||||||
|
문서 유형별 처리 로직을 분리하여 관리
|
||||||
|
"""
|
||||||
5
handlers/briefing/__init__.py
Normal file
5
handlers/briefing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
기획서(briefing) 처리 모듈
|
||||||
|
"""
|
||||||
|
from .processor import BriefingProcessor
|
||||||
279
handlers/briefing/processor.py
Normal file
279
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
handlers/briefing/prompts/step1_5_plan.txt
Normal file
104
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만 출력 (설명 없이)
|
||||||
122
handlers/briefing/prompts/step1_extract.txt
Normal file
122
handlers/briefing/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
handlers/briefing/prompts/step2_generate.txt
Normal file
440
handlers/briefing/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로 해당 페이지 요약 포함
|
||||||
84
handlers/common.py
Normal file
84
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
|
||||||
5
handlers/report/__init__.py
Normal file
5
handlers/report/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
보고서(report) 처리 모듈
|
||||||
|
"""
|
||||||
|
from .processor import ReportProcessor
|
||||||
152
handlers/report/processor.py
Normal file
152
handlers/report/processor.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# -*- 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': '내용이 비어있습니다.'}
|
||||||
|
|
||||||
|
# 이미지 경로 변환
|
||||||
|
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)}
|
||||||
104
handlers/report/prompts/refine_selection.txt
Normal file
104
handlers/report/prompts/refine_selection.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만 출력 (설명 없이)
|
||||||
13
railway.json
13
railway.json
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://railway.app/railway.schema.json",
|
|
||||||
"build": {
|
|
||||||
"builder": "NIXPACKS"
|
|
||||||
},
|
|
||||||
"deploy": {
|
|
||||||
"startCommand": "gunicorn app:app",
|
|
||||||
"healthcheckPath": "/health",
|
|
||||||
"healthcheckTimeout": 100,
|
|
||||||
"restartPolicyType": "ON_FAILURE",
|
|
||||||
"restartPolicyMaxRetries": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,25 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--ui-panel);
|
background: var(--ui-panel);
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-bar.active { display: flex; }
|
.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 {
|
.format-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -61,6 +74,26 @@
|
|||||||
|
|
||||||
.format-btn:hover .tooltip { opacity: 1; }
|
.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 {
|
.color-picker-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -185,6 +218,65 @@
|
|||||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
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 {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ let redoStack = [];
|
|||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
let isApplyingFormat = false;
|
let isApplyingFormat = false;
|
||||||
|
|
||||||
|
// ===== 편집 바 HTML 생성 =====
|
||||||
// ===== 편집 바 HTML 생성 =====
|
// ===== 편집 바 HTML 생성 =====
|
||||||
function createFormatBar() {
|
function createFormatBar() {
|
||||||
const formatBarHTML = `
|
const formatBarHTML = `
|
||||||
@@ -21,52 +22,120 @@ function createFormatBar() {
|
|||||||
<option value="나눔고딕">나눔고딕</option>
|
<option value="나눔고딕">나눔고딕</option>
|
||||||
<option value="돋움">돋움</option>
|
<option value="돋움">돋움</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="format-btn" onclick="loadLocalFonts()">📁<span class="tooltip">폰트 불러오기</span></button>
|
||||||
<input type="number" class="format-select" id="fontSizeInput" value="12" min="8" max="72"
|
<input type="number" class="format-select" id="fontSizeInput" value="12" min="8" max="72"
|
||||||
style="width:55px;" onchange="applyFontSizeInput(this.value)">
|
style="width:55px;" onchange="applyFontSizeInput(this.value)">
|
||||||
<div class="format-divider"></div>
|
<div class="format-divider"></div>
|
||||||
<button class="format-btn" id="btnBold" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게 (Ctrl+B)</span></button>
|
<button class="format-btn" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게</span></button>
|
||||||
<button class="format-btn" id="btnItalic" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임 (Ctrl+I)</span></button>
|
<button class="format-btn" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임</span></button>
|
||||||
<button class="format-btn" id="btnUnderline" onclick="formatText('underline')"><u>U</u><span class="tooltip">밑줄 (Ctrl+U)</span></button>
|
<button class="format-btn" onclick="formatText('underline')"><u>U</u><span class="tooltip">밑줄</span></button>
|
||||||
<button class="format-btn" id="btnStrike" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
|
<button class="format-btn" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
|
||||||
<div class="format-divider"></div>
|
<div class="format-divider"></div>
|
||||||
<button class="format-btn" onclick="adjustLetterSpacing(-0.5)">A⇠<span class="tooltip">자간 줄이기</span></button>
|
<select class="format-select" onchange="if(this.value) formatText(this.value); this.selectedIndex=0;">
|
||||||
<button class="format-btn" onclick="adjustLetterSpacing(0.5)">A⇢<span class="tooltip">자간 늘리기</span></button>
|
<option value="">정렬 ▾</option>
|
||||||
|
<option value="justifyLeft">⫷ 왼쪽</option>
|
||||||
|
<option value="justifyCenter">☰ 가운데</option>
|
||||||
|
<option value="justifyRight">⫸ 오른쪽</option>
|
||||||
|
</select>
|
||||||
|
<select class="format-select" onchange="if(this.value) adjustLetterSpacing(parseFloat(this.value)); this.selectedIndex=0;">
|
||||||
|
<option value="">자간 ▾</option>
|
||||||
|
<option value="-0.5">좁게</option>
|
||||||
|
<option value="-1">더 좁게</option>
|
||||||
|
<option value="0.5">넓게</option>
|
||||||
|
<option value="1">더 넓게</option>
|
||||||
|
</select>
|
||||||
<div class="format-divider"></div>
|
<div class="format-divider"></div>
|
||||||
<div class="color-picker-btn format-btn">
|
<div class="color-picker-btn format-btn">
|
||||||
<span style="border-bottom:3px solid #000;">A</span>
|
<span style="border-bottom:3px solid #000;">A</span>
|
||||||
<input type="color" id="textColor" value="#000000" onchange="applyTextColor(this.value)">
|
<input type="color" id="textColor" value="#000000" onchange="applyTextColor(this.value)">
|
||||||
<span class="tooltip">글자 색상</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="color-picker-btn format-btn">
|
<div class="color-picker-btn format-btn">
|
||||||
<span style="background:#ff0;padding:0 4px;">A</span>
|
<span style="background:#ff0;padding:0 4px;">A</span>
|
||||||
<input type="color" id="bgColor" value="#ffff00" onchange="applyBgColor(this.value)">
|
<input type="color" id="bgColor" value="#ffff00" onchange="applyBgColor(this.value)">
|
||||||
<span class="tooltip">배경 색상</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="format-divider"></div>
|
<select class="format-select" onchange="handleInsert(this.value); this.selectedIndex=0;">
|
||||||
<button class="format-btn" onclick="formatText('justifyLeft')">⫷<span class="tooltip">왼쪽 정렬</span></button>
|
<option value="">삽입 ▾</option>
|
||||||
<button class="format-btn" onclick="formatText('justifyCenter')">☰<span class="tooltip">가운데 정렬</span></button>
|
<option value="table">▦ 표</option>
|
||||||
<button class="format-btn" onclick="formatText('justifyRight')">⫸<span class="tooltip">오른쪽 정렬</span></button>
|
<option value="image">🖼️ 그림</option>
|
||||||
<div class="format-divider"></div>
|
<option value="hr">― 구분선</option>
|
||||||
<button class="format-btn" onclick="toggleBulletList()">•≡<span class="tooltip">글머리 기호</span></button>
|
|
||||||
<button class="format-btn" onclick="toggleNumberList()">1.<span class="tooltip">번호 목록</span></button>
|
|
||||||
<button class="format-btn" onclick="adjustIndent(-1)">⇤<span class="tooltip">내어쓰기</span></button>
|
|
||||||
<button class="format-btn" onclick="adjustIndent(1)">⇥<span class="tooltip">들여쓰기</span></button>
|
|
||||||
<div class="format-divider"></div>
|
|
||||||
<button class="format-btn" onclick="openTableModal()">▦<span class="tooltip">표 삽입</span></button>
|
|
||||||
<button class="format-btn" onclick="insertImage()">🖼️<span class="tooltip">그림 삽입</span></button>
|
|
||||||
<button class="format-btn" onclick="insertHR()">―<span class="tooltip">구분선</span></button>
|
|
||||||
<div class="format-divider"></div>
|
|
||||||
<select class="format-select" onchange="applyHeading(this.value)" style="min-width:100px;">
|
|
||||||
<option value="">본문</option>
|
|
||||||
<option value="h1">제목 1</option>
|
|
||||||
<option value="h2">제목 2</option>
|
|
||||||
<option value="h3">제목 3</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<select class="format-select" onchange="applyHeading(this.value)">
|
||||||
|
<option value="">본문</option>
|
||||||
|
<option value="h1">제목1</option>
|
||||||
|
<option value="h2">제목2</option>
|
||||||
|
<option value="h3">제목3</option>
|
||||||
|
</select>
|
||||||
|
<div class="format-divider"></div>
|
||||||
|
<button class="format-btn page-btn" onclick="smartAlign()">🔄 지능형 정렬</button>
|
||||||
|
<button class="format-btn page-btn" onclick="forcePageBreak()">📄 새페이지</button>
|
||||||
|
<button class="format-btn page-btn" onclick="moveToPrevPage()">📤 전페이지</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return formatBarHTML;
|
return formatBarHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 로컬 폰트 불러오기 =====
|
||||||
|
async function loadLocalFonts() {
|
||||||
|
// API 지원 여부 확인
|
||||||
|
if (!('queryLocalFonts' in window)) {
|
||||||
|
toast('⚠️ 이 브라우저는 폰트 불러오기를 지원하지 않습니다 (Chrome/Edge 필요)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast('🔄 폰트 불러오는 중...');
|
||||||
|
|
||||||
|
// 사용자 권한 요청 & 폰트 목록 가져오기
|
||||||
|
const fonts = await window.queryLocalFonts();
|
||||||
|
const fontSelect = document.getElementById('fontFamily');
|
||||||
|
|
||||||
|
// 기존 옵션들의 값 수집 (중복 방지)
|
||||||
|
const existingFonts = new Set();
|
||||||
|
fontSelect.querySelectorAll('option').forEach(opt => {
|
||||||
|
existingFonts.add(opt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 중복 제거 (family 기준)
|
||||||
|
const families = [...new Set(fonts.map(f => f.family))];
|
||||||
|
|
||||||
|
// 구분선 추가
|
||||||
|
const separator = document.createElement('option');
|
||||||
|
separator.disabled = true;
|
||||||
|
separator.textContent = '──── 내 컴퓨터 ────';
|
||||||
|
fontSelect.appendChild(separator);
|
||||||
|
|
||||||
|
// 새 폰트 추가
|
||||||
|
let addedCount = 0;
|
||||||
|
families.sort().forEach(family => {
|
||||||
|
if (!existingFonts.has(family)) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = family;
|
||||||
|
option.textContent = family;
|
||||||
|
fontSelect.appendChild(option);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast(`✅ ${addedCount}개 폰트 추가됨 (총 ${families.length}개)`);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'NotAllowedError') {
|
||||||
|
toast('⚠️ 폰트 접근 권한이 거부되었습니다');
|
||||||
|
} else {
|
||||||
|
console.error('폰트 로드 오류:', e);
|
||||||
|
toast('❌ 폰트 불러오기 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 삽입 핸들러 =====
|
||||||
|
function handleInsert(type) {
|
||||||
|
if (type === 'table') openTableModal();
|
||||||
|
else if (type === 'image') insertImage();
|
||||||
|
else if (type === 'hr') insertHR();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== 표 삽입 모달 HTML 생성 =====
|
// ===== 표 삽입 모달 HTML 생성 =====
|
||||||
function createTableModal() {
|
function createTableModal() {
|
||||||
const modalHTML = `
|
const modalHTML = `
|
||||||
@@ -457,11 +526,196 @@ function handleEditorKeydown(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== 리사이즈 핸들 추가 함수 =====
|
||||||
|
function addResizeHandle(doc, element, type) {
|
||||||
|
// wrapper 생성
|
||||||
|
const wrapper = doc.createElement('div');
|
||||||
|
wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize');
|
||||||
|
|
||||||
|
// 초기 크기 설정
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
wrapper.style.width = element.style.width || (rect.width + 'px');
|
||||||
|
|
||||||
|
// 크기 표시 툴팁
|
||||||
|
const tooltip = doc.createElement('div');
|
||||||
|
tooltip.className = 'size-tooltip';
|
||||||
|
tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height);
|
||||||
|
|
||||||
|
// 리사이즈 핸들
|
||||||
|
const handle = doc.createElement('div');
|
||||||
|
handle.className = 'resize-handle';
|
||||||
|
handle.title = '드래그하여 크기 조절';
|
||||||
|
|
||||||
|
// DOM 구조 변경
|
||||||
|
element.parentNode.insertBefore(wrapper, element);
|
||||||
|
wrapper.appendChild(element);
|
||||||
|
wrapper.appendChild(tooltip);
|
||||||
|
wrapper.appendChild(handle);
|
||||||
|
|
||||||
|
// 표는 width 100%로 시작
|
||||||
|
if (type === 'table') {
|
||||||
|
element.style.width = '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리사이즈 이벤트
|
||||||
|
let isResizing = false;
|
||||||
|
let startX, startY, startWidth, startHeight;
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isResizing = true;
|
||||||
|
wrapper.classList.add('resizing');
|
||||||
|
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
startWidth = wrapper.offsetWidth;
|
||||||
|
startHeight = wrapper.offsetHeight;
|
||||||
|
|
||||||
|
doc.addEventListener('mousemove', onMouseMove);
|
||||||
|
doc.addEventListener('mouseup', onMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (!isResizing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
const deltaY = e.clientY - startY;
|
||||||
|
|
||||||
|
const aspectRatio = startWidth / startHeight;
|
||||||
|
let newWidth = Math.max(100, startWidth + deltaX);
|
||||||
|
let newHeight;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
newHeight = newWidth / aspectRatio; // 비율 유지
|
||||||
|
} else {
|
||||||
|
newHeight = Math.max(50, startHeight + deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.style.width = newWidth + 'px';
|
||||||
|
|
||||||
|
// 이미지인 경우 width, height 둘 다 조절
|
||||||
|
if (type !== 'table') {
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
if (img) {
|
||||||
|
img.style.width = newWidth + 'px';
|
||||||
|
img.style.height = newHeight + 'px';
|
||||||
|
img.style.maxWidth = 'none';
|
||||||
|
img.style.maxHeight = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp(e) {
|
||||||
|
if (!isResizing) return;
|
||||||
|
isResizing = false;
|
||||||
|
wrapper.classList.remove('resizing');
|
||||||
|
|
||||||
|
doc.removeEventListener('mousemove', onMouseMove);
|
||||||
|
doc.removeEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
saveState();
|
||||||
|
toast('📐 크기 조절: ' + Math.round(wrapper.offsetWidth) + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== iframe 내부에 편집용 스타일 주입 =====
|
||||||
|
function injectEditStyles(doc) {
|
||||||
|
if (doc.getElementById('editor-inject-style')) return;
|
||||||
|
|
||||||
|
const style = doc.createElement('style');
|
||||||
|
style.id = 'editor-inject-style';
|
||||||
|
style.textContent = `
|
||||||
|
/* 리사이즈 컨테이너 */
|
||||||
|
.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;
|
||||||
|
top: -25px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.resizable-container:hover .size-tooltip,
|
||||||
|
.resizable-container.resizing .size-tooltip { opacity: 1; }
|
||||||
|
|
||||||
|
/* 열 리사이즈 핸들 */
|
||||||
|
.col-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); }
|
||||||
|
.col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); }
|
||||||
|
|
||||||
|
/* 편집 중 하이라이트 */
|
||||||
|
[contenteditable]:focus { outline: 2px solid #00C853 !important; }
|
||||||
|
[contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); }
|
||||||
|
`;
|
||||||
|
doc.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== iframe 편집 이벤트 바인딩 =====
|
||||||
// ===== iframe 편집 이벤트 바인딩 =====
|
// ===== iframe 편집 이벤트 바인딩 =====
|
||||||
function bindIframeEditEvents() {
|
function bindIframeEditEvents() {
|
||||||
const doc = getIframeDoc();
|
const doc = getIframeDoc();
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
|
// 편집용 스타일 주입
|
||||||
|
injectEditStyles(doc);
|
||||||
|
|
||||||
// 키보드 이벤트
|
// 키보드 이벤트
|
||||||
doc.removeEventListener('keydown', handleEditorKeydown);
|
doc.removeEventListener('keydown', handleEditorKeydown);
|
||||||
doc.addEventListener('keydown', handleEditorKeydown);
|
doc.addEventListener('keydown', handleEditorKeydown);
|
||||||
@@ -479,6 +733,81 @@ function bindIframeEditEvents() {
|
|||||||
}
|
}
|
||||||
clearActiveBlock();
|
clearActiveBlock();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 표에 리사이즈 핸들 추가 =====
|
||||||
|
doc.querySelectorAll('.body-content table, .sheet table').forEach(table => {
|
||||||
|
if (table.closest('.resizable-container')) return;
|
||||||
|
addResizeHandle(doc, table, 'table');
|
||||||
|
addColumnResizeHandles(doc, table); // 열 리사이즈 추가
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 이미지에 리사이즈 핸들 추가 =====
|
||||||
|
doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => {
|
||||||
|
if (img.closest('.resizable-container')) return;
|
||||||
|
addResizeHandle(doc, img, 'image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ===== 표 열 리사이즈 핸들 추가 =====
|
||||||
|
function addColumnResizeHandles(doc, table) {
|
||||||
|
// 테이블에 position relative 설정
|
||||||
|
table.style.position = 'relative';
|
||||||
|
|
||||||
|
// 첫 번째 행의 셀들을 기준으로 열 핸들 생성
|
||||||
|
const firstRow = table.querySelector('tr');
|
||||||
|
if (!firstRow) return;
|
||||||
|
|
||||||
|
const cells = firstRow.querySelectorAll('th, td');
|
||||||
|
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
if (index === cells.length - 1) return; // 마지막 열은 제외
|
||||||
|
|
||||||
|
// 이미 핸들이 있으면 스킵
|
||||||
|
if (cell.querySelector('.col-resize-handle')) return;
|
||||||
|
|
||||||
|
cell.style.position = 'relative';
|
||||||
|
|
||||||
|
const handle = doc.createElement('div');
|
||||||
|
handle.className = 'col-resize-handle';
|
||||||
|
handle.style.right = '-3px';
|
||||||
|
cell.appendChild(handle);
|
||||||
|
|
||||||
|
let startX, startWidth, nextStartWidth;
|
||||||
|
let nextCell = cells[index + 1];
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
handle.classList.add('dragging');
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidth = cell.offsetWidth;
|
||||||
|
nextStartWidth = nextCell ? nextCell.offsetWidth : 0;
|
||||||
|
|
||||||
|
doc.addEventListener('mousemove', onMouseMove);
|
||||||
|
doc.addEventListener('mouseup', onMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
const delta = e.clientX - startX;
|
||||||
|
const newWidth = Math.max(30, startWidth + delta);
|
||||||
|
|
||||||
|
cell.style.width = newWidth + 'px';
|
||||||
|
|
||||||
|
// 다음 열도 조정 (테이블 전체 너비 유지)
|
||||||
|
if (nextCell && nextStartWidth > 30) {
|
||||||
|
const newNextWidth = Math.max(30, nextStartWidth - delta);
|
||||||
|
nextCell.style.width = newNextWidth + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
handle.classList.remove('dragging');
|
||||||
|
doc.removeEventListener('mousemove', onMouseMove);
|
||||||
|
doc.removeEventListener('mouseup', onMouseUp);
|
||||||
|
saveState();
|
||||||
|
toast('📊 열 너비 조절됨');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 편집 모드 토글 =====
|
// ===== 편집 모드 토글 =====
|
||||||
@@ -533,7 +862,7 @@ function toggleEditMode() {
|
|||||||
function initEditor() {
|
function initEditor() {
|
||||||
// 편집 바가 없으면 생성
|
// 편집 바가 없으면 생성
|
||||||
if (!document.getElementById('formatBar')) {
|
if (!document.getElementById('formatBar')) {
|
||||||
const previewContainer = document.querySelector('.preview-container');
|
const previewContainer = document.querySelector('.main');
|
||||||
if (previewContainer) {
|
if (previewContainer) {
|
||||||
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
|
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
|
||||||
}
|
}
|
||||||
@@ -550,5 +879,330 @@ function initEditor() {
|
|||||||
console.log('Editor initialized');
|
console.log('Editor initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 지능형 정렬 =====
|
||||||
|
function smartAlign() {
|
||||||
|
const doc = getIframeDoc();
|
||||||
|
if (!doc) {
|
||||||
|
toast('⚠️ 문서가 로드되지 않았습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 현재 스크롤 위치 저장 =====
|
||||||
|
const iframe = getPreviewIframe();
|
||||||
|
const scrollY = iframe?.contentWindow?.scrollY || 0;
|
||||||
|
|
||||||
|
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||||||
|
if (sheets.length < 2) {
|
||||||
|
toast('⚠️ 정렬할 본문 페이지가 없습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast('지능형 정렬 실행 중...');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// 1. 표지 유지
|
||||||
|
const coverSheet = sheets[0];
|
||||||
|
|
||||||
|
// 2. 보고서 제목 추출
|
||||||
|
let reportTitle = "보고서";
|
||||||
|
const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title');
|
||||||
|
if (existingTitle) reportTitle = existingTitle.innerText;
|
||||||
|
|
||||||
|
// 3. 콘텐츠 수집 (표지 제외)
|
||||||
|
const contentSheets = sheets.slice(1);
|
||||||
|
let allNodes = [];
|
||||||
|
|
||||||
|
contentSheets.forEach(sheet => {
|
||||||
|
const body = sheet.querySelector('.body-content');
|
||||||
|
if (body) {
|
||||||
|
Array.from(body.children).forEach(child => {
|
||||||
|
if (child.classList.contains('add-after-btn') ||
|
||||||
|
child.classList.contains('delete-block-btn') ||
|
||||||
|
child.classList.contains('empty-placeholder')) return;
|
||||||
|
|
||||||
|
if (['P', 'DIV', 'SPAN'].includes(child.tagName) &&
|
||||||
|
child.innerText.trim() === '' &&
|
||||||
|
!child.querySelector('img, table, figure')) return;
|
||||||
|
|
||||||
|
allNodes.push(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 설정값
|
||||||
|
const MAX_HEIGHT = 970;
|
||||||
|
const HEADING_RESERVE = 90;
|
||||||
|
let currentHeaderTitle = "목차";
|
||||||
|
let pageNum = 1;
|
||||||
|
|
||||||
|
// 5. 새 페이지 생성 함수
|
||||||
|
function createNewPage(headerText) {
|
||||||
|
const newSheet = doc.createElement('div');
|
||||||
|
newSheet.className = 'sheet';
|
||||||
|
newSheet.innerHTML = `
|
||||||
|
<div class="page-header">${headerText}</div>
|
||||||
|
<div class="body-content"></div>
|
||||||
|
<div class="page-footer">
|
||||||
|
<span class="rpt-title">${reportTitle}</span>
|
||||||
|
<span class="pg-num">- ${pageNum++} -</span>
|
||||||
|
</div>`;
|
||||||
|
doc.body.appendChild(newSheet);
|
||||||
|
return newSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 페이지 재구성
|
||||||
|
let currentPage = createNewPage(currentHeaderTitle);
|
||||||
|
let currentBody = currentPage.querySelector('.body-content');
|
||||||
|
|
||||||
|
allNodes.forEach(node => {
|
||||||
|
// 강제 페이지 브레이크
|
||||||
|
if (node.classList && node.classList.contains('page-break-forced')) {
|
||||||
|
currentPage = createNewPage(currentHeaderTitle);
|
||||||
|
currentBody = currentPage.querySelector('.body-content');
|
||||||
|
currentBody.appendChild(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// H1: 새 섹션 시작
|
||||||
|
if (node.tagName === 'H1') {
|
||||||
|
currentHeaderTitle = node.innerText.split('-')[0].trim();
|
||||||
|
if (currentBody.children.length > 0) {
|
||||||
|
currentPage = createNewPage(currentHeaderTitle);
|
||||||
|
currentBody = currentPage.querySelector('.body-content');
|
||||||
|
} else {
|
||||||
|
currentPage.querySelector('.page-header').innerText = currentHeaderTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H2, H3: 남은 공간 부족하면 새 페이지
|
||||||
|
if (['H2', 'H3'].includes(node.tagName)) {
|
||||||
|
const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight;
|
||||||
|
if (spaceLeft < HEADING_RESERVE) {
|
||||||
|
currentPage = createNewPage(currentHeaderTitle);
|
||||||
|
currentBody = currentPage.querySelector('.body-content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 노드 추가
|
||||||
|
currentBody.appendChild(node);
|
||||||
|
|
||||||
|
// 전 페이지로 강제 이동 설정된 경우 스킵
|
||||||
|
if (node.classList && node.classList.contains('move-to-prev-page')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 높이 초과 시 새 페이지로 이동
|
||||||
|
if (currentBody.scrollHeight > MAX_HEIGHT) {
|
||||||
|
currentBody.removeChild(node);
|
||||||
|
currentPage = createNewPage(currentHeaderTitle);
|
||||||
|
currentBody = currentPage.querySelector('.body-content');
|
||||||
|
currentBody.appendChild(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 편집 모드였으면 복원
|
||||||
|
if (isEditing) {
|
||||||
|
bindIframeEditEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. generatedHTML 업데이트 (전역 변수)
|
||||||
|
if (typeof generatedHTML !== 'undefined') {
|
||||||
|
generatedHTML = '<!DOCTYPE html>' + doc.documentElement.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 스크롤 위치 복원 =====
|
||||||
|
setTimeout(() => {
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
iframe.contentWindow.scrollTo(0, scrollY);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
toast('✅ 지능형 정렬 완료 (' + pageNum + '페이지)');
|
||||||
|
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('smartAlign 오류:', e);
|
||||||
|
toast('❌ 정렬 중 오류: ' + e.message);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 새페이지 시작 =====
|
||||||
|
function forcePageBreak() {
|
||||||
|
const doc = getIframeDoc();
|
||||||
|
if (!doc) {
|
||||||
|
toast('⚠️ 문서가 로드되지 않았습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = doc.getSelection();
|
||||||
|
if (!selection || !selection.anchorNode) {
|
||||||
|
toast('⚠️ 분리할 위치를 클릭하세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetEl = selection.anchorNode.nodeType === 1
|
||||||
|
? selection.anchorNode
|
||||||
|
: selection.anchorNode.parentElement;
|
||||||
|
|
||||||
|
while (targetEl && targetEl.parentElement) {
|
||||||
|
if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
targetEl = targetEl.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) {
|
||||||
|
toast('⚠️ 본문 블록을 먼저 클릭하세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState();
|
||||||
|
|
||||||
|
const currentBody = targetEl.parentElement;
|
||||||
|
const currentSheet = currentBody.closest('.sheet');
|
||||||
|
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||||||
|
const currentIndex = sheets.indexOf(currentSheet);
|
||||||
|
|
||||||
|
// 클릭한 요소부터 끝까지 수집
|
||||||
|
const elementsToMove = [];
|
||||||
|
let sibling = targetEl;
|
||||||
|
while (sibling) {
|
||||||
|
elementsToMove.push(sibling);
|
||||||
|
sibling = sibling.nextElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementsToMove.length === 0) {
|
||||||
|
toast('⚠️ 이동할 내용이 없습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 페이지 찾기
|
||||||
|
let nextSheet = sheets[currentIndex + 1];
|
||||||
|
let nextBody;
|
||||||
|
|
||||||
|
if (!nextSheet || !nextSheet.querySelector('.body-content')) {
|
||||||
|
const oldHeader = currentSheet.querySelector('.page-header');
|
||||||
|
const oldFooter = currentSheet.querySelector('.page-footer');
|
||||||
|
nextSheet = doc.createElement('div');
|
||||||
|
nextSheet.className = 'sheet';
|
||||||
|
nextSheet.innerHTML = `
|
||||||
|
<div class="page-header">${oldHeader ? oldHeader.innerText : ''}</div>
|
||||||
|
<div class="body-content"></div>
|
||||||
|
<div class="page-footer">
|
||||||
|
<span class="rpt-title">${oldFooter?.querySelector('.rpt-title')?.innerText || ''}</span>
|
||||||
|
<span class="pg-num">- - -</span>
|
||||||
|
</div>`;
|
||||||
|
currentSheet.after(nextSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextBody = nextSheet.querySelector('.body-content');
|
||||||
|
|
||||||
|
// 역순으로 맨 앞에 삽입 (순서 유지)
|
||||||
|
for (let i = elementsToMove.length - 1; i >= 0; i--) {
|
||||||
|
nextBody.insertBefore(elementsToMove[i], nextBody.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 요소에 페이지 브레이크 마커 추가 (나중에 지능형 정렬이 존중함)
|
||||||
|
targetEl.classList.add('page-break-forced');
|
||||||
|
|
||||||
|
// 페이지 번호만 재정렬 (smartAlign 호출 안 함!)
|
||||||
|
renumberPages(doc);
|
||||||
|
|
||||||
|
toast('✅ 다음 페이지로 이동됨');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== 전페이지로 이동 (즉시 적용) =====
|
||||||
|
function moveToPrevPage() {
|
||||||
|
const doc = getIframeDoc();
|
||||||
|
if (!doc) {
|
||||||
|
toast('⚠️ 문서가 로드되지 않았습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = doc.getSelection();
|
||||||
|
if (!selection || !selection.anchorNode) {
|
||||||
|
toast('⚠️ 이동할 블록을 클릭하세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 선택된 요소에서 body-content 직계 자식 찾기
|
||||||
|
let targetEl = selection.anchorNode.nodeType === 1
|
||||||
|
? selection.anchorNode
|
||||||
|
: selection.anchorNode.parentElement;
|
||||||
|
|
||||||
|
while (targetEl && targetEl.parentElement) {
|
||||||
|
if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
targetEl = targetEl.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) {
|
||||||
|
toast('⚠️ 본문 블록을 먼저 클릭하세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState();
|
||||||
|
|
||||||
|
// 현재 sheet 찾기
|
||||||
|
const currentSheet = targetEl.closest('.sheet');
|
||||||
|
const sheets = Array.from(doc.querySelectorAll('.sheet'));
|
||||||
|
const currentIndex = sheets.indexOf(currentSheet);
|
||||||
|
|
||||||
|
// 이전 페이지 찾기 (표지 제외)
|
||||||
|
if (currentIndex <= 1) {
|
||||||
|
toast('⚠️ 이전 페이지가 없습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevSheet = sheets[currentIndex - 1];
|
||||||
|
const prevBody = prevSheet.querySelector('.body-content');
|
||||||
|
|
||||||
|
if (!prevBody) {
|
||||||
|
toast('⚠️ 이전 페이지에 본문 영역이 없습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요소를 이전 페이지 맨 아래로 이동
|
||||||
|
prevBody.appendChild(targetEl);
|
||||||
|
|
||||||
|
// 현재 페이지가 비었으면 삭제
|
||||||
|
const currentBody = currentSheet.querySelector('.body-content');
|
||||||
|
if (currentBody && currentBody.children.length === 0) {
|
||||||
|
currentSheet.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 번호 재정렬
|
||||||
|
renumberPages(doc);
|
||||||
|
|
||||||
|
toast('✅ 전 페이지로 이동됨');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 페이지 번호 재정렬 =====
|
||||||
|
function renumberPages(doc) {
|
||||||
|
const sheets = doc.querySelectorAll('.sheet');
|
||||||
|
let pageNum = 1;
|
||||||
|
|
||||||
|
sheets.forEach((sheet, idx) => {
|
||||||
|
if (idx === 0) return; // 표지는 번호 없음
|
||||||
|
|
||||||
|
const pgNum = sheet.querySelector('.pg-num');
|
||||||
|
if (pgNum) {
|
||||||
|
pgNum.innerText = `- ${pageNum++} -`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// DOM 로드 시 초기화
|
// DOM 로드 시 초기화
|
||||||
document.addEventListener('DOMContentLoaded', initEditor);
|
document.addEventListener('DOMContentLoaded', initEditor);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1074,6 +1074,8 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/css/editor.css">
|
<link rel="stylesheet" href="/static/css/editor.css">
|
||||||
|
<script src="/static/js/editor.js"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 상단 툴바 -->
|
<!-- 상단 툴바 -->
|
||||||
@@ -1217,17 +1219,7 @@
|
|||||||
|
|
||||||
<!-- 가운데 뷰어 -->
|
<!-- 가운데 뷰어 -->
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<!-- 서식 바 (편집 모드) -->
|
|
||||||
<div class="format-bar" id="formatBar">
|
|
||||||
<button class="format-btn" onclick="formatText('bold')"><b>B</b></button>
|
|
||||||
<button class="format-btn" onclick="formatText('italic')"><i>I</i></button>
|
|
||||||
<button class="format-btn" onclick="formatText('underline')"><u>U</u></button>
|
|
||||||
<div class="format-divider"></div>
|
|
||||||
<button class="format-btn" onclick="formatText('justifyLeft')">⫷</button>
|
|
||||||
<button class="format-btn" onclick="formatText('justifyCenter')">☰</button>
|
|
||||||
<button class="format-btn" onclick="formatText('justifyRight')">⫸</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="viewer" id="viewer">
|
<div class="viewer" id="viewer">
|
||||||
<div class="a4-wrapper" id="a4Wrapper">
|
<div class="a4-wrapper" id="a4Wrapper">
|
||||||
<div class="a4-preview" id="a4Preview">
|
<div class="a4-preview" id="a4Preview">
|
||||||
@@ -1531,7 +1523,8 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
html: html,
|
html: html,
|
||||||
doc_type: currentDocType
|
doc_type: currentDocType,
|
||||||
|
style_grouping: true
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1628,7 +1621,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
current_html: generatedHTML,
|
current_html: generatedHTML,
|
||||||
selected_text: selectedText,
|
selected_text: selectedText,
|
||||||
request: request
|
request: request,
|
||||||
|
doc_type: currentDocType // ← 추가
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1696,10 +1690,38 @@
|
|||||||
if (targetEl.nodeType === 3) {
|
if (targetEl.nodeType === 3) {
|
||||||
targetEl = targetEl.parentElement;
|
targetEl = targetEl.parentElement;
|
||||||
}
|
}
|
||||||
// 적절한 부모 찾기 (div, section 등)
|
|
||||||
const parent = targetEl.closest('.lead-box, .section, .info-table, .data-table, .process-flow') || targetEl.closest('div');
|
if (currentDocType === 'report') {
|
||||||
if (parent) {
|
// ===== 보고서: 안전한 부모만 교체 =====
|
||||||
parent.outerHTML = modifiedContent;
|
// sheet, body-content 등 페이지 구조는 절대 건드리지 않음
|
||||||
|
const dangerousClasses = ['sheet', 'body-content', 'page-header', 'page-footer', 'a4-preview'];
|
||||||
|
|
||||||
|
// p, li, td, h1~h6 등 안전한 요소 찾기
|
||||||
|
const safeParent = targetEl.closest('p, li, td, th, h1, h2, h3, h4, h5, h6, ul, ol, table, figure');
|
||||||
|
|
||||||
|
if (safeParent && !dangerousClasses.some(cls => safeParent.classList.contains(cls))) {
|
||||||
|
// 선택된 요소 뒤에 새 구조 삽입
|
||||||
|
safeParent.insertAdjacentHTML('afterend', modifiedContent);
|
||||||
|
// 기존 요소는 유지하거나 숨김 처리
|
||||||
|
// safeParent.style.display = 'none'; // 선택: 기존 숨기기
|
||||||
|
safeParent.remove(); // 또는 삭제
|
||||||
|
console.log('보고서 - 안전한 구조 교체:', safeParent.tagName);
|
||||||
|
} else {
|
||||||
|
// 안전한 부모를 못 찾으면 텍스트 앞에 구조 삽입
|
||||||
|
console.log('보고서 - 안전한 부모 없음, 선택 위치에 삽입');
|
||||||
|
const range = selectedRange.cloneRange();
|
||||||
|
range.deleteContents();
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = modifiedContent;
|
||||||
|
range.insertNode(temp.firstElementChild || temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ===== 기획서: 기존 로직 =====
|
||||||
|
const parent = targetEl.closest('.lead-box, .section, .info-table, .data-table, .process-flow') || targetEl.closest('div');
|
||||||
|
if (parent) {
|
||||||
|
parent.outerHTML = modifiedContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2196,6 +2218,93 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== HWP 다운로드 (스타일 그루핑) =====
|
||||||
|
async function downloadHwpStyled() {
|
||||||
|
if (!generatedHTML) {
|
||||||
|
alert('먼저 문서를 생성해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집된 내용 가져오기
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
const html = frame.contentDocument ?
|
||||||
|
'<!DOCTYPE html>' + frame.contentDocument.documentElement.outerHTML :
|
||||||
|
generatedHTML;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('HWP 변환 중...', true);
|
||||||
|
|
||||||
|
const response = await fetch('/export-hwp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
html: html,
|
||||||
|
doc_type: currentDocType || 'report',
|
||||||
|
style_grouping: true // ★ 스타일 그루핑 활성화
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error || 'HWP 변환 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `report_${new Date().toISOString().slice(0,10)}.hwp`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setStatus('HWP 저장 완료', true);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('HWP 변환 오류: ' + error.message);
|
||||||
|
setStatus('오류 발생', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 스타일 분석 미리보기 (선택사항) =====
|
||||||
|
async function analyzeStyles() {
|
||||||
|
if (!generatedHTML) {
|
||||||
|
alert('먼저 문서를 생성해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = document.getElementById('previewFrame');
|
||||||
|
const html = frame.contentDocument ?
|
||||||
|
'<!DOCTYPE html>' + frame.contentDocument.documentElement.outerHTML :
|
||||||
|
generatedHTML;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/analyze-styles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: html })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 표시
|
||||||
|
let summary = `📊 스타일 분석 결과\n\n총 ${data.total_elements}개 요소\n\n`;
|
||||||
|
summary += Object.entries(data.summary)
|
||||||
|
.map(([k, v]) => `${k}: ${v}개`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
alert(summary);
|
||||||
|
console.log('스타일 분석 상세:', data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('분석 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function printDoc() {
|
function printDoc() {
|
||||||
const frame = document.getElementById('previewFrame');
|
const frame = document.getElementById('previewFrame');
|
||||||
if (frame.contentWindow) {
|
if (frame.contentWindow) {
|
||||||
|
|||||||
Reference in New Issue
Block a user