From 5129ee69d44f8607452c37690366e5cc49e0c1d3 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 20 Feb 2026 11:43:44 +0900 Subject: [PATCH] =?UTF-8?q?v6:HWPX=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=C2=B7=EC=A0=80=EC=9E=A5=C2=B7=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=5F20260128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 113 +-- app.py | 65 +- converters/pipeline/router.py | 44 +- handlers/report/processor.py | 9 + handlers/template/__init__.py | 3 + handlers/template/processor.py | 625 ++++++++++++++++ .../template/prompts/analyze_template.txt | 28 + templates/index.html | 689 +++++++++++++++++- 8 files changed, 1482 insertions(+), 94 deletions(-) create mode 100644 handlers/template/__init__.py create mode 100644 handlers/template/processor.py create mode 100644 handlers/template/prompts/analyze_template.txt diff --git a/README.md b/README.md index e401f77..d221f83 100644 --- a/README.md +++ b/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/` | +| 저장소 | — | `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 템플릿 분석·저장·관리** | --- diff --git a/app.py b/app.py index 9afcbb3..178a54d 100644 --- a/app.py +++ b/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/', 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)) diff --git a/converters/pipeline/router.py b/converters/pipeline/router.py index ef41136..9a396cc 100644 --- a/converters/pipeline/router.py +++ b/converters/pipeline/router.py @@ -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 주입 + - 태그 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태그 뒤에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없으면 맨 앞에 추가 + else: + return f'\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 \ No newline at end of file diff --git a/handlers/report/processor.py b/handlers/report/processor.py index eeaa2f7..19def30 100644 --- a/handlers/report/processor.py +++ b/handlers/report/processor.py @@ -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) diff --git a/handlers/template/__init__.py b/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/handlers/template/processor.py b/handlers/template/processor.py new file mode 100644 index 0000000..f8cb6d1 --- /dev/null +++ b/handlers/template/processor.py @@ -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; } +""" \ No newline at end of file diff --git a/handlers/template/prompts/analyze_template.txt b/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 문서 템플릿 분석 전문가입니다. + +주어진 HWPX/HWP/PDF 템플릿의 구조를 분석하여 다음 정보를 추출해주세요: + +1. 제목 스타일 (H1~H6) + - 폰트명, 크기(pt), 굵기, 색상 + - 정렬 방식 + - 번호 체계 (제1장, 1.1, 가. 등) + +2. 본문 스타일 + - 기본 폰트, 크기, 줄간격 + - 들여쓰기 + +3. 표 스타일 + - 헤더 배경색 + - 테두리 스타일 (선 두께, 색상) + - 이중선 사용 여부 + +4. 그림/캡션 스타일 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페이지 구성 + - 표지 유무 + - 목차 유무 + - 머리말/꼬리말 + +분석 결과를 JSON 형식으로 출력해주세요. \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index e496d71..6a61fba 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@ - + + + - + @@ -1411,7 +1746,15 @@ - + + + +
요청사항
@@ -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) => ` +
+ + 📑 ${tpl.name} + + +
+
+
+
+
+
+
+
+
${tpl.name}
+
사용자 정의 템플릿
+
+ ${(tpl.features || []).map(f => `
${f}
`).join('')} +
+
+
+ `).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 @@
+ + +
+
+
+
📁 템플릿 추가
+ +
+ +
+ + +
+ +
+ +
+
📄
+
파일을 드래그하거나 클릭하여 선택
+
HWPX, HWP, PDF 지원
+
+ +
+ + +
+
+ + +
+
+ \ No newline at end of file