v6:HWPX 템플릿 분석·저장·관리_20260128

This commit is contained in:
2026-02-20 11:43:44 +09:00
parent 71740ce912
commit 5129ee69d4
8 changed files with 1482 additions and 94 deletions

113
README.md
View File

@@ -1,13 +1,14 @@
# 글벗 (Geulbeot) v5.0
# 글벗 (Geulbeot) v6.0
**HWPX 스타일 주입 + 표 열 너비 정밀 변환**
**HWPX 템플릿 분석·저장·관리**
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
v5에서는 HWP 변환 품질을 고도화했습니다. 기존 pyhwpx 기본 변환에 HWPX 후처리를 추가하여,
커스텀 스타일 주입과 표 열 너비 정밀 조정이 가능해졌습니다.
v6에서는 HWPX 템플릿 관리 기능을 추가했습니다.
HWPX 파일을 업로드하면 XML을 파싱하여 폰트·색상·여백·표 구조·테두리 등을 자동 분석하고,
재사용 가능한 템플릿으로 저장합니다.
---
@@ -29,7 +30,7 @@ RAG 파이프라인 (9단계) ─── 공통 처리
└─ 사용자 등록 (확장 가능)
글벗 표준 HTML 생성
글벗 표준 HTML 생성 ◀── 템플릿 스타일 참조 (v6 신규)
웹 편집기 (수기 편집 / AI 편집)
@@ -50,6 +51,7 @@ RAG 파이프라인 (9단계) ─── 공통 처리
- 자료 입력 → 9단계 RAG 파이프라인 (파일 변환 → 추출 → 도메인 분석 → 청킹 → 임베딩 → 코퍼스 → 인덱싱 → 콘텐츠 생성 → HTML 조립)
- 문서 유형별 생성: 기획서 (Claude 3단계), 보고서 (Gemini 2단계)
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
- HWPX 템플릿 분석·저장·관리 (v6 신규)
- HWP 변환: 하이브리드 방식 — pyhwpx 기본 생성 → HWPX 스타일 주입 → 표 열 너비 수정
- PDF 변환: WeasyPrint 기반
@@ -64,19 +66,28 @@ RAG 파이프라인 (9단계) ─── 공통 처리
- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립
- **분량 자동 판단**: 5,000자 기준 — 긴 문서는 전체 파이프라인, 짧은 문서는 축약 파이프라인
- **HWP 변환 (v5 하이브리드 방식)**:
1. HTML 분석 → StyleAnalyzer로 역할 분류
2. pyhwpx 기본 변환 (표·이미지·머리말·꼬리말 정상 처리)
3. HWP → HWPX 변환
4. HWPX 후처리 — header.xml에 커스텀 스타일 정의 주입, section*.xml에 역할별 styleIDRef 매핑
5. HWPX 후처리 — 표 열 너비 정밀 수정 (px/mm/% → HWPML 단위 변환)
- **HWP 변환 (하이브리드 방식)**: HTML 분석 → pyhwpx 변환 → HWPX 스타일 주입 → 표 열 너비 수정
### 4. 주요 시나리오 (Core Scenarios)
### 4. 템플릿 관리 (v6 신규)
- **HWPX 파싱**: 업로드된 HWPX를 압축 해제하여 header.xml + section*.xml 구조 분석
- **자동 추출 항목**:
- 폰트 정보 (이름·크기·굵기·색상)
- 문단 스타일 (정렬·줄간격·들여쓰기·번호 체계)
- 표 구조 (열 너비·행 수·셀 병합·테두리 스타일·선 종류)
- 배경 (색상·이미지 채우기)
- 테두리 (ARGB 8자리 색상 정규화, NONE 제외)
- 페이지 설정 (여백·용지 크기)
- **CSS 자동 생성**: 분석된 스타일을 CSS로 변환하여 HTML 생성 시 참조 가능
- **저장소**: `templates_store/` 디렉토리에 메타데이터(meta.json) + 원본 파일 + 분석 결과 저장
### 5. 주요 시나리오 (Core Scenarios)
1. **기획서 생성**: 텍스트 또는 파일을 입력하면, RAG 분석 후 Claude API가 구조 추출 → 페이지 배치 계획 → 글벗 표준 HTML 기획서를 생성. 1~N페이지 옵션 지원
2. **보고서 생성**: 폴더 경로의 자료들을 RAG 파이프라인으로 분석하고, Gemini API가 섹션별 콘텐츠 초안 → 표지·목차·간지·별첨이 포함된 다페이지 HTML 보고서를 생성
3. **AI 편집**: 생성된 문서를 웹 편집기에서 확인 후, "이 부분을 표로 바꿔줘" 같은 피드백으로 전체 또는 선택 부분을 AI가 수정
4. **HWP 내보내기 (v5 개선)**: 기존 pyhwpx 변환 후 HWPX를 열어 커스텀 스타일(제목 계층·본문·표)주입하고, 표 열 너비를 원본 HTML과 일치시켜 서식 정확도를 높임
3. **AI 편집**: 생성된 문서를 웹 편집기에서 확인 후, 피드백으로 전체 또는 선택 부분을 AI가 수정
4. **템플릿 등록 (v6 신규)**: HWPX 파일을 업로드하면 XML을 파싱하여 폰트·표·테두리·색상 등을 자동 분석하고, 재사용 가능한 템플릿으로 저장. 등록된 템플릿은 조회·삭제 가능
5. **HWP 내보내기**: pyhwpx 변환 후 HWPX 스타일 주입 + 표 열 너비 정밀 수정
### 프로세스 플로우
@@ -119,7 +130,7 @@ flowchart TD
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
classDef newModule fill:#e0f2f1,stroke:#00695c,stroke-width:2px,color:#004d40
A(["📋 RAG 분석 결과"]):::startEnd
B{"문서 유형 선택"}:::decision
@@ -129,6 +140,8 @@ flowchart TD
E["발표자료 생성\n예정"]:::planned
F["사용자 등록 유형\n확장 가능"]:::planned
T["📋 템플릿 스타일 참조\ntemplates_store/\n(v6 신규)"]:::newModule
G["글벗 표준 HTML\nA4·Navy·Noto Sans KR"]:::startEnd
H{"편집 방식"}:::decision
@@ -137,7 +150,7 @@ flowchart TD
K{"출력 형식"}:::decision
L["HTML / PDF"]:::exportStyle
M["HWP 변환 (v5 하이브리드)\npyhwpx→스타일주입→표주입"]:::newModule
M["HWP 변환 (하이브리드)\npyhwpx→스타일주입→표주입"]:::exportStyle
N["PPT 변환\n예정"]:::planned
O(["✅ 최종 산출물"]):::startEnd
@@ -147,6 +160,8 @@ flowchart TD
B -->|"발표자료"| E -.-> G
B -->|"확장"| F -.-> G
T -.->|"스타일 참조"| G
G --> H
H -->|"수기"| I --> K
H -->|"AI"| J --> K
@@ -155,38 +170,36 @@ flowchart TD
K -->|"PPT"| N -.-> O
```
#### HWP 변환 (v5 하이브리드 방식)
#### 템플릿 분석 (v6 신규)
```mermaid
flowchart TD
flowchart LR
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
classDef dataStore fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A(["📄 글벗 HTML"]):::startEnd
B["① StyleAnalyzer\nHTML 요소 역할 분류"]:::process
C["② pyhwpx 기본 변환\n표·이미지·머리말 처리"]:::process
D["③ HWP → HWPX 변환"]:::process
E["④ 스타일 주입\nhwpx_style_injector\nheader.xml + section.xml"]:::newModule
F["⑤ 표 열 너비 수정\nhwpx_table_injector\npx/mm/% → HWPML"]:::newModule
G([".hwpx 파일"]):::exportStyle
A(["📄 HWPX 업로드"]):::startEnd
B["압축 해제\nheader.xml\nsection*.xml"]:::process
C["XML 파싱\n폰트·표·테두리·색상\n페이지 설정"]:::newModule
D["CSS 자동 생성\n스타일 요약"]:::newModule
E[("📋 templates_store/\nmeta.json\n+ 분석 결과")]:::dataStore
A --> B --> C --> D --> E --> F --> G
A --> B --> C --> D --> E
```
---
## 🔄 v4 → v5 변경사항
## 🔄 v5 → v6 변경사항
| 영역 | v4 | v5 |
| 영역 | v5 | v6 |
|------|------|------|
| HWP 변환 방식 | pyhwpx 기본 변환만 | 하이브리드: pyhwpx → HWPX 후처리 |
| 스타일 주입 | style_analyzer로 분석 | + **hwpx_style_injector** header.xml 스타일 정의, section.xml 매핑 |
| 표 열 너비 | HTML 원본과 불일치 | + **hwpx_table_injector** — px/mm/% → HWPML 정밀 변환 |
| 표 너비 파싱 | 없음 | html_to_hwp.py에 `_parse_width()` 유틸 추가 |
| HWP 출력 형식 | .hwp만 | .hwpx 출력 지원 (mimetype 추가) |
| 테스트 코드 | dkdl.py 잔존 | 삭제 (정리) |
| 템플릿 관리 | 없음 | **handlers/template/ 패키지 신규** |
| HWPX 분석 | 없음 | header.xml·section.xml 파싱 (폰트·표·테두리·배경·페이지) |
| CSS 자동 생성 | 없음 | 분석된 스타일 → CSS 변환 |
| 신규 API | | `GET /templates` · `POST /analyze-template` · `DELETE /delete-template/<id>` |
| 저장소 | — | `templates_store/` (meta.json + 원본 + 분석 결과) |
| AI 프롬프트 | — | `analyze_template.txt` — 구조 분석 보조 |
---
@@ -195,9 +208,9 @@ flowchart TD
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현)
- **Phase 2**: 문서 생성 — 기획서·보고서 AI 생성 + 글벗 표준 HTML 양식 (🔧 기본 구현)
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현 · 현재 버전)
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
- **Phase 5**: 문서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정)
- **Phase 6**: HWPX 템플릿 관리 — 파싱·시맨틱 매핑·스타일 추출·표 매칭·콘텐츠 주입 (예정)
- **Phase 6**: HWPX 템플릿 관리 — 파싱·스타일 추출·CSS 생성·저장·조회·삭제 (🔧 기본 구현 · 현재 버전)
- **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, AI 편집 개선, 도메인 선택기 (예정)
- **Phase 8**: 백엔드 재구조화 + 배포 — 패키지 정리, API 키 공통화, 로깅, Docker (예정)
@@ -217,8 +230,8 @@ flowchart TD
```bash
# 저장소 클론 및 설정
git clone http://[Gitea주소]/kei/geulbeot-v5.git
cd geulbeot-v5
git clone http://[Gitea주소]/kei/geulbeot-v6.git
cd geulbeot-v6
# 가상환경
python -m venv venv
@@ -252,14 +265,18 @@ python app.py
## 📂 프로젝트 구조
```
geulbeot_5th/
geulbeot_6th/
├── app.py # Flask 웹 서버 — API 라우팅
├── api_config.py # .env 환경변수 로더
├── handlers/ # 비즈니스 로직
│ ├── common.py # Claude API 호출, JSON/HTML 추출
│ ├── briefing/ # 기획서 처리 (구조추출 → 배치 → HTML)
── report/ # 보고서 처리 (RAG 파이프라인 연동)
── report/ # 보고서 처리 (RAG 파이프라인 연동)
│ └── template/ # ★ v6 신규 — 템플릿 관리
│ ├── processor.py # HWPX 파싱·분석·CSS 생성·CRUD
│ └── prompts/
│ └── analyze_template.txt # AI 구조 분석 프롬프트
├── converters/ # 변환 엔진
│ ├── pipeline/ # 9단계 RAG 파이프라인
@@ -268,11 +285,13 @@ geulbeot_5th/
│ ├── style_analyzer.py # HTML 요소 역할 분류
│ ├── hwpx_generator.py # HWPX 파일 직접 생성
│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑
│ ├── hwpx_style_injector.py # ★ v5 신규 — HWPX 커스텀 스타일 주입
│ ├── hwpx_table_injector.py # ★ v5 신규 — HWPX 표 열 너비 정밀 수정
│ ├── hwpx_style_injector.py # HWPX 커스텀 스타일 주입
│ ├── hwpx_table_injector.py # HWPX 표 열 너비 정밀 수정
│ ├── html_to_hwp.py # 보고서 → HWP 변환 (하이브리드 워크플로우)
│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환
├── templates_store/ # ★ v6 신규 — 등록된 템플릿 저장소
├── static/
│ ├── js/editor.js # 웹 WYSIWYG 편집기
│ └── css/editor.css # 편집기 스타일
@@ -307,6 +326,7 @@ geulbeot_5th/
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
- 문서 유형: 기획서·보고서만 구현, 발표자료·사용자 등록 유형 미구현
- 템플릿 → 문서 생성 연동: 아직 미연결 (분석·저장만 가능, 생성 시 자동 적용은 예정)
- 레거시 잔존: prompts/ 디렉토리
---
@@ -315,9 +335,9 @@ geulbeot_5th/
| 영역 | 줄 수 |
|------|-------|
| Python 전체 | 10,782 (+1,002) |
| Python 전체 | 11,406 (+624) |
| 프론트엔드 (JS + CSS + HTML) | 3,859 |
| **합계** | **~14,600** |
| **합계** | **~15,300** |
---
@@ -329,7 +349,8 @@ geulbeot_5th/
| v2 | 웹 편집기 추가 |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
| **v5** | **HWPX 스타일 주입 + 표 열 너비 정밀 변환** |
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
| **v6** | **HWPX 템플릿 분석·저장·관리** |
---

65
app.py
View File

@@ -9,6 +9,8 @@ import io
import tempfile
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response, session, send_file
from handlers.template import TemplateProcessor
# 문서 유형별 프로세서
from handlers.briefing import BriefingProcessor
@@ -18,13 +20,15 @@ app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
# 프로세서 인스턴스
# processors 딕셔너리에 추가
processors = {
'briefing': BriefingProcessor(),
'report': ReportProcessor()
'report': ReportProcessor(),
'template': TemplateProcessor() # 추가
}
# ============== 메인 페이지 ==============
@app.route('/')
@@ -75,7 +79,8 @@ def generate_report():
'cover': data.get('cover', False),
'toc': data.get('toc', False),
'divider': data.get('divider', False),
'instruction': data.get('instruction', '')
'instruction': data.get('instruction', ''),
'template_id': data.get('template_id')
}
result = processors['report'].generate(content, options)
@@ -290,7 +295,59 @@ def analyze_styles():
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/templates', methods=['GET'])
def get_templates():
"""저장된 템플릿 목록 조회"""
try:
result = processors['template'].get_list()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/analyze-template', methods=['POST'])
def analyze_template():
"""템플릿 분석 및 저장"""
try:
if 'file' not in request.files:
return jsonify({'error': '파일이 없습니다'}), 400
file = request.files['file']
name = request.form.get('name', '').strip()
if not name:
return jsonify({'error': '템플릿 이름을 입력해주세요'}), 400
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
result = processors['template'].analyze(file, name)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/delete-template/<template_id>', methods=['DELETE'])
def delete_template(template_id):
"""템플릿 삭제"""
try:
result = processors['template'].delete(template_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))

View File

@@ -34,23 +34,21 @@ def is_long_document(html_content: str) -> bool:
def convert_image_paths(html_content: str) -> str:
"""
HTML 내 상대 이미지 경로를 서버 경로로 변환
assets/xxx.png → /assets/xxx.png
HTML 내 이미지 경로를 서버 경로로 변환
- assets/xxx.png → /assets/xxx.png (Flask 서빙용)
- 절대 경로나 URL은 그대로 유지
"""
result = re.sub(r'src="assets/', 'src="/assets/', html_content)
return result
def replace_src(match):
original_path = match.group(1)
# 이미 절대 경로이거나 URL이면 그대로
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')):
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')):
return match.group(0)
# assets/로 시작하면 절대 경로로 변환
# assets/로 시작하면 /assets/로 변환 (Flask 서빙)
if original_path.startswith('assets/'):
filename = original_path.replace('assets/', '')
absolute_path = os.path.join(ASSETS_BASE_PATH, filename)
return f'src="{absolute_path}"'
return f'src="/{original_path}"'
return match.group(0)
@@ -80,6 +78,29 @@ def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
'pipeline': 'short'
}
def inject_template_css(html_content: str, template_css: str) -> str:
"""
HTML에 템플릿 CSS 주입
- <style> 태그가 있으면 그 안에 추가
- 없으면 <head>에 새로 생성
"""
if not template_css:
return html_content
css_block = f"\n/* ===== 템플릿 스타일 ===== */\n{template_css}\n"
# 기존 </style> 태그 앞에 추가
if '</style>' in html_content:
return html_content.replace('</style>', f'{css_block}</style>', 1)
# <head> 태그 뒤에 새로 추가
elif '<head>' in html_content:
return html_content.replace('<head>', f'<head>\n<style>{css_block}</style>', 1)
# head도 없으면 맨 앞에 추가
else:
return f'<style>{css_block}</style>\n{html_content}'
def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
"""
@@ -136,4 +157,9 @@ def process_document(content: str, options: dict = None) -> Dict[str, Any]:
result['char_count'] = char_count
result['threshold'] = LONG_DOC_THRESHOLD
# ⭐ 템플릿 CSS 주입
template_css = options.get('template_css')
if template_css and result.get('success') and result.get('html'):
result['html'] = inject_template_css(result['html'], template_css)
return result

View File

@@ -31,6 +31,15 @@ class ReportProcessor:
if not content.strip():
return {'error': '내용이 비어있습니다.'}
# ⭐ 템플릿 스타일 로드
template_id = options.get('template_id')
if template_id:
from handlers.template import TemplateProcessor
template_processor = TemplateProcessor()
style = template_processor.get_style(template_id)
if style and style.get('css'):
options['template_css'] = style['css']
# 이미지 경로 변환
processed_html = convert_image_paths(content)

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,334 @@
--ui-error: #f85149;
--ui-info: #58a6ff;
}
/* ===== 사용자 템플릿 영역 ===== */
.user-templates-section {
max-height: 200px;
overflow-y: auto;
margin: 10px 0;
padding-right: 5px;
}
.user-templates-section::-webkit-scrollbar {
width: 4px;
}
.user-templates-section::-webkit-scrollbar-thumb {
background: var(--ui-border);
border-radius: 2px;
}
.user-templates-section::-webkit-scrollbar-thumb:hover {
background: var(--ui-dim);
}
.template-divider {
height: 1px;
background: var(--ui-border);
margin: 10px 0;
position: relative;
}
.template-divider::after {
content: '사용자 템플릿';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--ui-nav);
padding: 0 8px;
font-size: 9px;
color: var(--ui-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 사용자 템플릿 아이템 */
.user-template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
position: relative;
margin-bottom: 4px;
}
.user-template-item:hover {
background: var(--ui-hover);
border-color: var(--ui-dim);
}
.user-template-item.selected {
border-color: var(--ui-accent);
background: rgba(0, 200, 83, 0.1);
}
.user-template-item .label {
flex: 1;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-template-item .delete-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--ui-dim);
font-size: 14px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.15s;
}
.user-template-item:hover .delete-btn {
opacity: 1;
}
.user-template-item .delete-btn:hover {
background: var(--ui-error);
color: white;
}
/* 템플릿 추가 모달 */
.template-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
align-items: center;
justify-content: center;
z-index: 2000;
}
.template-modal.active {
display: flex;
}
.template-modal-content {
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: 12px;
padding: 24px;
width: 420px;
box-shadow: 0 15px 50px rgba(0,0,0,0.5);
}
.template-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.template-modal-title {
font-size: 16px;
font-weight: 700;
color: var(--ui-accent);
display: flex;
align-items: center;
gap: 8px;
}
.template-modal-close {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--ui-dim);
font-size: 18px;
cursor: pointer;
border-radius: 6px;
}
.template-modal-close:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.template-input-group {
margin-bottom: 16px;
}
.template-input-label {
font-size: 12px;
font-weight: 600;
color: var(--ui-dim);
margin-bottom: 8px;
display: block;
}
.template-name-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-bg);
color: var(--ui-text);
font-size: 13px;
}
.template-name-input:focus {
outline: none;
border-color: var(--ui-accent);
}
.template-dropzone {
border: 2px dashed var(--ui-border);
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.template-dropzone:hover,
.template-dropzone.dragover {
border-color: var(--ui-accent);
background: rgba(0, 200, 83, 0.05);
}
.template-dropzone-icon {
font-size: 36px;
margin-bottom: 10px;
}
.template-dropzone-text {
font-size: 13px;
color: var(--ui-text);
margin-bottom: 5px;
}
.template-dropzone-hint {
font-size: 11px;
color: var(--ui-dim);
}
.template-dropzone-file {
display: none;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--ui-bg);
border-radius: 6px;
margin-top: 10px;
}
.template-dropzone-file.show {
display: flex;
}
.template-dropzone-file .filename {
flex: 1;
font-size: 12px;
color: var(--ui-accent);
}
.template-dropzone-file .remove {
color: var(--ui-dim);
cursor: pointer;
}
.template-dropzone-file .remove:hover {
color: var(--ui-error);
}
.template-submit-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, var(--ui-accent), #00a844);
color: #003300;
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
transition: all 0.2s;
}
.template-submit-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 200, 83, 0.3);
}
.template-submit-btn:disabled {
background: var(--ui-border);
color: var(--ui-dim);
cursor: not-allowed;
}
.template-submit-btn .spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* 사용자 템플릿 프리뷰 */
.user-template-preview {
display: none;
position: fixed;
width: 280px;
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: 12px;
padding: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
z-index: 1000;
pointer-events: none;
}
.user-template-preview::after {
content: '';
position: absolute;
right: -8px;
top: 50%;
transform: translateY(-50%);
border: 8px solid transparent;
border-left-color: var(--ui-panel);
}
.user-template-preview.show {
display: block;
}
.preview-analyzed-features {
margin-top: 12px;
}
.preview-analyzed-feature {
font-size: 11px;
color: var(--ui-accent);
padding: 3px 0;
display: flex;
align-items: center;
gap: 6px;
}
.preview-analyzed-feature::before {
content: '✓';
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -1354,9 +1682,16 @@
</div>
</div>
</div>
<div id="userTemplatesArea" style="display: none;">
<div class="template-divider"></div>
<div class="user-templates-section" id="userTemplatesList">
<!-- 동적으로 추가됨 -->
</div>
</div>
<!-- 템플릿 추가 버튼 -->
<button class="add-template-btn" onclick="addTemplate()">+ 템플릿 추가</button>
<button class="add-template-btn" onclick="openTemplateModal()">+ 템플릿 추가</button>
</div>
<!-- 기획서 옵션 -->
@@ -1411,7 +1746,15 @@
</div>
</div>
</div>
<!-- 템플릿 옵션 (요청사항만) -->
<div id="templateOptions" style="display:none;">
<div class="option-section">
<div class="option-title">요청사항</div>
<textarea class="request-textarea" id="templateInstructionInput" placeholder="예: 요약을 상세하게 작성해줘&#10;예: 특정 섹션 강조"></textarea>
</div>
</div>
<!-- 요청사항 -->
<div class="option-section">
<div class="option-title">요청사항</div>
@@ -1502,6 +1845,232 @@
let selectedText = '';
let selectedRange = null;
// ===== 템플릿 관련 변수 =====
let userTemplates = []; // [{id, name, features, created_at}, ...]
let selectedTemplateFile = null;
// ===== 템플릿 모달 =====
function openTemplateModal() {
document.getElementById('templateModal').classList.add('active');
document.getElementById('templateNameInput').value = '';
removeTemplateFile();
// 이벤트 리스너 재연결
document.getElementById('templateNameInput').oninput = updateTemplateSubmitBtn;
}
function closeTemplateModal() {
document.getElementById('templateModal').classList.remove('active');
}
function handleTemplateFile(input) {
if (input.files.length > 0) {
handleTemplateFileSelect(input.files[0]);
}
}
function handleTemplateFileSelect(file) {
const validExtensions = ['.hwpx', '.hwp', '.pdf'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!validExtensions.includes(ext)) {
alert('지원하지 않는 파일 형식입니다.\n(HWPX, HWP, PDF만 지원)');
return;
}
selectedTemplateFile = file;
document.getElementById('templateFileName').textContent = file.name;
document.getElementById('templateFileInfo').classList.add('show');
document.getElementById('templateDropzone').style.display = 'none';
// 파일명에서 템플릿 이름 자동 추출
const nameInput = document.getElementById('templateNameInput');
if (!nameInput.value.trim()) {
const baseName = file.name.replace(/\.[^/.]+$/, '');
nameInput.value = baseName;
}
updateTemplateSubmitBtn();
}
function removeTemplateFile() {
selectedTemplateFile = null;
document.getElementById('templateFileInput').value = '';
document.getElementById('templateFileInfo').classList.remove('show');
document.getElementById('templateDropzone').style.display = 'block';
updateTemplateSubmitBtn();
}
function updateTemplateSubmitBtn() {
const nameInput = document.getElementById('templateNameInput');
const btn = document.getElementById('templateSubmitBtn');
btn.disabled = !selectedTemplateFile || !nameInput.value.trim();
}
document.getElementById('templateNameInput')?.addEventListener('input', updateTemplateSubmitBtn);
// ===== 템플릿 제출 =====
async function submitTemplate() {
const name = document.getElementById('templateNameInput').value.trim();
if (!name || !selectedTemplateFile) return;
const btn = document.getElementById('templateSubmitBtn');
const spinner = document.getElementById('templateSpinner');
const text = document.getElementById('templateSubmitText');
btn.disabled = true;
spinner.style.display = 'inline-block';
text.textContent = '분석 중...';
try {
const formData = new FormData();
formData.append('name', name);
formData.append('file', selectedTemplateFile);
const response = await fetch('/analyze-template', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
// 템플릿 목록에 추가
userTemplates.push(data.template);
renderUserTemplates();
closeTemplateModal();
setStatus(`템플릿 "${name}" 추가 완료`, true);
} catch (error) {
alert('템플릿 분석 오류: ' + error.message);
} finally {
btn.disabled = false;
spinner.style.display = 'none';
text.textContent = '✨ 분석 및 추가';
}
}
// ===== 템플릿 목록 렌더링 =====
function renderUserTemplates() {
const container = document.getElementById('userTemplatesList');
const area = document.getElementById('userTemplatesArea');
if (userTemplates.length === 0) {
area.style.display = 'none';
return;
}
area.style.display = 'block';
container.innerHTML = userTemplates.map((tpl, idx) => `
<div class="user-template-item" data-id="${tpl.id}" onclick="selectDocType('template_${tpl.id}')">
<input type="radio" name="docType">
<span class="label">📑 ${tpl.name}</span>
<button class="delete-btn" onclick="event.stopPropagation(); deleteTemplate('${tpl.id}')" title="삭제">✕</button>
<div class="user-template-preview doc-type-preview">
<div class="preview-thumbnail report">
<div class="line h1"></div>
<div class="line body"></div>
<div class="line body" style="width:90%"></div>
<div class="line h2"></div>
<div class="line body" style="width:85%"></div>
</div>
<div class="preview-title">${tpl.name}</div>
<div class="preview-desc">사용자 정의 템플릿</div>
<div class="preview-analyzed-features">
${(tpl.features || []).map(f => `<div class="preview-analyzed-feature">${f}</div>`).join('')}
</div>
</div>
</div>
`).join('');
// 호버 이벤트 연결
container.querySelectorAll('.user-template-item').forEach(item => {
const preview = item.querySelector('.user-template-preview');
if (!preview) return;
item.addEventListener('mouseenter', (e) => {
const rect = item.getBoundingClientRect();
preview.style.top = (rect.top + rect.height / 2 - 150) + 'px';
preview.style.left = (rect.left - 295) + 'px';
preview.classList.add('show');
});
item.addEventListener('mouseleave', () => {
preview.classList.remove('show');
});
});
}
// ===== 템플릿 목록 로드 =====
async function loadUserTemplates() {
try {
const response = await fetch('/templates');
const data = await response.json();
if (data.templates) {
userTemplates = data.templates;
renderUserTemplates();
}
} catch (error) {
console.error('템플릿 목록 로드 실패:', error);
}
}
// ===== 문서 유형 선택 =====
function selectDocType(type) {
// PPT는 비활성화
if (type === 'presentation') {
return;
}
currentDocType = type;
// 모든 doc-type-item 선택 해제
document.querySelectorAll('.doc-type-item').forEach(item => {
item.classList.remove('selected');
const radio = item.querySelector('input[type="radio"]');
if (radio) radio.checked = false;
});
// 모든 user-template-item 선택 해제
document.querySelectorAll('.user-template-item').forEach(item => {
item.classList.remove('selected');
const radio = item.querySelector('input[type="radio"]');
if (radio) radio.checked = false;
});
// 선택한 아이템 활성화
let selectedItem;
if (type.startsWith('template_')) {
const templateId = type.replace('template_', '');
selectedItem = document.querySelector(`.user-template-item[data-id="${templateId}"]`);
} else {
selectedItem = document.querySelector(`.doc-type-item[data-type="${type}"]`);
}
if (selectedItem && !selectedItem.classList.contains('disabled')) {
selectedItem.classList.add('selected');
const radio = selectedItem.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
}
// ⭐ 옵션 패널 전환 (3가지로 분기)
const isBriefing = (type === 'briefing');
const isReport = (type === 'report');
const isTemplate = type.startsWith('template_');
document.getElementById('briefingOptions').style.display = isBriefing ? 'block' : 'none';
document.getElementById('reportOptions').style.display = isReport ? 'block' : 'none';
document.getElementById('templateOptions').style.display = isTemplate ? 'block' : 'none';
}
// ===== HWP 추출 =====
async function exportHwp() {
if (!generatedHTML) {
@@ -1920,32 +2489,7 @@
reader.readAsText(file);
}
// ===== 문서 유형 선택 =====
function selectDocType(type) {
if (type === 'presentation') {
return; // PPT만 disabled
}
currentDocType = type;
document.querySelectorAll('.doc-type-item').forEach(item => {
item.classList.remove('selected');
if (item.dataset.type === type) {
item.classList.add('selected');
item.querySelector('input[type="radio"]').checked = true;
}
});
// 옵션 패널 표시/숨김
document.getElementById('briefingOptions').style.display = (type === 'briefing') ? 'block' : 'none';
document.getElementById('reportOptions').style.display = (type === 'report') ? 'block' : 'none';
}
// ===== 템플릿 추가 =====
function addTemplate() {
alert('템플릿 추가 기능은 준비중입니다.\n\n향후 사용자 정의 양식을 추가할 수 있습니다.');
}
// ===== 페이지 옵션 선택 =====
// ===== 페이지 옵션 선택 =====
function selectPageOption(option) {
currentPageOption = option;
document.querySelectorAll('.option-item').forEach(item => {
@@ -1961,9 +2505,13 @@
await generateBriefing();
} else if (currentDocType === 'report') {
await generateReport();
} else if (currentDocType.startsWith('template_')) {
// ⭐ 사용자 템플릿: 보고서와 동일하게 처리
await generateReport();
}
}
// ===== 기획서 생성 (기존 로직) =====
async function generateBriefing() {
if (!inputContent && !folderPath && referenceLinks.length === 0) {
@@ -2083,19 +2631,27 @@
updateStep(i, 'done');
}
let instruction = '';
if (currentDocType === 'report') {
instruction = document.getElementById('reportInstructionInput').value;
} else if (currentDocType.startsWith('template_')) {
instruction = document.getElementById('templateInstructionInput').value;
}
const response = await fetch('/generate-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: inputContent, // HTML 내용 추가
content: inputContent,
folder_path: folderPath,
cover: document.getElementById('reportCover').checked,
toc: document.getElementById('reportToc').checked,
divider: document.getElementById('reportDivider').checked,
instruction: document.getElementById('reportInstructionInput').value
template_id: currentDocType.startsWith('template_') ? currentDocType.replace('template_', '') : null,
cover: currentDocType === 'report' ? document.getElementById('reportCover').checked : false,
toc: currentDocType === 'report' ? document.getElementById('reportToc').checked : false,
divider: currentDocType === 'report' ? document.getElementById('reportDivider').checked : false,
instruction: instruction
})
});
const data = await response.json();
if (data.error) {
@@ -2333,6 +2889,33 @@
preview.classList.remove('show');
});
});
// 드롭존 이벤트 연결
const dropzone = document.getElementById('templateDropzone');
if (dropzone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, () => dropzone.classList.add('dragover'));
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, () => dropzone.classList.remove('dragover'));
});
dropzone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
handleTemplateFileSelect(files[0]);
}
});
}
// 템플릿 목록 로드
loadUserTemplates();
});
// Enter 키로 피드백 제출
@@ -2352,5 +2935,41 @@
<button class="ai-edit-btn" onclick="submitAiEdit()">✨ 수정하기</button>
</div>
<script src="/static/js/editor.js"></script>
<!-- 템플릿 추가 모달 -->
<div class="template-modal" id="templateModal">
<div class="template-modal-content">
<div class="template-modal-header">
<div class="template-modal-title">📁 템플릿 추가</div>
<button class="template-modal-close" onclick="closeTemplateModal()"></button>
</div>
<div class="template-input-group">
<label class="template-input-label">템플릿 이름</label>
<input type="text" class="template-name-input" id="templateNameInput"
placeholder="예: 걸포4지구 교통영향평가">
</div>
<div class="template-input-group">
<label class="template-input-label">템플릿 파일</label>
<div class="template-dropzone" id="templateDropzone" onclick="document.getElementById('templateFileInput').click()">
<div class="template-dropzone-icon">📄</div>
<div class="template-dropzone-text">파일을 드래그하거나 클릭하여 선택</div>
<div class="template-dropzone-hint">HWPX, HWP, PDF 지원</div>
</div>
<input type="file" id="templateFileInput" accept=".hwpx,.hwp,.pdf" style="display:none" onchange="handleTemplateFile(this)">
<div class="template-dropzone-file" id="templateFileInfo">
<span class="filename" id="templateFileName"></span>
<span class="remove" onclick="removeTemplateFile()"></span>
</div>
</div>
<button class="template-submit-btn" id="templateSubmitBtn" onclick="submitTemplate()" disabled>
<span class="spinner" id="templateSpinner"></span>
<span id="templateSubmitText">✨ 분석 및 추가</span>
</button>
</div>
</div>
</body>
</html>