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