v4:코드모듈화_20260123

This commit is contained in:
2026-02-20 11:34:02 +09:00
parent a990081287
commit 17e639ed40
24 changed files with 5412 additions and 1054 deletions

7
.env.sample Normal file
View File

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

393
README.md
View File

@@ -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단계) ─── 공통 처리
문서 유형 선택
├─ 기획서 (기본)
├─ 보고서 (기본)
├─ 발표자료 (기본)
└─ 사용자 등록 (확장 가능)
글벗 표준 HTML 생성
웹 편집기 (수기 편집 / AI 편집)
출력 (HTML / PDF / HWP)
```
### 1. Backend (Python Flask)
- **Language**: Python 3.13
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
- **AI**:
- Claude API (Anthropic) — 기획서 생성, AI 편집
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
- Gemini API — 보고서 콘텐츠·HTML 생성
- **Features**:
- 자료 입력 → 9단계 RAG 파이프라인 (파일 변환 → 추출 → 도메인 분석 → 청킹 → 임베딩 → 코퍼스 → 인덱싱 → 콘텐츠 생성 → 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
flowchart TD
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A[/"📂 자료 입력 (파일/폴더)"/]:::process
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
D{"분량 판단\n5,000자 기준"}:::decision
E["step3: 도메인 분석"]:::process
F["step4: 의미 단위 청킹"]:::process
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
H["step6: 코퍼스 생성"]:::process
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
A --> B --> C --> D
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
D -->|"< 5,000자"| I
I --> J
```
#### 문서 유형별 생성 → 편집 → 출력
```mermaid
flowchart TD
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
A(["📋 RAG 분석 결과"]):::startEnd
B{"문서 유형 선택"}:::decision
C["기획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude
D["보고서 생성\n콘텐츠→HTML 조립\n⚡ Gemini API"]:::aiGemini
E["발표자료 생성\n예정"]:::planned
F["사용자 등록 유형\n확장 가능"]:::planned
G["글벗 표준 HTML\nA4·Navy·Noto Sans KR"]:::startEnd
H{"편집 방식"}:::decision
I["웹 편집기\n수기 편집"]:::editStyle
J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude
K{"출력 형식"}:::decision
L["HTML / PDF"]:::exportStyle
M["HWP 변환\n스타일 분석→매핑→생성"]:::exportStyle
N["PPT 변환\n예정"]:::planned
O(["✅ 최종 산출물"]):::startEnd
A --> 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 # 분기 판단 (긴/짧은 문서)
│ │ ── 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줄)
│ │ └── step9_html.py # HTML 생성 (1248줄)
│ ├── html_to_hwp.py # 보고서→HWP 변환 (572줄)
│ └── html_to_hwp_briefing.py # 기획서→HWP 변환 (572줄)
├── prompts/
│ ├── step1_extract.txt # 구조 추출 프롬프트
│ ├── step1_5_plan.txt # 배치 계획 프롬프트
│ └── step2_generate.txt # HTML 생성 프롬프트
│ │ ├── 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/
│ ├── css/editor.css # 편집기 스타일
│ └── js/editor.js # 편집기 기능
│ ├── js/editor.js # 웹 WYSIWYG 편집기
│ └── css/editor.css # 편집기 스타일
├── templates/
│ ├── index.html # 메인 UI
│ └── hwp_guide.html # HWP 변환 가이드
├── output/assets/ # 이미지 에셋
├── .env / .env.sample # API 키 관리
├── .gitignore
├── requirements.txt
├── Procfile
└── railway.json
├── Procfile # 배포 설정 (Gunicorn)
└── README.md
```
## ⚙️ 프로세스 플로우
```mermaid
flowchart TB
subgraph INPUT["📥 Input"]
direction TB
A["🗂️ 문서 입력\nPDF, HWP, 이미지, 동영상"] --> B["step1: 파일 변환\nPDF 통일"]
B --> C["step2: 텍스트/이미지 추출\n(GPT API)"]
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"]
direction TB
I --> J["step8: 콘텐츠 생성\n(Gemini API)"]
J --> K["step9: HTML 생성\n(Gemini API)"]
end
subgraph EDIT["✏️ Edit"]
direction TB
K --> M["웹 편집기\neditor.js"]
K --> N["AI 편집\n/refine (Claude API)"]
end
subgraph EXPORT["📦 Export"]
direction TB
M & N --> P["HTML / PDF"]
M & N --> Q["HWP 변환\nhtml_to_hwp.py"]
end
```
## 🌐 API 라우트
| 라우트 | 메서드 | 기능 |
|--------|--------|------|
| `/` | GET | 메인 페이지 |
| `/generate` | POST | 기획서 생성 (1단계→1.5단계→2단계) |
| `/generate-report` | POST | 보고서 생성 (9단계 파이프라인) |
| `/refine` | POST | AI 전체 수정 |
| `/refine-selection` | POST | AI 부분 수정 |
| `/export-hwp` | POST | HWP 변환 |
| `/download/html` | POST | HTML 다운로드 |
| `/download/pdf` | POST | PDF 다운로드 |
| `/health` | GET | 서버 상태 확인 |
## 🤖 활용 AI
| 단계 | AI | 역할 |
|------|-----|------|
| step2 (추출) | GPT | PDF에서 텍스트/이미지 메타데이터 추출 |
| step7 (목차) | GPT | 인덱싱 및 목차 자동 생성 |
| step8 (콘텐츠) | Gemini | 섹션별 본문 초안 생성 |
| step9 (HTML) | Gemini | 최종 HTML 보고서 생성 |
| 기획서 생성 | Claude | HTML 구조 추출 + 변환 |
| AI 편집 | Claude | 피드백 반영 수정 |
---
## 🎨 글벗 표준 HTML 양식
- A4 인쇄 최적화 (210mm × 297mm)
- Noto Sans KR 폰트
- Navy 계열 색상 (#1a365d 기본)
- 구성: page-header, lead-box, section, data-table, bottom-box, footer
| 항목 | 사양 |
|------|------|
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
| 폰트 | Noto Sans KR (Google Fonts) |
| 색상 | Navy 계열 (#1a365d 기본) |
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
## 🖥️ 로컬 실행
---
```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-...",
"GPT_API_KEY": "sk-proj-...",
"GEMINI_API_KEY": "AIzaSy..."
}
```
| 영역 | 줄 수 |
|------|-------|
| Python 전체 | 9,780 |
| 프론트엔드 (JS + CSS + HTML) | 3,859 |
| **합계** | **~13,600** |
> ⚠️ `api_keys.json`은 `.gitignore`에 포함되어 Git에 올라가지 않습니다.
---
## 📝 v1 → v3 변경 이력
## 📝 버전 이력
| 버전 | 변경 내용 |
| 버전 | 핵심 변경 |
|------|----------|
| v1 | Flask + Claude API 기획서 생성기 (12파일, 422줄) |
| v2 | 웹 편집기 추가 (editor.js, editor.css) |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 추가 (40파일+, 6000줄+) |
| v1 | Flask + Claude API 기획서 생성기 |
| v2 | 웹 편집기 추가 |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
| **v4** | **코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기** |
---
## 📝 라이선스

View File

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

539
app.py
View File

@@ -1,174 +1,31 @@
# -*- coding: utf-8 -*-
"""
글벗 Light v2.0
2단계 API 변환 + 대화형 피드백 시스템
Flask + Claude API + Railway
Flask 라우팅 + 공통 기능
"""
import os
import json
import anthropic
from flask import Flask, render_template, request, jsonify, Response, session
from datetime import datetime
import io
import re
from flask import send_file
from datetime import datetime
import tempfile
from converters.pipeline.router import process_document
from api_config import API_KEYS
from datetime import datetime
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.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
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('/')
def index():
@@ -176,263 +33,107 @@ def index():
return render_template('index.html')
# ============== 생성 API ==============
@app.route('/generate', methods=['POST'])
def generate():
"""보고서 생성 API (2단계 처리)"""
"""기획서 생성 API"""
try:
# 입력 받기
content = ""
if 'file' in request.files and request.files['file'].filename:
file = request.files['file']
content = file.read().decode('utf-8')
elif 'content' in request.form:
content = request.form.get('content', '')
if not content.strip():
return jsonify({'error': '내용을 입력하거나 파일을 업로드해주세요.'}), 400
# 옵션
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] 형태로 분할합니다.'
options = {
'page_option': request.form.get('page_option', '1'),
'department': request.form.get('department', '총괄기획실'),
'instruction': request.form.get('instruction', '')
}
step2_prompt = get_step2_prompt()
step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요.
result = processors['briefing'].generate(content, options)
## 배치 계획
{json.dumps(page_plan, ensure_ascii=False, indent=2)}
if 'error' in result:
return jsonify(result), 400 if 'trace' not in result else 500
return jsonify(result)
## 문서 구조 (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 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 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:
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'])
def refine():
"""피드백 반영 API (대화형)"""
"""피드백 반영 API"""
try:
feedback = request.json.get('feedback', '')
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', '')
doc_type = request.json.get('doc_type', 'briefing')
# 피드백 반영 프롬프트
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine(feedback, current_html, original_html)
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
## 규칙
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
# 대화 히스토리 저장
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:
return jsonify({'error': f'서버 오류: {str(e)}'}), 500
return jsonify({'error': str(e)}), 500
@app.route('/refine-selection', methods=['POST'])
def refine_selection():
"""선택 부분 수정"""
"""선택 부분 수정 API"""
try:
data = request.json
current_html = data.get('current_html', '')
selected_text = data.get('selected_text', '')
user_request = data.get('request', '')
doc_type = data.get('doc_type', 'briefing')
if not current_html or not selected_text or not user_request:
return jsonify({'error': '필수 데이터가 없습니다.'}), 400
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine_selection(current_html, selected_text, user_request)
# Claude API 호출
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()
# 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
})
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============== 다운로드 API ==============
@app.route('/download/html', methods=['POST'])
def download_html():
"""HTML 파일 다운로드"""
@@ -473,48 +174,12 @@ def download_pdf():
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
except ImportError:
return jsonify({'error': 'PDF 변환 미지원. HTML 다운로드 후 브라우저에서 인쇄하세요.'}), 501
return jsonify({'error': 'PDF 변환 미지원'}), 501
except Exception as e:
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
@app.route('/hwp-script')
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
# ============== 기타 API ==============
@app.route('/assets/<path:filename>')
def serve_assets(filename):
@@ -523,43 +188,51 @@ def serve_assets(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')
def health():
"""헬스 체크"""
return jsonify({'status': 'healthy', 'version': '2.0.0'})
# ===== HWP 변환 =====
@app.route('/export-hwp', methods=['POST'])
def export_hwp():
"""HWP 변환 (스타일 그루핑 지원)"""
try:
data = request.get_json()
html_content = data.get('html', '')
doc_type = data.get('doc_type', 'briefing')
use_style_grouping = data.get('style_grouping', False) # 새 옵션
if not html_content:
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
# 임시 파일 생성
temp_dir = tempfile.gettempdir()
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
# HTML 저장
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html_content)
# 변환기 import 및 실행
# 변환기 선택
if doc_type == 'briefing':
from converters.html_to_hwp_briefing import HtmlToHwpConverter
else:
from converters.html_to_hwp import HtmlToHwpConverter
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(
hwp_path,
as_attachment=True,
@@ -573,6 +246,46 @@ def export_hwp():
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__':
port = int(os.environ.get('PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'

37
converters/dkdl.py Normal file
View 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("완료!")

View File

@@ -13,6 +13,11 @@ from pyhwpx import Hwp
from bs4 import BeautifulSoup, NavigableString
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 (이미지 크기 확인용)
try:
from PIL import Image
@@ -25,9 +30,12 @@ class Config:
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
HEADER_LEN, FOOTER_LEN = 10, 10
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가
class StyleParser:
def __init__(self):
self.style_map = {} # 스타일 매핑 (역할 → HwpStyle)
self.sty_gen = None # 스타일 생성기
self.class_styles = {
'h1': {'font-size': '20pt', 'color': '#008000'},
'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']
# ═══════════════════════════════════════════════════════════════
# 번호 제거 유틸리티
# ═══════════════════════════════════════════════════════════════
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:
def __init__(self, visible=True):
@@ -71,6 +107,8 @@ class HtmlToHwpConverter:
self.base_path = ""
self.is_first_h1 = True
self.image_count = 0
self.style_map = {} # 역할 → 스타일 이름 매핑
self.sty_path = None # .sty 파일 경로
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
@@ -155,6 +193,80 @@ class HtmlToHwpConverter:
except Exception as 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'):
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
@@ -372,17 +484,23 @@ class HtmlToHwpConverter:
# ═══════════════════════════════════════════════════════════════
def _insert_image(self, src, caption=""):
self.image_count += 1
print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}")
if not src:
return
# 상대경로 → 절대경로
# 🆕 assets 폴더에서 먼저 찾기
filename = os.path.basename(src)
full_path = os.path.join(self.cfg.ASSETS_PATH, filename)
# 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):
print(f" ❌ 파일 없음: {full_path}")
self._set_font(9, False, '#999999')
@@ -451,6 +569,122 @@ class HtmlToHwpConverter:
except Exception as 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):
txt = elem.get_text(strip=True)
if not txt: return
@@ -551,19 +785,225 @@ class HtmlToHwpConverter:
print(f"\n✅ 저장: {output_path}")
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):
try: self.hwp.Quit()
except: pass
def main():
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:
conv = HtmlToHwpConverter(visible=True)
conv.convert(html_path, output_path)
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가
input("\nEnter를 누르면 HWP가 닫힙니다...")
conv.close()
except Exception as e:
print(f"\n[에러] {e}")

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

84
handlers/common.py Normal file
View File

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

View File

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

View 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)}

View File

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

View File

@@ -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
}
}

View File

@@ -5,12 +5,25 @@
padding: 8px 12px;
background: var(--ui-panel);
border-bottom: 1px solid var(--ui-border);
gap: 4px;
gap: 6px;
flex-wrap: wrap;
}
.format-bar.active { display: flex; }
/* 편집 바 2줄 구조 */
.format-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.format-row:first-child {
border-bottom: 1px solid var(--ui-border);
padding-bottom: 8px;
}
.format-btn {
padding: 6px 10px;
background: none;
@@ -61,6 +74,26 @@
.format-btn:hover .tooltip { opacity: 1; }
/* 페이지 버튼 스타일 */
.format-btn.page-btn {
padding: 6px 12px;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
min-width: fit-content;
}
/* 페이지 브레이크 표시 */
.page-break-forced {
border-top: 3px solid #e65100 !important;
margin-top: 10px;
}
.move-to-prev-page {
border-top: 3px dashed #1976d2 !important;
margin-top: 10px;
}
/* 색상 선택기 */
.color-picker-btn {
position: relative;
@@ -185,6 +218,65 @@
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
}
.resizable-container { position: relative; display: inline-block; max-width: 100%; }
.resizable-container.block-type { display: block; }
.resize-handle {
position: absolute;
right: -2px;
bottom: -2px;
width: 18px;
height: 18px;
background: #00C853;
cursor: se-resize;
opacity: 0;
transition: opacity 0.2s;
z-index: 100;
border-radius: 3px 0 3px 0;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '⤡';
color: white;
font-size: 12px;
font-weight: bold;
}
.resizable-container:hover .resize-handle { opacity: 0.8; }
.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); }
.resizable-container.resizing { outline: 2px dashed #00C853 !important; }
.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; }
/* 표 전용 */
.resizable-container.table-resize .resize-handle { background: #2196F3; }
.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; }
/* 이미지 전용 */
.resizable-container.figure-resize img { display: block; }
/* 크기 표시 툴팁 */
.size-tooltip {
position: absolute;
bottom: 100%;
right: 0;
background: rgba(0,0,0,0.8);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.resizable-container:hover .size-tooltip,
.resizable-container.resizing .size-tooltip { opacity: 1; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }

View File

@@ -11,6 +11,7 @@ let redoStack = [];
const MAX_HISTORY = 50;
let isApplyingFormat = false;
// ===== 편집 바 HTML 생성 =====
// ===== 편집 바 HTML 생성 =====
function createFormatBar() {
const formatBarHTML = `
@@ -21,52 +22,120 @@ function createFormatBar() {
<option value="나눔고딕">나눔고딕</option>
<option value="돋움">돋움</option>
</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"
style="width:55px;" onchange="applyFontSizeInput(this.value)">
<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" id="btnItalic" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임 (Ctrl+I)</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" id="btnStrike" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
<button class="format-btn" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게</span></button>
<button class="format-btn" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임</span></button>
<button class="format-btn" onclick="formatText('underline')"><u>U</u><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>
<button class="format-btn" onclick="adjustLetterSpacing(-0.5)">A⇠<span class="tooltip">자간 줄이기</span></button>
<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;">
<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="color-picker-btn format-btn">
<span style="border-bottom:3px solid #000;">A</span>
<input type="color" id="textColor" value="#000000" onchange="applyTextColor(this.value)">
<span class="tooltip">글자 색상</span>
</div>
<div class="color-picker-btn format-btn">
<span style="background:#ff0;padding:0 4px;">A</span>
<input type="color" id="bgColor" value="#ffff00" onchange="applyBgColor(this.value)">
<span class="tooltip">배경 색상</span>
</div>
<div class="format-divider"></div>
<button class="format-btn" onclick="formatText('justifyLeft')">⫷<span class="tooltip">왼쪽 정렬</span></button>
<button class="format-btn" onclick="formatText('justifyCenter')">☰<span class="tooltip">가운데 정렬</span></button>
<button class="format-btn" onclick="formatText('justifyRight')">⫸<span class="tooltip">오른쪽 정렬</span></button>
<div class="format-divider"></div>
<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 class="format-select" onchange="handleInsert(this.value); this.selectedIndex=0;">
<option value="">삽입 ▾</option>
<option value="table">▦ 표</option>
<option value="image">🖼️ 그림</option>
<option value="hr">― 구분선</option>
</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>
`;
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 생성 =====
function createTableModal() {
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 편집 이벤트 바인딩 =====
function bindIframeEditEvents() {
const doc = getIframeDoc();
if (!doc) return;
// 편집용 스타일 주입
injectEditStyles(doc);
// 키보드 이벤트
doc.removeEventListener('keydown', handleEditorKeydown);
doc.addEventListener('keydown', handleEditorKeydown);
@@ -479,6 +733,81 @@ function bindIframeEditEvents() {
}
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() {
// 편집 바가 없으면 생성
if (!document.getElementById('formatBar')) {
const previewContainer = document.querySelector('.preview-container');
const previewContainer = document.querySelector('.main');
if (previewContainer) {
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
}
@@ -550,5 +879,330 @@ function initEditor() {
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 로드 시 초기화
document.addEventListener('DOMContentLoaded', initEditor);

View File

@@ -1074,6 +1074,8 @@
}
</style>
<link rel="stylesheet" href="/static/css/editor.css">
<script src="/static/js/editor.js"></script>
</head>
<body>
<!-- 상단 툴바 -->
@@ -1217,16 +1219,6 @@
<!-- 가운데 뷰어 -->
<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="a4-wrapper" id="a4Wrapper">
@@ -1531,7 +1523,8 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
html: html,
doc_type: currentDocType
doc_type: currentDocType,
style_grouping: true
})
});
@@ -1628,7 +1621,8 @@
body: JSON.stringify({
current_html: generatedHTML,
selected_text: selectedText,
request: request
request: request,
doc_type: currentDocType // ← 추가
})
});
@@ -1696,13 +1690,41 @@
if (targetEl.nodeType === 3) {
targetEl = targetEl.parentElement;
}
// 적절한 부모 찾기 (div, section 등)
if (currentDocType === 'report') {
// ===== 보고서: 안전한 부모만 교체 =====
// 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;
}
}
}
}
generatedHTML = '<!DOCTYPE html>' + doc.documentElement.outerHTML;
@@ -2196,6 +2218,93 @@
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() {
const frame = document.getElementById('previewFrame');
if (frame.contentWindow) {