From 17e639ed40d1209f620e2780aec47ccf24ee5c34 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 20 Feb 2026 11:34:02 +0900 Subject: [PATCH] =?UTF-8?q?v4:=EC=BD=94=EB=93=9C=EB=AA=A8=EB=93=88?= =?UTF-8?q?=ED=99=94=5F20260123?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 7 + README.md | 385 +++++--- api_config.py | 37 +- app.py | 541 +++-------- converters/dkdl.py | 37 + converters/html_to_hwp.py | 462 ++++++++- converters/html_to_hwp_briefing.py | 935 ++++++++++--------- converters/hwp_style_mapping.py | 434 +++++++++ converters/hwpx_generator.py | 431 +++++++++ converters/style_analyzer.py | 935 +++++++++++++++++++ handlers/__init__.py | 5 + handlers/briefing/__init__.py | 5 + handlers/briefing/processor.py | 279 ++++++ handlers/briefing/prompts/step1_5_plan.txt | 104 +++ handlers/briefing/prompts/step1_extract.txt | 122 +++ handlers/briefing/prompts/step2_generate.txt | 440 +++++++++ handlers/common.py | 84 ++ handlers/report/__init__.py | 5 + handlers/report/processor.py | 152 +++ handlers/report/prompts/refine_selection.txt | 104 +++ railway.json | 13 - static/css/editor.css | 94 +- static/js/editor.js | 712 +++++++++++++- templates/index.html | 143 ++- 24 files changed, 5412 insertions(+), 1054 deletions(-) create mode 100644 .env.sample create mode 100644 converters/dkdl.py create mode 100644 converters/hwp_style_mapping.py create mode 100644 converters/hwpx_generator.py create mode 100644 converters/style_analyzer.py create mode 100644 handlers/__init__.py create mode 100644 handlers/briefing/__init__.py create mode 100644 handlers/briefing/processor.py create mode 100644 handlers/briefing/prompts/step1_5_plan.txt create mode 100644 handlers/briefing/prompts/step1_extract.txt create mode 100644 handlers/briefing/prompts/step2_generate.txt create mode 100644 handlers/common.py create mode 100644 handlers/report/__init__.py create mode 100644 handlers/report/processor.py create mode 100644 handlers/report/prompts/refine_selection.txt delete mode 100644 railway.json diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ +# 글벗 API Keys +# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요 +# cp .env.sample .env + +CLAUDE_API_KEY=여기에_키값_입력 +GEMINI_API_KEY=여기에_키값_입력 +GPT_API_KEY=여기에_키값_입력 diff --git a/README.md b/README.md index aeb961a..6634aca 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,308 @@ -# 글벗 Light v3.0 +# 글벗 (Geulbeot) v4.0 -AI 기반 문서 자동화 시스템 — 9단계 RAG 파이프라인 + 웹 편집기 + HWP 변환 +**AI 기반 문서 자동화 시스템 — GPD 총괄기획실** -## 🎯 개요 +다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤 +선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다. +생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다. -다양한 형식의 입력 문서(PDF, HWP, 이미지, 동영상 등)를 분석하여 표준 HTML 보고서를 자동 생성하고, 웹 편집기로 수정한 뒤 HTML/PDF/HWP로 출력하는 시스템입니다. +--- -## 📁 프로젝트 구조 +## 🏗 아키텍처 (Architecture) + +### 핵심 흐름 ``` -geulbeot_3rd/ -├── app.py # Flask 메인 서버 (579줄) -├── api_config.py # API 키 로더 -├── converters/ -│ ├── pipeline/ # 9단계 RAG 파이프라인 -│ │ ├── router.py # 분기 판단 (긴/짧은 문서) -│ │ ├── step1_convert.py # 파일→PDF 변환 (783줄) -│ │ ├── step2_extract.py # 텍스트/이미지 추출 (788줄) -│ │ ├── step3_domain.py # 도메인 분석 (265줄) -│ │ ├── step4_chunk.py # 청킹 (356줄) -│ │ ├── step5_rag.py # RAG 임베딩 (141줄) -│ │ ├── step6_corpus.py # 코퍼스 생성 (231줄) -│ │ ├── step7_index.py # 인덱싱 + 목차 생성 (504줄) -│ │ ├── step8_content.py # 콘텐츠 생성 (1020줄) -│ │ └── step9_html.py # HTML 생성 (1248줄) -│ ├── html_to_hwp.py # 보고서→HWP 변환 (572줄) -│ └── html_to_hwp_briefing.py # 기획서→HWP 변환 (572줄) -├── prompts/ -│ ├── step1_extract.txt # 구조 추출 프롬프트 -│ ├── step1_5_plan.txt # 배치 계획 프롬프트 -│ └── step2_generate.txt # HTML 생성 프롬프트 -├── static/ -│ ├── css/editor.css # 편집기 스타일 -│ └── js/editor.js # 편집기 기능 -├── templates/ -│ ├── index.html # 메인 UI -│ └── hwp_guide.html # HWP 변환 가이드 -├── output/assets/ # 이미지 에셋 -├── requirements.txt -├── Procfile -└── railway.json +자료 입력 (파일/폴더) + │ + ▼ +RAG 파이프라인 (9단계) ─── 공통 처리 + │ + ▼ +문서 유형 선택 + ├─ 기획서 (기본) + ├─ 보고서 (기본) + ├─ 발표자료 (기본) + └─ 사용자 등록 (확장 가능) + │ + ▼ +글벗 표준 HTML 생성 + │ + ▼ +웹 편집기 (수기 편집 / AI 편집) + │ + ▼ +출력 (HTML / PDF / HWP) ``` -## ⚙️ 프로세스 플로우 +### 1. Backend (Python Flask) + +- **Language**: Python 3.13 +- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅 +- **AI**: + - Claude API (Anthropic) — 기획서 생성, AI 편집 + - OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출 + - Gemini API — 보고서 콘텐츠·HTML 생성 +- **Features**: + - 자료 입력 → 9단계 RAG 파이프라인 (파일 변환 → 추출 → 도메인 분석 → 청킹 → 임베딩 → 코퍼스 → 인덱싱 → 콘텐츠 생성 → HTML 조립) + - 문서 유형별 생성: 기획서 (Claude 3단계), 보고서 (Gemini 2단계) + - AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - HWP 변환: HTML 스타일 분석 → 역할 매핑 → HWPX 생성 + - PDF 변환: WeasyPrint 기반 + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 편집기 — 브라우저에서 생성된 문서 직접 수정 + - 페이지 넘김·들여쓰기·정렬 등 서식 도구 + - HTML / PDF / HWP 다운로드 + +### 3. 변환 엔진 (Converters) + +- **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립 +- **분량 자동 판단**: 5,000자 기준 — 긴 문서는 전체 파이프라인, 짧은 문서는 축약 파이프라인 +- **HWP 변환**: pyhwpx 기반 + v4에서 추가된 스타일 분석기·HWPX 생성기·매핑 모듈 + +### 4. 주요 시나리오 (Core Scenarios) + +1. **기획서 생성**: 텍스트 또는 파일을 입력하면, RAG 분석 후 Claude API가 구조 추출 → 페이지 배치 계획 → 글벗 표준 HTML 기획서를 생성. 1~N페이지 옵션 지원 +2. **보고서 생성**: 폴더 경로의 자료들을 RAG 파이프라인으로 분석하고, Gemini API가 섹션별 콘텐츠 초안 → 표지·목차·간지·별첨이 포함된 다페이지 HTML 보고서를 생성 +3. **AI 편집**: 생성된 문서를 웹 편집기에서 확인 후, "이 부분을 표로 바꿔줘" 같은 피드백으로 전체 또는 선택 부분을 AI가 수정 +4. **HWP 내보내기**: 글벗 HTML을 스타일 분석기가 요소별 역할을 분류하고, HWP 스타일로 매핑하여 서식이 유지된 HWP 파일로 변환 + +### 프로세스 플로우 + +#### RAG 파이프라인 (공통) ```mermaid -flowchart TB - subgraph INPUT["📥 Input"] - direction TB - A["🗂️ 문서 입력\nPDF, HWP, 이미지, 동영상"] --> B["step1: 파일 변환\nPDF 통일"] - B --> C["step2: 텍스트/이미지 추출\n(GPT API)"] - C --> D{"router.py\n분량 판단\n5000자 기준"} - D -->|"긴 문서"| E["step3: 도메인 분석"] - D -->|"짧은 문서"| H - E --> F["step4: 청킹"] - F --> G["step5: RAG 임베딩"] - G --> H["step6: 코퍼스 생성"] - H --> I["step7: 인덱싱 + 목차 생성\n(GPT API)"] - end +flowchart TD + classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d + classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333 + classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724 + classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px - subgraph OUTPUT["📤 Output"] - direction TB - I --> J["step8: 콘텐츠 생성\n(Gemini API)"] - J --> K["step9: HTML 생성\n(Gemini API)"] - end + A[/"📂 자료 입력 (파일/폴더)"/]:::process + B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process + C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt + D{"분량 판단\n5,000자 기준"}:::decision - subgraph EDIT["✏️ Edit"] - direction TB - K --> M["웹 편집기\neditor.js"] - K --> N["AI 편집\n/refine (Claude API)"] - end + E["step3: 도메인 분석"]:::process + F["step4: 의미 단위 청킹"]:::process + G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt + H["step6: 코퍼스 생성"]:::process - subgraph EXPORT["📦 Export"] - direction TB - M & N --> P["HTML / PDF"] - M & N --> Q["HWP 변환\nhtml_to_hwp.py"] - end + I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt + J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd + + A --> B --> C --> D + D -->|"≥ 5,000자"| E --> F --> G --> H --> I + D -->|"< 5,000자"| I + I --> J ``` -## 🌐 API 라우트 +#### 문서 유형별 생성 → 편집 → 출력 -| 라우트 | 메서드 | 기능 | -|--------|--------|------| -| `/` | GET | 메인 페이지 | -| `/generate` | POST | 기획서 생성 (1단계→1.5단계→2단계) | -| `/generate-report` | POST | 보고서 생성 (9단계 파이프라인) | -| `/refine` | POST | AI 전체 수정 | -| `/refine-selection` | POST | AI 부분 수정 | -| `/export-hwp` | POST | HWP 변환 | -| `/download/html` | POST | HTML 다운로드 | -| `/download/pdf` | POST | PDF 다운로드 | -| `/health` | GET | 서버 상태 확인 | +```mermaid +flowchart TD + classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333 + classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404 + classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f + classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100 + classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c + classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px + classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999 -## 🤖 활용 AI + A(["📋 RAG 분석 결과"]):::startEnd + B{"문서 유형 선택"}:::decision -| 단계 | AI | 역할 | -|------|-----|------| -| step2 (추출) | GPT | PDF에서 텍스트/이미지 메타데이터 추출 | -| step7 (목차) | GPT | 인덱싱 및 목차 자동 생성 | -| step8 (콘텐츠) | Gemini | 섹션별 본문 초안 생성 | -| step9 (HTML) | Gemini | 최종 HTML 보고서 생성 | -| 기획서 생성 | Claude | HTML 구조 추출 + 변환 | -| AI 편집 | Claude | 피드백 반영 수정 | + C["기획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude + D["보고서 생성\n콘텐츠→HTML 조립\n⚡ Gemini API"]:::aiGemini + E["발표자료 생성\n예정"]:::planned + F["사용자 등록 유형\n확장 가능"]:::planned + + G["글벗 표준 HTML\nA4·Navy·Noto Sans KR"]:::startEnd + + H{"편집 방식"}:::decision + I["웹 편집기\n수기 편집"]:::editStyle + J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude + + K{"출력 형식"}:::decision + L["HTML / PDF"]:::exportStyle + M["HWP 변환\n스타일 분석→매핑→생성"]:::exportStyle + N["PPT 변환\n예정"]:::planned + O(["✅ 최종 산출물"]):::startEnd + + A --> B + B -->|"기획서"| C --> G + B -->|"보고서"| D --> G + B -->|"발표자료"| E -.-> G + B -->|"확장"| F -.-> G + + G --> H + H -->|"수기"| I --> K + H -->|"AI"| J --> K + K -->|"웹/인쇄"| L --> O + K -->|"HWP"| M --> O + K -->|"PPT"| N -.-> O +``` + +--- + +## 🔄 v3 → v4 변경사항 + +| 영역 | v3 | v4 | +|------|------|------| +| app.py | 모든 로직 포함 (579줄) | 라우팅 전담 (291줄) | +| 비즈니스 로직 | app.py 내부 | handlers/ 패키지로 분리 (briefing + report + common) | +| 프롬프트 | prompts/ 공용 1곳 | handlers/*/prompts/ 모듈별 분리 | +| HWP 변환 | pyhwpx 직접 변환만 | + 스타일 분석기·HWPX 생성기·매핑 모듈 추가 | +| 환경설정 | 없음 | .env + api_config.py (python-dotenv) | + +--- + +## 🗺 상태 및 로드맵 (Status & Roadmap) + +- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현 · 현재 버전) +- **Phase 2**: 문서 생성 — 기획서·보고서 AI 생성 + 글벗 표준 HTML 양식 (🔧 기본 구현) +- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현) +- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석기, HWPX 생성기, 역할→HWP 매핑 (🔧 기본 구현) +- **Phase 5**: 문서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) +- **Phase 6**: HWPX 템플릿 관리 — 파싱·시맨틱 매핑·스타일 추출·표 매칭·콘텐츠 주입 (예정) +- **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, AI 편집 개선, 도메인 선택기 (예정) +- **Phase 8**: 백엔드 재구조화 + 배포 — 패키지 정리, API 키 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하기 (Getting Started) + +### 사전 요구사항 + +- Python 3.10+ +- Claude API 키 (Anthropic) — 기획서 생성, AI 편집 +- OpenAI API 키 — RAG 파이프라인 +- Gemini API 키 — 보고서 콘텐츠·HTML 생성 +- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수) + +### 환경 설정 + +```bash +# 저장소 클론 및 설정 +git clone http://[Gitea주소]/kei/geulbeot-v4.git +cd geulbeot-v4 + +# 가상환경 +python -m venv venv +venv\Scripts\activate # Windows + +# 패키지 설치 +pip install -r requirements.txt + +# 환경변수 +cp .env.sample .env +# .env 파일을 열어 실제 API 키 입력 +``` + +### .env 작성 + +```env +CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집 +GPT_API_KEY=sk-proj-your-key-here # RAG 파이프라인 +GEMINI_API_KEY=AIzaSy-your-key-here # 보고서 콘텐츠 생성 +``` + +### 실행 + +```bash +python app.py +# → http://localhost:5000 접속 +``` + +--- + +## 📂 프로젝트 구조 + +``` +geulbeot_4th/ +├── app.py # Flask 웹 서버 — API 라우팅 +├── api_config.py # .env 환경변수 로더 +│ +├── handlers/ # 비즈니스 로직 +│ ├── common.py # Claude API 호출, JSON/HTML 추출 +│ ├── briefing/ # 기획서 처리 +│ │ ├── processor.py # 구조추출 → 배치계획 → HTML 생성 +│ │ └── prompts/ # 각 단계별 AI 프롬프트 +│ └── report/ # 보고서 처리 +│ ├── processor.py # RAG 파이프라인 연동 + AI 편집 +│ └── prompts/ +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9단계 RAG 파이프라인 +│ │ ├── router.py # 분량 판단 (5,000자 기준) +│ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→코퍼스→인덱싱→콘텐츠→HTML +│ ├── style_analyzer.py # HTML 요소 역할 분류 (v4 신규) +│ ├── hwpx_generator.py # HWPX 파일 직접 생성 (v4 신규) +│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑 (v4 신규) +│ ├── html_to_hwp.py # 보고서 → HWP 변환 +│ └── html_to_hwp_briefing.py # 기획서 → HWP 변환 +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 편집기 +│ └── css/editor.css # 편집기 스타일 +├── templates/ +│ ├── index.html # 메인 UI +│ └── hwp_guide.html # HWP 변환 가이드 +│ +├── .env / .env.sample # API 키 관리 +├── .gitignore +├── requirements.txt +├── Procfile # 배포 설정 (Gunicorn) +└── README.md +``` + +--- ## 🎨 글벗 표준 HTML 양식 -- A4 인쇄 최적화 (210mm × 297mm) -- Noto Sans KR 폰트 -- Navy 계열 색상 (#1a365d 기본) -- 구성: page-header, lead-box, section, data-table, bottom-box, footer +| 항목 | 사양 | +|------|------| +| 용지 | A4 인쇄 최적화 (210mm × 297mm) | +| 폰트 | Noto Sans KR (Google Fonts) | +| 색상 | Navy 계열 (#1a365d 기본) | +| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer | +| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 | -## 🖥️ 로컬 실행 +--- -```bash -pip install -r requirements.txt -python app.py -``` +## ⚠️ 알려진 제한사항 -http://localhost:5000 접속 +- 로컬 경로 하드코딩: `D:\for python\...` 잔존 (router.py, app.py) +- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완) +- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수 +- 문서 유형: 기획서·보고서만 구현, 발표자료·사용자 등록 유형 미구현 +- 레거시 잔존: prompts/ 디렉토리, dkdl.py 테스트 코드 -## 🔑 API 키 설정 +--- -`api_keys.json` 파일을 프로젝트 루트에 생성: +## 📊 코드 규모 -```json -{ - "CLAUDE_API_KEY": "sk-ant-...", - "GPT_API_KEY": "sk-proj-...", - "GEMINI_API_KEY": "AIzaSy..." -} -``` +| 영역 | 줄 수 | +|------|-------| +| Python 전체 | 9,780 | +| 프론트엔드 (JS + CSS + HTML) | 3,859 | +| **합계** | **~13,600** | -> ⚠️ `api_keys.json`은 `.gitignore`에 포함되어 Git에 올라가지 않습니다. +--- -## 📝 v1 → v3 변경 이력 +## 📝 버전 이력 -| 버전 | 변경 내용 | +| 버전 | 핵심 변경 | |------|----------| -| v1 | Flask + Claude API 기획서 생성기 (12파일, 422줄) | -| v2 | 웹 편집기 추가 (editor.js, editor.css) | -| v3 | 9단계 RAG 파이프라인 + HWP 변환 추가 (40파일+, 6000줄+) | +| v1 | Flask + Claude API 기획서 생성기 | +| v2 | 웹 편집기 추가 | +| v3 | 9단계 RAG 파이프라인 + HWP 변환 | +| **v4** | **코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기** | + +--- ## 📝 라이선스 diff --git a/api_config.py b/api_config.py index 8efbe7e..e2b3524 100644 --- a/api_config.py +++ b/api_config.py @@ -1,17 +1,30 @@ -"""API 키 관리 - api_keys.json에서 읽기""" -import json +"""API 키 관리 - .env 파일에서 읽기""" +import os from pathlib import Path def load_api_keys(): - """프로젝트 폴더의 api_keys.json에서 API 키 로딩""" - search_path = Path(__file__).resolve().parent - for _ in range(5): - key_file = search_path / 'api_keys.json' - if key_file.exists(): - with open(key_file, 'r', encoding='utf-8') as f: - return json.load(f) - search_path = search_path.parent - print("warning: api_keys.json not found") - return {} + """프로젝트 폴더의 .env에서 API 키 로딩""" + # python-dotenv 있으면 사용 + try: + from dotenv import load_dotenv + env_path = Path(__file__).resolve().parent / '.env' + load_dotenv(env_path) + except ImportError: + # python-dotenv 없으면 수동 파싱 + env_path = Path(__file__).resolve().parent / '.env' + if env_path.exists(): + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, _, value = line.partition('=') + os.environ.setdefault(key.strip(), value.strip()) + + return { + 'CLAUDE_API_KEY': os.getenv('CLAUDE_API_KEY', ''), + 'GPT_API_KEY': os.getenv('GPT_API_KEY', ''), + 'GEMINI_API_KEY': os.getenv('GEMINI_API_KEY', ''), + 'PERPLEXITY_API_KEY': os.getenv('PERPLEXITY_API_KEY', ''), + } API_KEYS = load_api_keys() diff --git a/app.py b/app.py index 27c2e80..e2de47f 100644 --- a/app.py +++ b/app.py @@ -1,174 +1,31 @@ # -*- coding: utf-8 -*- """ 글벗 Light v2.0 -2단계 API 변환 + 대화형 피드백 시스템 - -Flask + Claude API + Railway +Flask 라우팅 + 공통 기능 """ import os -import json -import anthropic -from flask import Flask, render_template, request, jsonify, Response, session -from datetime import datetime import io -import re -from flask import send_file -from datetime import datetime import tempfile -from converters.pipeline.router import process_document -from api_config import API_KEYS +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response, session, send_file +# 문서 유형별 프로세서 +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2') -# Claude API 클라이언트 -client = anthropic.Anthropic(api_key=API_KEYS.get('CLAUDE_API_KEY', '')) +# 프로세서 인스턴스 +processors = { + 'briefing': BriefingProcessor(), + 'report': ReportProcessor() +} -# ============== 프롬프트 로드 ============== - -def load_prompt(filename): - """프롬프트 파일 로드""" - prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename) - try: - with open(prompt_path, 'r', encoding='utf-8') as f: - return f.read() - except FileNotFoundError: - return None - - -def get_step1_prompt(): - """1단계: 구조 추출 프롬프트""" - prompt = load_prompt('step1_extract.txt') - if prompt: - return prompt - # 기본 프롬프트 (파일 없을 경우) - return """HTML 문서를 분석하여 JSON 구조로 추출하세요. -원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요.""" - - -def get_step2_prompt(): - """2단계: HTML 생성 프롬프트""" - prompt = load_prompt('step2_generate.txt') - if prompt: - return prompt - # 기본 프롬프트 (파일 없을 경우) - return """JSON 구조를 각인된 양식의 HTML로 변환하세요. -Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요.""" - -def get_step1_5_prompt(): - """1.5단계: 배치 계획 프롬프트""" - prompt = load_prompt('step1_5_plan.txt') - if prompt: - return prompt - return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요.""" - -def get_refine_prompt(): - """피드백 반영 프롬프트""" - return """당신은 HTML 보고서 수정 전문가입니다. - -사용자의 피드백을 반영하여 현재 HTML을 수정합니다. - -## 규칙 -1. 피드백에서 언급된 부분만 정확히 수정 -2. 나머지 구조와 스타일은 그대로 유지 -3. 완전한 HTML 문서로 출력 ( ~ ) -4. 코드 블록(```) 없이 순수 HTML만 출력 - -## 현재 HTML -{current_html} - -## 사용자 피드백 -{feedback} - -위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요.""" - -# ============== API 호출 함수 ============== - -def call_claude(system_prompt, user_message, max_tokens=8000): - """Claude API 호출""" - response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=max_tokens, - system=system_prompt, - messages=[{"role": "user", "content": user_message}] - ) - return response.content[0].text - - -def extract_json(text): - """텍스트에서 JSON 추출""" - # 코드 블록 제거 - if '```json' in text: - text = text.split('```json')[1].split('```')[0] - elif '```' in text: - text = text.split('```')[1].split('```')[0] - - text = text.strip() - - # JSON 파싱 시도 - try: - return json.loads(text) - except json.JSONDecodeError: - # JSON 부분만 추출 시도 - match = re.search(r'\{[\s\S]*\}', text) - if match: - try: - return json.loads(match.group()) - except: - pass - return None - - -def extract_html(text): - """텍스트에서 HTML 추출""" - # 코드 블록 제거 - if '```html' in text: - text = text.split('```html')[1].split('```')[0] - elif '```' in text: - parts = text.split('```') - if len(parts) >= 2: - text = parts[1] - - text = text.strip() - - # )', text, re.IGNORECASE) - if match: - text = match.group(1) - - return text - -def content_too_long(html, max_sections_per_page=4): - """페이지당 콘텐츠 양 체크""" - from bs4 import BeautifulSoup - soup = BeautifulSoup(html, 'html.parser') - - sheets = soup.find_all('div', class_='sheet') - for sheet in sheets: - sections = sheet.find_all('div', class_='section') - if len(sections) > max_sections_per_page: - return True - - # 리스트 항목 체크 - all_li = sheet.find_all('li') - if len(all_li) > 12: - return True - - # 프로세스 스텝 체크 - steps = sheet.find_all('div', class_='process-step') - if len(steps) > 6: - return True - - return False - - -# ============== 라우트 ============== +# ============== 메인 페이지 ============== @app.route('/') def index(): @@ -176,263 +33,107 @@ def index(): return render_template('index.html') +# ============== 생성 API ============== + @app.route('/generate', methods=['POST']) def generate(): - """보고서 생성 API (2단계 처리)""" + """기획서 생성 API""" try: - # 입력 받기 content = "" - if 'file' in request.files and request.files['file'].filename: file = request.files['file'] content = file.read().decode('utf-8') elif 'content' in request.form: content = request.form.get('content', '') - if not content.strip(): - return jsonify({'error': '내용을 입력하거나 파일을 업로드해주세요.'}), 400 - - # 옵션 - page_option = request.form.get('page_option', '1') - department = request.form.get('department', '총괄기획실') - additional_prompt = request.form.get('additional_prompt', '') - - # ============== 1단계: 구조 추출 ============== - step1_prompt = get_step1_prompt() - step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요. - -## 원본 HTML -{content} - ---- -위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력.""" - - step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) - structure_json = extract_json(step1_response) - - if not structure_json: - # JSON 추출 실패 시 원본 그대로 전달 - structure_json = {"raw_content": content, "parse_failed": True} - - -# ============== 1.5단계: 배치 계획 ============== - step1_5_prompt = get_step1_5_prompt() - step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요. - -## 문서 구조 (JSON) -{json.dumps(structure_json, ensure_ascii=False, indent=2)} - -## 페이지 수 -{page_option}페이지 - ---- -배치 계획 JSON만 출력하세요. 설명 없이 JSON만.""" - - step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) - page_plan = extract_json(step1_5_response) - - if not page_plan: - page_plan = {"page_plan": {}, "parse_failed": True} - - - - # ============== 2단계: HTML 생성 ============== - page_instructions = { - '1': '1페이지로 핵심 내용만 압축하여 작성하세요. 내용이 넘치면 텍스트를 줄이거나 줄간격을 조정하세요.', - '2': '2페이지로 작성하세요. 1페이지는 본문(개요, 핵심 내용), 2페이지는 [첨부]로 시작하는 상세 내용입니다.', - 'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부 1], [첨부 2] 형태로 분할합니다.' + options = { + 'page_option': request.form.get('page_option', '1'), + 'department': request.form.get('department', '총괄기획실'), + 'instruction': request.form.get('instruction', '') } - step2_prompt = get_step2_prompt() - step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요. - -## 배치 계획 -{json.dumps(page_plan, ensure_ascii=False, indent=2)} - -## 문서 구조 (JSON) -{json.dumps(structure_json, ensure_ascii=False, indent=2)} - -## 페이지 옵션 -{page_instructions.get(page_option, page_instructions['1'])} - -## 부서명 -{department} - -## 추가 요청사항 -{additional_prompt if additional_prompt else '없음'} - ---- -위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요. -코드 블록(```) 없이 부터 까지 순수 HTML만 출력.""" - - step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) - html_content = extract_html(step2_response) + result = processors['briefing'].generate(content, options) - # 후처리 검증: 콘텐츠가 너무 많으면 압축 재요청 - if content_too_long(html_content): - compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다. -각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요. -텍스트를 줄이거나 덜 중요한 내용은 생략하세요. - -{html_content} - -코드 블록 없이 압축된 완전한 HTML만 출력하세요.""" - - compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) - html_content = extract_html(compress_response) + if 'error' in result: + return jsonify(result), 400 if 'trace' not in result else 500 + return jsonify(result) - # 세션에 저장 (피드백용) - session['original_html'] = content - session['current_html'] = html_content - session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) - session['conversation'] = [] - - return jsonify({ - 'success': True, - 'html': html_content, - 'structure': structure_json - }) - - except anthropic.APIError as e: - return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500 except Exception as e: import traceback - return jsonify({'error': f'서버 오류: {str(e)}', 'trace': traceback.format_exc()}), 500 + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 +@app.route('/generate-report', methods=['POST']) +def generate_report(): + """보고서 생성 API""" + try: + data = request.get_json() or {} + content = data.get('content', '') + + options = { + 'folder_path': data.get('folder_path', ''), + 'cover': data.get('cover', False), + 'toc': data.get('toc', False), + 'divider': data.get('divider', False), + 'instruction': data.get('instruction', '') + } + + result = processors['report'].generate(content, options) + + if 'error' in result: + return jsonify(result), 500 + return jsonify(result) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +# ============== 수정 API ============== + @app.route('/refine', methods=['POST']) def refine(): - """피드백 반영 API (대화형)""" + """피드백 반영 API""" try: feedback = request.json.get('feedback', '') current_html = request.json.get('current_html', '') or session.get('current_html', '') - - if not feedback.strip(): - return jsonify({'error': '피드백 내용을 입력해주세요.'}), 400 - - if not current_html: - return jsonify({'error': '수정할 HTML이 없습니다. 먼저 변환을 실행해주세요.'}), 400 - - # 원본 HTML도 컨텍스트에 포함 original_html = session.get('original_html', '') + doc_type = request.json.get('doc_type', 'briefing') - # 피드백 반영 프롬프트 - refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다. - -사용자의 피드백을 반영하여 현재 HTML을 수정합니다. - -## 규칙 -1. 피드백에서 언급된 부분만 정확히 수정 -2. 나머지 구조와 스타일은 그대로 유지 -3. 완전한 HTML 문서로 출력 ( ~ ) -4. 코드 블록(```) 없이 순수 HTML만 출력 -5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능 - -## 원본 HTML (참고용) -{original_html[:3000] if original_html else '없음'}... - -## 현재 HTML -{current_html} - -## 사용자 피드백 -{feedback} - ---- -위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요.""" - - response = call_claude("", refine_prompt, max_tokens=8000) - new_html = extract_html(response) + processor = processors.get(doc_type, processors['briefing']) + result = processor.refine(feedback, current_html, original_html) - # 세션 업데이트 - session['current_html'] = new_html + if 'error' in result: + return jsonify(result), 400 + return jsonify(result) - # 대화 히스토리 저장 - conversation = session.get('conversation', []) - conversation.append({'role': 'user', 'content': feedback}) - conversation.append({'role': 'assistant', 'content': '수정 완료'}) - session['conversation'] = conversation - - return jsonify({ - 'success': True, - 'html': new_html - }) - - except anthropic.APIError as e: - return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500 except Exception as e: - return jsonify({'error': f'서버 오류: {str(e)}'}), 500 + return jsonify({'error': str(e)}), 500 @app.route('/refine-selection', methods=['POST']) def refine_selection(): - """선택된 부분만 수정""" + """선택 부분 수정 API""" try: data = request.json current_html = data.get('current_html', '') selected_text = data.get('selected_text', '') user_request = data.get('request', '') + doc_type = data.get('doc_type', 'briefing') - if not current_html or not selected_text or not user_request: - return jsonify({'error': '필수 데이터가 없습니다.'}), 400 + processor = processors.get(doc_type, processors['briefing']) + result = processor.refine_selection(current_html, selected_text, user_request) - # Claude API 호출 - message = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=8000, - messages=[{ - "role": "user", -"content" : f"""HTML 문서에서 지정된 부분만 수정해주세요. - -## 전체 문서 (컨텍스트 파악용) -{current_html} - -## 수정 대상 텍스트 -"{selected_text}" - -## 수정 요청 -{user_request} - -## 규칙 -1. 요청을 분석하여 수정 유형을 판단: - - TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등) - - STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등) - -2. 반드시 다음 형식으로만 출력: - -TYPE: (TEXT 또는 STRUCTURE) -CONTENT: -(수정된 내용) - -3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이) -4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지) -5. 개조식 문체 유지 (~임, ~함, ~필요) -""" - }] - ) - - result = message.content[0].text - result = result.replace('```html', '').replace('```', '').strip() - - # TYPE과 CONTENT 파싱 - edit_type = 'TEXT' - content = result - - if 'TYPE:' in result and 'CONTENT:' in result: - type_line = result.split('CONTENT:')[0] - if 'STRUCTURE' in type_line: - edit_type = 'STRUCTURE' - content = result.split('CONTENT:')[1].strip() - - return jsonify({ - 'success': True, - 'type': edit_type, - 'html': content - }) + if 'error' in result: + return jsonify(result), 400 + return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 +# ============== 다운로드 API ============== + @app.route('/download/html', methods=['POST']) def download_html(): """HTML 파일 다운로드""" @@ -473,48 +174,12 @@ def download_pdf(): headers={'Content-Disposition': f'attachment; filename={filename}'} ) except ImportError: - return jsonify({'error': 'PDF 변환 미지원. HTML 다운로드 후 브라우저에서 인쇄하세요.'}), 501 + return jsonify({'error': 'PDF 변환 미지원'}), 501 except Exception as e: return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500 -@app.route('/hwp-script') -def hwp_script(): - """HWP 변환 스크립트 안내""" - return render_template('hwp_guide.html') - -@app.route('/generate-report', methods=['POST']) -def generate_report_api(): - """보고서 생성 API (router 기반)""" - try: - data = request.get_json() or {} - - # HTML 내용 (폴더에서 읽거나 직접 입력) - content = data.get('content', '') - - # 옵션 - options = { - 'folder_path': data.get('folder_path', ''), - 'cover': data.get('cover', False), - 'toc': data.get('toc', False), - 'divider': data.get('divider', False), - 'instruction': data.get('instruction', '') - } - - if not content.strip(): - return jsonify({'error': '내용이 비어있습니다.'}), 400 - - # router로 처리 - result = process_document(content, options) - - if result.get('success'): - return jsonify(result) - else: - return jsonify({'error': result.get('error', '처리 실패')}), 500 - - except Exception as e: - import traceback - return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 +# ============== 기타 API ============== @app.route('/assets/') def serve_assets(filename): @@ -523,43 +188,51 @@ def serve_assets(filename): return send_file(os.path.join(assets_dir, filename)) +@app.route('/hwp-script') +def hwp_script(): + """HWP 변환 스크립트 안내""" + return render_template('hwp_guide.html') + + @app.route('/health') def health(): """헬스 체크""" return jsonify({'status': 'healthy', 'version': '2.0.0'}) -# ===== HWP 변환 ===== @app.route('/export-hwp', methods=['POST']) def export_hwp(): + """HWP 변환 (스타일 그루핑 지원)""" try: data = request.get_json() html_content = data.get('html', '') doc_type = data.get('doc_type', 'briefing') + use_style_grouping = data.get('style_grouping', False) # 새 옵션 if not html_content: return jsonify({'error': 'HTML 내용이 없습니다'}), 400 - # 임시 파일 생성 temp_dir = tempfile.gettempdir() html_path = os.path.join(temp_dir, 'geulbeot_temp.html') hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp') - # HTML 저장 with open(html_path, 'w', encoding='utf-8') as f: f.write(html_content) - # 변환기 import 및 실행 + # 변환기 선택 if doc_type == 'briefing': from converters.html_to_hwp_briefing import HtmlToHwpConverter else: from converters.html_to_hwp import HtmlToHwpConverter converter = HtmlToHwpConverter(visible=False) - converter.convert(html_path, hwp_path) - converter.close() - # 파일 전송 + # 스타일 그루핑 사용 여부 + if use_style_grouping: + converter.convert_with_styles(html_path, hwp_path) + else: + converter.convert(html_path, hwp_path) + return send_file( hwp_path, as_attachment=True, @@ -573,7 +246,47 @@ def export_hwp(): return jsonify({'error': str(e)}), 500 +@app.route('/analyze-styles', methods=['POST']) +def analyze_styles(): + """HTML 스타일 분석 미리보기""" + try: + data = request.get_json() + html_content = data.get('html', '') + + if not html_content: + return jsonify({'error': 'HTML 내용이 없습니다'}), 400 + + from converters.style_analyzer import StyleAnalyzer + from converters.hwp_style_mapping import ROLE_TO_STYLE_NAME + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + # 요약 정보 + summary = analyzer.get_role_summary() + + # 상세 정보 (처음 50개만) + details = [] + for elem in elements[:50]: + details.append({ + 'role': elem.role, + 'hwp_style': ROLE_TO_STYLE_NAME.get(elem.role, '바탕글'), + 'text': elem.text[:50] + ('...' if len(elem.text) > 50 else ''), + 'section': elem.section + }) + + return jsonify({ + 'total_elements': len(elements), + 'summary': summary, + 'details': details + }) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' - app.run(host='0.0.0.0', port=port, debug=debug) + app.run(host='0.0.0.0', port=port, debug=debug) \ No newline at end of file diff --git a/converters/dkdl.py b/converters/dkdl.py new file mode 100644 index 0000000..1ba3302 --- /dev/null +++ b/converters/dkdl.py @@ -0,0 +1,37 @@ +from pyhwpx import Hwp + +hwp = Hwp() +hwp.FileNew() + +# HTML 헤딩 레벨 → 한글 기본 스타일 매핑 +heading_style_map = { + 'h1': 1, # 개요 1 + 'h2': 2, # 개요 2 + 'h3': 3, # 개요 3 + 'h4': 4, # 개요 4 + 'h5': 5, # 개요 5 + 'h6': 6, # 개요 6 +} + +def apply_heading_style(text, tag): + """HTML 태그에 맞는 스타일 적용""" + hwp.insert_text(text) + hwp.HAction.Run("MoveLineBegin") + hwp.HAction.Run("MoveSelLineEnd") + + # 해당 태그의 스타일 번호로 적용 + style_num = heading_style_map.get(tag, 0) + if style_num: + hwp.HAction.Run(f"StyleShortcut{style_num}") + + hwp.HAction.Run("MoveLineEnd") + hwp.BreakPara() + +# 테스트 +apply_heading_style("1장 서론", 'h1') +apply_heading_style("1.1 연구의 배경", 'h2') +apply_heading_style("1.1.1 세부 내용", 'h3') +apply_heading_style("본문 텍스트", 'p') # 일반 텍스트 + +hwp.SaveAs(r"D:\test_output.hwp") +print("완료!") \ No newline at end of file diff --git a/converters/html_to_hwp.py b/converters/html_to_hwp.py index 0c143d8..73af99a 100644 --- a/converters/html_to_hwp.py +++ b/converters/html_to_hwp.py @@ -13,6 +13,11 @@ from pyhwpx import Hwp from bs4 import BeautifulSoup, NavigableString import os, re +# 스타일 그루핑 시스템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME + + # PIL 선택적 import (이미지 크기 확인용) try: from PIL import Image @@ -25,9 +30,12 @@ class Config: MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 HEADER_LEN, FOOTER_LEN = 10, 10 MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 class StyleParser: def __init__(self): + self.style_map = {} # 스타일 매핑 (역할 → HwpStyle) + self.sty_gen = None # 스타일 생성기 self.class_styles = { 'h1': {'font-size': '20pt', 'color': '#008000'}, 'h2': {'font-size': '16pt', 'color': '#03581d'}, @@ -62,6 +70,34 @@ class StyleParser: def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] +# ═══════════════════════════════════════════════════════════════ +# 번호 제거 유틸리티 +# ═══════════════════════════════════════════════════════════════ + +NUMBERING_PATTERNS = { + 'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → "" + 'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → "" + 'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → "" + 'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → "" + 'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → "" + 'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → "" + 'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → "" + 'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → "" +} + +def strip_numbering(text: str, role: str) -> str: + """ + 역할에 따라 텍스트 앞의 번호/기호 제거 + HWP 개요 기능이 번호를 자동 생성하므로 중복 방지 + """ + if not text: + return text + + pattern = NUMBERING_PATTERNS.get(role) + if pattern: + return pattern.sub('', text).strip() + + return text.strip() class HtmlToHwpConverter: def __init__(self, visible=True): @@ -71,6 +107,8 @@ class HtmlToHwpConverter: self.base_path = "" self.is_first_h1 = True self.image_count = 0 + self.style_map = {} # 역할 → 스타일 이름 매핑 + self.sty_path = None # .sty 파일 경로 def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) @@ -155,6 +193,80 @@ class HtmlToHwpConverter: except Exception as e: print(f" [경고] 구역 머리말: {e}") + # 스타일 적용 관련 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 스타일 템플릿 로드 + HWP에서 스타일 불러오기 기능 사용 + """ + if not os.path.exists(sty_path): + print(f" [경고] 스타일 파일 없음: {sty_path}") + return False + + try: + # HWP 스타일 불러오기 + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 스타일 템플릿 로드: {sty_path}") + return True + except Exception as e: + print(f" [경고] 스타일 로드 실패: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 문단에 스타일 이름으로 적용 + 텍스트 삽입 후 호출 + """ + try: + # 현재 문단 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 스타일 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 커서 문단 끝으로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [경고] 스타일 적용 실패 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결과 기반 동적 스타일 매핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # 기타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순차 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 스타일 매핑: {self.style_map}") + return self.style_map + + def _set_font(self, size=11, bold=False, color='#000000'): self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) @@ -372,16 +484,22 @@ class HtmlToHwpConverter: # ═══════════════════════════════════════════════════════════════ def _insert_image(self, src, caption=""): self.image_count += 1 - print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}") if not src: return - # 상대경로 → 절대경로 - if not os.path.isabs(src): - full_path = os.path.normpath(os.path.join(self.base_path, src)) - else: - full_path = src + # 🆕 assets 폴더에서 먼저 찾기 + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없으면 기존 방식으로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 이미지 #{self.image_count}: {filename}") if not os.path.exists(full_path): print(f" ❌ 파일 없음: {full_path}") @@ -450,7 +568,123 @@ class HtmlToHwpConverter: except Exception as e: print(f" ❌ 오류: {e}") - + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삽입 (수정됨)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삽입: {num_rows}행 × {num_cols}열") + + try: + # 1. 표 앞에 문단 설정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 내장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데이터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 건너뛰기 (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헤더 셀 스타일 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍스트 입력 + self.hwp.insert_text(cell_text) + + # 다음 셀로 (마지막 셀 제외) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠져나오기 (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 해제 + self.hwp.HAction.Run("CloseEx") # 표 편집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 문서 끝으로 + + # 5. 표 뒤 문단 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삽입 완료") + + except Exception as e: + print(f" [오류] 표 삽입 실패: {e}") + # 표 안에 갇혔을 경우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 이동""" + # 첫 셀로 이동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큼 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큼 오른쪽으로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 스타일 적용""" + # 글자 굵기 + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배경색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배경색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 단색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [경고] 셀 배경색: {e}") + + def _insert_highlight_box(self, elem): txt = elem.get_text(strip=True) if not txt: return @@ -551,19 +785,225 @@ class HtmlToHwpConverter: print(f"\n✅ 저장: {output_path}") print(f" 이미지: {self.image_count}개 처리") + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 스타일 그루핑이 적용된 HWP 변환 + + ✅ 수정: 기존 convert() 로직 + 스타일 적용 + """ + print("="*60) + print("HTML → HWP 변환기 v11 (스타일 그루핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + + # 1. HTML 파일 읽기 + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # 2. 스타일 분석 + from converters.style_analyzer import StyleAnalyzer + from converters.hwp_style_mapping import HwpStyGenerator + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + html_styles = analyzer.extract_css_styles(html_content) + + print(f"\n📊 분석 결과: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # 3. 스타일 매핑 생성 + sty_gen = HwpStyGenerator() + sty_gen.update_from_html(html_styles) + self.style_map = sty_gen.apply_to_hwp(self.hwp) # Dict[str, HwpStyle] + self.sty_gen = sty_gen # 나중에 사용 + + # 4. ★ 기존 convert() 로직 그대로 사용 ★ + soup = BeautifulSoup(html_content, 'html.parser') + + title_tag = soup.find('title') + if title_tag: + full_title = title_tag.get_text(strip=True) + footer_title = full_title.split(':')[0].strip() + else: + footer_title = "" + + self.hwp.FileNew() + self._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: + self._process(ch) + self.hwp.HAction.Run("BreakPage") + + toc = raw.find(id='box-toc') + if toc: + print(" → 목차") + self.is_first_h1 = True + self._underline_box("목 차", 20, '#008000') + self.hwp.BreakPara() + self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + + summary = raw.find(id='box-summary') + if summary: + print(" → 요약") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + + content = raw.find(id='box-content') + if content: + print(" → 본문") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + # 5. 저장 + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 이미지: {self.image_count}개 처리") + + + def _insert_styled_element(self, elem: 'StyledElement'): + """스타일이 지정된 요소 삽입 (수정됨)""" + role = elem.role + text = elem.text + + # ═══ 특수 요소 처리 ═══ + + # 그림 + if role == 'FIGURE': + src = elem.attributes.get('src', '') + if src: + self._insert_image(src) + return + + # 표 + if role == 'TABLE': + self._insert_table_from_element(elem) + return + + # 표 셀/캡션은 TABLE에서 처리 + if role in ['TH', 'TD']: + return + + # 빈 텍스트 스킵 + if not text: + return + + # ═══ 텍스트 요소 처리 ═══ + + # 번호 제거 (HWP 개요가 자동 생성하면) + # clean_text = strip_numbering(text, role) # 필요시 활성화 + clean_text = text # 일단 원본 유지 + + # 1. 스타일 설정 가져오기 + style_config = self._get_style_config(role) + + # 2. 문단 모양 먼저 적용 + self._set_para( + align=style_config.get('align', 'justify'), + lh=style_config.get('line_height', 160), + left=style_config.get('indent_left', 0), + indent=style_config.get('indent_first', 0), + before=style_config.get('space_before', 0), + after=style_config.get('space_after', 0) + ) + + # 3. 글자 모양 적용 + self._set_font( + size=style_config.get('font_size', 11), + bold=style_config.get('bold', False), + color=style_config.get('color', '#000000') + ) + + # 4. 텍스트 삽입 + self.hwp.insert_text(clean_text) + + # 5. 스타일 적용 (F6 목록에서 참조되도록) + style_name = self.style_map.get(role) + if style_name: + try: + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HAction.Run("MoveLineEnd") + except: + pass # 스타일 없으면 무시 + + # 6. 줄바꿈 + self.hwp.BreakPara() + + + def _get_style_config(self, role: str) -> dict: + """역할에 따른 스타일 설정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목차 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계층 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 본문 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 그림 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # 기타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + def close(self): try: self.hwp.Quit() except: pass - def main(): html_path = r"D:\for python\survey_test\output\generated\report.html" - output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가스타일.sty" # 🆕 추가 try: conv = HtmlToHwpConverter(visible=True) - conv.convert(html_path, output_path) - input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항 + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter를 누르면 HWP가 닫힙니다...") conv.close() except Exception as e: print(f"\n[에러] {e}") diff --git a/converters/html_to_hwp_briefing.py b/converters/html_to_hwp_briefing.py index 0c143d8..d591e69 100644 --- a/converters/html_to_hwp_briefing.py +++ b/converters/html_to_hwp_briefing.py @@ -1,84 +1,124 @@ # -*- coding: utf-8 -*- """ -HTML → HWP 변환기 v11 +HTML → HWP 변환기 (기획서 전용) -✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정 -✅ 페이지번호: ctrl 코드 방식으로 수정 -✅ 나머지는 v10 유지 +✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함) +✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원 +✅ process-container (단계별 프로세스) 지원 +✅ badge 스타일 텍스트 변환 +✅ Navy 색상 테마 -pip install pyhwpx beautifulsoup4 pillow +pip install pyhwpx beautifulsoup4 """ from pyhwpx import Hwp -from bs4 import BeautifulSoup, NavigableString -import os, re +from bs4 import BeautifulSoup +import os -# PIL 선택적 import (이미지 크기 확인용) -try: - from PIL import Image - HAS_PIL = True -except ImportError: - HAS_PIL = False - print("[알림] PIL 없음 - 이미지 원본 크기로 삽입") class Config: - MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 - HEADER_LEN, FOOTER_LEN = 10, 10 - MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비) - -class StyleParser: - def __init__(self): - self.class_styles = { - 'h1': {'font-size': '20pt', 'color': '#008000'}, - 'h2': {'font-size': '16pt', 'color': '#03581d'}, - 'h3': {'font-size': '13pt', 'color': '#228B22'}, - 'p': {'font-size': '11pt', 'color': '#333333'}, - 'li': {'font-size': '11pt', 'color': '#333333'}, - 'th': {'font-size': '9pt', 'color': '#006400'}, - 'td': {'font-size': '9.5pt', 'color': '#333333'}, - 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, - 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, - 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, - } - - def get_element_style(self, elem): - style = {} - tag = elem.name if hasattr(elem, 'name') else None - if tag and tag in self.class_styles: style.update(self.class_styles[tag]) - for cls in elem.get('class', []) if hasattr(elem, 'get') else []: - if cls in self.class_styles: style.update(self.class_styles[cls]) - return style - - def parse_size(self, s): - m = re.search(r'([\d.]+)', str(s)) if s else None - return float(m.group(1)) if m else 11 - - def parse_color(self, c): - if not c: return '#000000' - c = str(c).strip().lower() - if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() - m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) - return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' - - def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + """페이지 설정""" + PAGE_WIDTH = 210 + PAGE_HEIGHT = 297 + MARGIN_LEFT = 20 + MARGIN_RIGHT = 20 + MARGIN_TOP = 20 + MARGIN_BOTTOM = 15 + HEADER_LEN = 10 + FOOTER_LEN = 10 + CONTENT_WIDTH = 170 class HtmlToHwpConverter: + """HTML → HWP 변환기 (기획서 전용)""" + def __init__(self, visible=True): self.hwp = Hwp(visible=visible) self.cfg = Config() - self.sp = StyleParser() - self.base_path = "" + self.colors = {} self.is_first_h1 = True - self.image_count = 0 - def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) - def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) - def _rgb(self, c): - c = c.lstrip('#') - return self.hwp.RGBColor(int(c[0:2],16), int(c[2:4],16), int(c[4:6],16)) if len(c)>=6 else self.hwp.RGBColor(0,0,0) + # ───────────────────────────────────────────────────────── + # 초기화 및 유틸리티 + # ───────────────────────────────────────────────────────── + + def _init_colors(self): + """색상 팔레트 초기화 (Navy 계열)""" + self.colors = { + 'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d + 'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282 + 'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce + 'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748 + 'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568 + 'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0 + 'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc + 'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0 + 'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f + 'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13 + 'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b + 'white': self.hwp.RGBColor(255, 255, 255), + 'black': self.hwp.RGBColor(0, 0, 0), + } + + def _mm(self, mm): + """밀리미터를 HWP 단위로 변환""" + return self.hwp.MiliToHwpUnit(mm) + + def _pt(self, pt): + """포인트를 HWP 단위로 변환""" + return self.hwp.PointToHwpUnit(pt) + + def _rgb(self, hex_color): + """HEX 색상을 RGB로 변환""" + c = hex_color.lstrip('#') + return self.hwp.RGBColor(int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) if len(c) >= 6 else self.hwp.RGBColor(0, 0, 0) + + def _font(self, size=10, color='black', bold=False): + """폰트 설정 (색상 이름 사용)""" + self.hwp.set_font( + FaceName='맑은 고딕', + Height=size, + Bold=bold, + TextColor=self.colors.get(color, self.colors['black']) + ) + + def _set_font(self, size=11, bold=False, hex_color='#000000'): + """폰트 설정 (HEX 색상 사용)""" + self.hwp.set_font( + FaceName='맑은 고딕', + Height=size, + Bold=bold, + TextColor=self._rgb(hex_color) + ) + + def _align(self, align): + """정렬 설정""" + actions = { + 'left': 'ParagraphShapeAlignLeft', + 'center': 'ParagraphShapeAlignCenter', + 'right': 'ParagraphShapeAlignRight', + 'justify': 'ParagraphShapeAlignJustify', + } + if align in actions: + self.hwp.HAction.Run(actions[align]) + + def _para(self, text='', size=10, color='black', bold=False, align='left'): + """문단 삽입""" + self._align(align) + self._font(size, color, bold) + if text: + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _exit_table(self): + """표 편집 모드 종료""" + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self.hwp.BreakPara() def _setup_page(self): + """페이지 설정""" try: self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet) s = self.hwp.HParameterSet.HSecDef @@ -89,9 +129,16 @@ class HtmlToHwpConverter: s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN) s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN) self.hwp.HAction.Execute("PageSetup", s.HSet) - except: pass + print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm") + except Exception as e: + print(f"[경고] 페이지 설정 실패: {e}") + + # ───────────────────────────────────────────────────────── + # 머리말 / 꼬리말 (보고서 방식) + # ───────────────────────────────────────────────────────── def _create_header(self, right_text=""): + """머리말 생성 (우측 정렬)""" print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}") try: self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) @@ -100,7 +147,7 @@ class HtmlToHwpConverter: self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) self.hwp.HAction.Run("ParagraphShapeAlignRight") - self._set_font(9, False, '#333333') + self._set_font(9, False, '#4a5568') if right_text: self.hwp.insert_text(right_text) @@ -108,10 +155,8 @@ class HtmlToHwpConverter: except Exception as e: print(f" [경고] 머리말: {e}") - # ═══════════════════════════════════════════════════════════════ - # 꼬리말 - 페이지 번호 (수정) - # ═══════════════════════════════════════════════════════════════ def _create_footer(self, left_text=""): + """꼬리말 생성 (좌측 텍스트 + 우측 페이지 번호)""" print(f" → 꼬리말: {left_text}") # 1. 꼬리말 열기 @@ -122,7 +167,7 @@ class HtmlToHwpConverter: # 2. 좌측 정렬 + 제목 8pt self.hwp.HAction.Run("ParagraphShapeAlignLeft") - self._set_font(8, False, '#666666') + self._set_font(8, False, '#4a5568') self.hwp.insert_text(left_text) # 3. 꼬리말 닫기 @@ -132,7 +177,7 @@ class HtmlToHwpConverter: self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet) self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight") self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet) - + def _new_section_with_header(self, header_text): """새 구역 생성 후 머리말 설정""" print(f" → 새 구역 머리말: {header_text}") @@ -148,426 +193,424 @@ class HtmlToHwpConverter: self.hwp.HAction.Run("Delete") self.hwp.HAction.Run("ParagraphShapeAlignRight") - self._set_font(9, False, '#333333') + self._set_font(9, False, '#4a5568') self.hwp.insert_text(header_text) self.hwp.HAction.Run("CloseEx") except Exception as e: print(f" [경고] 구역 머리말: {e}") - - - def _set_font(self, size=11, bold=False, color='#000000'): - self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) - def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): - acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', - 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} - if align in acts: self.hwp.HAction.Run(acts[align]) - try: - self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) - p = self.hwp.HParameterSet.HParaShape - p.LineSpaceType, p.LineSpacing = 0, lh - p.LeftMargin = self._mm(left) - p.IndentMargin = self._mm(indent) - p.SpaceBeforePara = self._pt(before) - p.SpaceAfterPara = self._pt(after) - p.BreakNonLatinWord = 0 - self.hwp.HAction.Execute("ParagraphShape", p.HSet) - except: pass + # ───────────────────────────────────────────────────────── + # 셀 배경색 설정 + # ───────────────────────────────────────────────────────── - def _set_cell_bg(self, color): - try: - self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) - p = self.hwp.HParameterSet.HCellBorderFill - p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") - p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") - p.FillAttr.WinBrushHatchColor = self._rgb('#000000') - p.FillAttr.WinBrushFaceColor = self._rgb(color) - p.FillAttr.WindowsBrush = 1 - self.hwp.HAction.Execute("CellBorderFill", p.HSet) - except: pass + def _set_cell_bg(self, color_name): + """셀 배경색 설정 (색상 이름)""" + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + pset = self.hwp.HParameterSet.HCellBorderFill + pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0) + pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white']) + pset.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", pset.HSet) - def _underline_box(self, text, size=14, color='#008000'): - try: - self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) - t = self.hwp.HParameterSet.HTableCreation - t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 - t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) - self.hwp.HAction.Execute("TableCreate", t.HSet) - self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) - self.hwp.HParameterSet.HInsertText.Text = text - self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) - self.hwp.HAction.Run("TableCellBlock") - self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) - self.hwp.HParameterSet.HCharShape.Height = self._pt(size) - self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) - self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) - self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) - c = self.hwp.HParameterSet.HCellBorderFill - c.BorderTypeTop = self.hwp.HwpLineType("None") - c.BorderTypeRight = self.hwp.HwpLineType("None") - c.BorderTypeLeft = self.hwp.HwpLineType("None") - self.hwp.HAction.Execute("CellBorder", c.HSet) - self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) - c = self.hwp.HParameterSet.HCellBorderFill - c.BorderColorBottom = self._rgb(color) - c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") - self.hwp.HAction.Execute("CellBorder", c.HSet) - self.hwp.HAction.Run("Cancel") - self.hwp.HAction.Run("CloseEx") - self.hwp.HAction.Run("MoveDocEnd") - except: - self._set_font(size, True, color) - self.hwp.insert_text(text) - self.hwp.BreakPara() + # ───────────────────────────────────────────────────────── + # HTML 요소 변환 (기획서 전용) + # ───────────────────────────────────────────────────────── - def _update_header(self, new_title): - """머리말 텍스트 업데이트""" - try: - # 기존 머리말 편집 모드로 진입 - self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) - self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 편집 모드 - self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0) - self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) - - # 기존 내용 삭제 - self.hwp.HAction.Run("SelectAll") - self.hwp.HAction.Run("Delete") - - # 새 내용 삽입 - self.hwp.HAction.Run("ParagraphShapeAlignRight") - self._set_font(9, False, '#333333') - self.hwp.insert_text(new_title) - - self.hwp.HAction.Run("CloseEx") - except Exception as e: - print(f" [경고] 머리말 업데이트: {e}") - - def _insert_heading(self, elem): - lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 - txt = elem.get_text(strip=True) - st = self.sp.get_element_style(elem) - sz = self.sp.parse_size(st.get('font-size','14pt')) - cl = self.sp.parse_color(st.get('color','#008000')) - - if lv == 1: - if self.is_first_h1: - self._create_header(txt) - self.is_first_h1 = False - else: - self._new_section_with_header(txt) - - self._set_para('left', 130, before=0, after=0) - self._underline_box(txt, sz, cl) - self.hwp.BreakPara() - self._set_para('left', 130, before=0, after=15) - self.hwp.BreakPara() - elif lv == 2: - self._set_para('left', 150, before=20, after=8) - self._set_font(sz, True, cl) - self.hwp.insert_text("■ " + txt) - self.hwp.BreakPara() - elif lv == 3: - self._set_para('left', 140, left=3, before=12, after=5) - self._set_font(sz, True, cl) - self.hwp.insert_text("▸ " + txt) - self.hwp.BreakPara() - - def _insert_paragraph(self, elem): - txt = elem.get_text(strip=True) - if not txt: return - st = self.sp.get_element_style(elem) - sz = self.sp.parse_size(st.get('font-size','11pt')) - cl = self.sp.parse_color(st.get('color','#333333')) - self._set_para('justify', 170, left=0, indent=3, before=0, after=3) - - if elem.find(['b','strong']): - for ch in elem.children: - if isinstance(ch, NavigableString): - if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) - elif ch.name in ['b','strong']: - if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) - else: - self._set_font(sz, self.sp.is_bold(st), cl) - self.hwp.insert_text(txt) - self.hwp.BreakPara() - - def _insert_list(self, elem): - lt = elem.name - for i, li in enumerate(elem.find_all('li', recursive=False)): - st = self.sp.get_element_style(li) - cls = li.get('class', []) - txt = li.get_text(strip=True) - is_toc = any('toc-' in c for c in cls) - - if 'toc-lvl-1' in cls: left, bef = 0, 8 - elif 'toc-lvl-2' in cls: left, bef = 7, 3 - elif 'toc-lvl-3' in cls: left, bef = 14, 1 - else: left, bef = 4, 2 - - pf = f"{i+1}. " if lt == 'ol' else "• " - sz = self.sp.parse_size(st.get('font-size','11pt')) - cl = self.sp.parse_color(st.get('color','#333333')) - bd = self.sp.is_bold(st) - - if is_toc: - self._set_para('left', 170, left=left, indent=0, before=bef, after=1) - self._set_font(sz, bd, cl) - self.hwp.insert_text(pf + txt) - self.hwp.BreakPara() - else: - self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) - self._set_font(sz, bd, cl) - self.hwp.insert_text(pf) - self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") - self.hwp.insert_text(txt) - self.hwp.BreakPara() - - def _insert_table(self, table_elem): - rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0 - for ri, tr in enumerate(table_elem.find_all('tr')): - row, ci = [], 0 - for cell in tr.find_all(['td','th']): - while (ri,ci) in occupied: row.append(""); ci+=1 - txt = cell.get_text(strip=True) - cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1)) - cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0} - row.append(txt) - for dr in range(rs): - for dc in range(cs): - if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True - for _ in range(cs-1): row.append("") - ci += cs - rows_data.append(row) - max_cols = max(max_cols, len(row)) - for row in rows_data: - while len(row) < max_cols: row.append("") - - rc = len(rows_data) - if rc == 0 or max_cols == 0: return - print(f" 표: {rc}행 × {max_cols}열") - - self._set_para('left', 130, before=5, after=0) - self.hwp.create_table(rc, max_cols, treat_as_char=True) - - for ri, row in enumerate(rows_data): - for ci in range(max_cols): - if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue - txt = row[ci] if ci < len(row) else "" - hdr = cell_styles.get((ri,ci),{}).get('is_header', False) - if hdr: self._set_cell_bg('#E8F5E9') - self.hwp.HAction.Run("ParagraphShapeAlignCenter") - self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') - self.hwp.insert_text(str(txt)) - if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight") - - self.hwp.HAction.Run("Cancel") - self.hwp.HAction.Run("CloseEx") - self.hwp.HAction.Run("MoveDocEnd") - self._set_para('left', 130, before=5, after=5) - self.hwp.BreakPara() - - # ═══════════════════════════════════════════════════════════════ - # 이미지 삽입 - sizeoption 수정 ★ - # ═══════════════════════════════════════════════════════════════ - def _insert_image(self, src, caption=""): - self.image_count += 1 - print(f" 📷 이미지 #{self.image_count}: {os.path.basename(src)}") - - if not src: + def _convert_lead_box(self, elem): + """lead-box 변환 (핵심 기조 박스)""" + content = elem.find("div") + if not content: return - # 상대경로 → 절대경로 - if not os.path.isabs(src): - full_path = os.path.normpath(os.path.join(self.base_path, src)) - else: - full_path = src + text = content.get_text(strip=True) + text = ' '.join(text.split()) + print(f" → lead-box") - if not os.path.exists(full_path): - print(f" ❌ 파일 없음: {full_path}") - self._set_font(9, False, '#999999') - self._set_para('center', 130) - self.hwp.insert_text(f"[이미지 없음: {os.path.basename(src)}]") - self.hwp.BreakPara() - return - - try: - self._set_para('center', 130, before=5, after=3) - - # ★ sizeoption=0: 원본 크기 - # ★ sizeoption=2: 지정 크기 (width, height 필요) - # ★ 둘 다 안되면 sizeoption 없이 시도 - - inserted = False - - # 방법 1: sizeoption=0 (원본 크기) - try: - self.hwp.insert_picture(full_path, sizeoption=0) - inserted = True - print(f" ✅ 삽입 성공 (원본 크기)") - except Exception as e1: - pass - - # 방법 2: width/height 지정 - if not inserted and HAS_PIL: - try: - with Image.open(full_path) as img: - w_px, h_px = img.size - # px → mm 변환 (96 DPI 기준) - w_mm = w_px * 25.4 / 96 - h_mm = h_px * 25.4 / 96 - # 최대 너비 제한 - if w_mm > self.cfg.MAX_IMAGE_WIDTH: - ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm - w_mm = self.cfg.MAX_IMAGE_WIDTH - h_mm = h_mm * ratio - - self.hwp.insert_picture(full_path, sizeoption=1, - width=self._mm(w_mm), height=self._mm(h_mm)) - inserted = True - print(f" ✅ 삽입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") - except Exception as e2: - pass - - # 방법 3: 기본값 - if not inserted: - try: - self.hwp.insert_picture(full_path) - inserted = True - print(f" ✅ 삽입 성공 (기본)") - except Exception as e3: - print(f" ❌ 삽입 실패: {e3}") - self._set_font(9, False, '#FF0000') - self.hwp.insert_text(f"[이미지 오류: {os.path.basename(src)}]") - - self.hwp.BreakPara() - - if caption and inserted: - self._set_font(9.5, True, '#666666') - self._set_para('center', 130, before=0, after=5) - self.hwp.insert_text(caption) - self.hwp.BreakPara() - - except Exception as e: - print(f" ❌ 오류: {e}") - - def _insert_highlight_box(self, elem): - txt = elem.get_text(strip=True) - if not txt: return - self._set_para('left', 130, before=5, after=0) self.hwp.create_table(1, 1, treat_as_char=True) - self._set_cell_bg('#E2ECE2') - self._set_font(11, False, '#333333') - self.hwp.insert_text(txt) - self.hwp.HAction.Run("Cancel") - self.hwp.HAction.Run("CloseEx") - self.hwp.HAction.Run("MoveDocEnd") - self._set_para('left', 130, before=0, after=5) - self.hwp.BreakPara() + self._set_cell_bg('bg-light') + self._font(11.5, 'dark-gray', False) + self.hwp.insert_text(text) + self._exit_table() - def _process(self, elem): - if isinstance(elem, NavigableString): return - tag = elem.name - if not tag or tag in ['script','style','template','noscript','head']: return - - if tag == 'figure': - img = elem.find('img') - if img: - figcaption = elem.find('figcaption') - caption = figcaption.get_text(strip=True) if figcaption else "" - self._insert_image(img.get('src', ''), caption) + def _convert_strategy_grid(self, elem): + """strategy-grid 변환 (2x2 전략 박스)""" + items = elem.find_all(class_="strategy-item") + if not items: return - if tag == 'img': - self._insert_image(elem.get('src', '')) + print(f" → strategy-grid: {len(items)} items") + + self.hwp.create_table(2, 2, treat_as_char=True) + + for i, item in enumerate(items[:4]): + if i > 0: + self.hwp.HAction.Run("MoveRight") + + self._set_cell_bg('bg-light') + + title = item.find(class_="strategy-title") + if title: + self._font(10, 'primary-navy', True) + self.hwp.insert_text(title.get_text(strip=True)) + self.hwp.BreakPara() + + p = item.find("p") + if p: + self._font(9.5, 'dark-gray', False) + self.hwp.insert_text(p.get_text(strip=True)) + + self._exit_table() + + def _convert_process_container(self, elem): + """process-container 변환 (단계별 프로세스)""" + steps = elem.find_all(class_="process-step") + if not steps: return - if tag in ['h1','h2','h3']: self._insert_heading(elem) - elif tag == 'p': self._insert_paragraph(elem) - elif tag == 'table': self._insert_table(elem) - elif tag in ['ul','ol']: self._insert_list(elem) - elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) - elif tag in ['div','section','article','main','body','html','span']: - for ch in elem.children: self._process(ch) + print(f" → process-container: {len(steps)} steps") + + rows = len(steps) + self.hwp.create_table(rows, 2, treat_as_char=True) + + for i, step in enumerate(steps): + if i > 0: + self.hwp.HAction.Run("MoveRight") + + # 번호 셀 + num = step.find(class_="step-num") + self._set_cell_bg('primary-navy') + self._font(10, 'white', True) + self._align('center') + if num: + self.hwp.insert_text(num.get_text(strip=True)) + + self.hwp.HAction.Run("MoveRight") + + # 내용 셀 + content = step.find(class_="step-content") + self._set_cell_bg('bg-light') + self._font(10.5, 'dark-gray', False) + self._align('left') + if content: + self.hwp.insert_text(content.get_text(strip=True)) + + self._exit_table() + + def _convert_data_table(self, table): + """data-table 변환 (badge 포함)""" + data = [] + + thead = table.find("thead") + if thead: + ths = thead.find_all("th") + data.append([th.get_text(strip=True) for th in ths]) + + tbody = table.find("tbody") + if tbody: + for tr in tbody.find_all("tr"): + row = [] + for td in tr.find_all("td"): + badge = td.find(class_="badge") + if badge: + badge_class = ' '.join(badge.get('class', [])) + badge_text = badge.get_text(strip=True) + if 'badge-safe' in badge_class: + row.append(f"[✓ {badge_text}]") + elif 'badge-caution' in badge_class: + row.append(f"[△ {badge_text}]") + elif 'badge-risk' in badge_class: + row.append(f"[✗ {badge_text}]") + else: + row.append(f"[{badge_text}]") + else: + row.append(td.get_text(strip=True)) + data.append(row) + + if not data: + return + + rows = len(data) + cols = len(data[0]) if data else 0 + print(f" → data-table: {rows}×{cols}") + + self.hwp.create_table(rows, cols, treat_as_char=True) + + for row_idx, row in enumerate(data): + for col_idx, cell_text in enumerate(row): + is_header = (row_idx == 0) + is_first_col = (col_idx == 0 and not is_header) + + is_safe = '[✓' in str(cell_text) + is_caution = '[△' in str(cell_text) + is_risk = '[✗' in str(cell_text) + + if is_header: + self._set_cell_bg('primary-navy') + self._font(9, 'white', True) + elif is_first_col: + self._set_cell_bg('bg-light') + self._font(9.5, 'primary-navy', True) + elif is_safe: + self._font(9.5, 'badge-safe', True) + elif is_caution: + self._font(9.5, 'badge-caution', True) + elif is_risk: + self._font(9.5, 'badge-risk', True) + else: + self._font(9.5, 'dark-gray', False) + + self._align('center') + self.hwp.insert_text(str(cell_text)) + + if not (row_idx == rows - 1 and col_idx == cols - 1): + self.hwp.HAction.Run("MoveRight") + + self._exit_table() + + def _convert_qa_grid(self, elem): + """qa-grid 변환 (Q&A 2단 박스)""" + items = elem.find_all(class_="qa-item") + if not items: + return + + print(f" → qa-grid: {len(items)} items") + + self.hwp.create_table(1, 2, treat_as_char=True) + + for i, item in enumerate(items[:2]): + if i > 0: + self.hwp.HAction.Run("MoveRight") + + self._set_cell_bg('bg-light') + + text = item.get_text(strip=True) + strong = item.find("strong") + if strong: + q_text = strong.get_text(strip=True) + a_text = text.replace(q_text, '').strip() + + self._font(9.5, 'primary-navy', True) + self.hwp.insert_text(q_text) + self.hwp.BreakPara() + self._font(9.5, 'dark-gray', False) + self.hwp.insert_text(a_text) + else: + self._font(9.5, 'dark-gray', False) + self.hwp.insert_text(text) + + self._exit_table() + + def _convert_bottom_box(self, elem): + """bottom-box 변환 (핵심 결론 박스)""" + left = elem.find(class_="bottom-left") + right = elem.find(class_="bottom-right") + + if not left or not right: + return + + left_text = ' '.join(left.get_text().split()) + right_text = right.get_text(strip=True) + print(f" → bottom-box") + + self.hwp.create_table(1, 2, treat_as_char=True) + + # 좌측 (Navy 배경) + self._set_cell_bg('primary-navy') + self._font(10.5, 'white', True) + self._align('center') + self.hwp.insert_text(left_text) + + self.hwp.HAction.Run("MoveRight") + + # 우측 (연한 배경) + self._set_cell_bg('bg-light') + self._font(10.5, 'primary-navy', True) + self._align('center') + self.hwp.insert_text(right_text) + + self._exit_table() + + def _convert_section(self, section): + """section 변환""" + title = section.find(class_="section-title") + if title: + self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True) + + strategy_grid = section.find(class_="strategy-grid") + if strategy_grid: + self._convert_strategy_grid(strategy_grid) + + process = section.find(class_="process-container") + if process: + self._convert_process_container(process) + + table = section.find("table", class_="data-table") + if table: + self._convert_data_table(table) + + ul = section.find("ul") + if ul: + for li in ul.find_all("li", recursive=False): + keyword = li.find(class_="keyword") + if keyword: + kw_text = keyword.get_text(strip=True) + full = li.get_text(strip=True) + rest = full.replace(kw_text, '', 1).strip() + + self._font(10.5, 'primary-navy', True) + self.hwp.insert_text(" • " + kw_text + " ") + self._font(10.5, 'dark-gray', False) + self.hwp.insert_text(rest) + self.hwp.BreakPara() + else: + self._para(" • " + li.get_text(strip=True), 10.5, 'dark-gray') + + qa_grid = section.find(class_="qa-grid") + if qa_grid: + self._convert_qa_grid(qa_grid) + + self._para() + + def _convert_sheet(self, sheet, is_first_page=False, footer_title=""): + """한 페이지(sheet) 변환""" + + # 첫 페이지에서만 머리말/꼬리말 설정 + if is_first_page: + # 머리말: page-header에서 텍스트 추출 + header = sheet.find(class_="page-header") + if header: + left = header.find(class_="header-left") + right = header.find(class_="header-right") + # 우측 텍스트 사용 (부서명 등) + header_text = right.get_text(strip=True) if right else "" + if header_text: + self._create_header(header_text) + + # 꼬리말: 제목 + 페이지번호 + self._create_footer(footer_title) + + # 대제목 + title = sheet.find(class_="header-title") + if title: + title_text = title.get_text(strip=True) + if '[첨부]' in title_text: + self._para(title_text, 15, 'primary-navy', True, 'left') + self._font(10, 'secondary-navy', False) + self._align('left') + self.hwp.insert_text("─" * 60) + self.hwp.BreakPara() + else: + self._para(title_text, 23, 'primary-navy', True, 'center') + self._font(10, 'secondary-navy', False) + self._align('center') + self.hwp.insert_text("━" * 45) + self.hwp.BreakPara() + + self._para() + + # 리드 박스 + lead_box = sheet.find(class_="lead-box") + if lead_box: + self._convert_lead_box(lead_box) + self._para() + + # 섹션들 + for section in sheet.find_all(class_="section"): + self._convert_section(section) + + # 하단 박스 + bottom_box = sheet.find(class_="bottom-box") + if bottom_box: + self._para() + self._convert_bottom_box(bottom_box) + + # ───────────────────────────────────────────────────────── + # 메인 변환 함수 + # ───────────────────────────────────────────────────────── def convert(self, html_path, output_path): - print("="*60) - print("HTML → HWP 변환기 v11") - print(" ✓ 이미지: sizeoption 수정") - print(" ✓ 페이지번호: 다중 방법 시도") - print("="*60) + """HTML → HWP 변환 실행""" - self.base_path = os.path.dirname(os.path.abspath(html_path)) - self.is_first_h1 = True - self.image_count = 0 + print("=" * 60) + print("HTML → HWP 변환기 (기획서 전용)") + print(" ✓ 머리말/꼬리말: 보고서 방식") + print(" ✓ Navy 테마, 기획서 요소") + print("=" * 60) - print(f"\n입력: {html_path}") - print(f"출력: {output_path}\n") + print(f"\n[입력] {html_path}") with open(html_path, 'r', encoding='utf-8') as f: soup = BeautifulSoup(f.read(), 'html.parser') + # 제목 추출 (꼬리말용) title_tag = soup.find('title') if title_tag: full_title = title_tag.get_text(strip=True) - footer_title = full_title.split(':')[0].strip() # ":" 이전 + footer_title = full_title.split(':')[0].strip() else: footer_title = "" - - self.hwp.FileNew() - self._setup_page() - self._create_footer(footer_title) - - raw = soup.find(id='raw-container') - if raw: - cover = raw.find(id='box-cover') - if cover: - print(" → 표지") - for ch in cover.children: self._process(ch) - self.hwp.HAction.Run("BreakPage") - toc = raw.find(id='box-toc') - if toc: - print(" → 목차") - self.is_first_h1 = True - self._underline_box("목 차", 20, '#008000') - self.hwp.BreakPara(); self.hwp.BreakPara() - self._insert_list(toc.find('ul') or toc) - self.hwp.HAction.Run("BreakPage") - summary = raw.find(id='box-summary') - if summary: - print(" → 요약") - self.is_first_h1 = True - self._process(summary) - self.hwp.HAction.Run("BreakPage") - content = raw.find(id='box-content') - if content: - print(" → 본문") - self.is_first_h1 = True - self._process(content) - else: - self._process(soup.find('body') or soup) + self.hwp.FileNew() + self._init_colors() + self._setup_page() + + # 페이지별 변환 + sheets = soup.find_all(class_="sheet") + total = len(sheets) + print(f"[변환] 총 {total} 페이지\n") + + for i, sheet in enumerate(sheets, 1): + print(f"[{i}/{total}] 페이지 처리 중...") + self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title) + + if i < total: + self.hwp.HAction.Run("BreakPage") + + # 저장 self.hwp.SaveAs(output_path) - print(f"\n✅ 저장: {output_path}") - print(f" 이미지: {self.image_count}개 처리") + print(f"\n✅ 저장 완료: {output_path}") def close(self): - try: self.hwp.Quit() - except: pass + """HWP 종료""" + try: + self.hwp.Quit() + except: + pass def main(): - html_path = r"D:\for python\survey_test\output\generated\report.html" - output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp" + """메인 실행""" + + html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html" + output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp" + + print("=" * 60) + print("HTML → HWP 변환기 (기획서)") + print("=" * 60) + print() try: - conv = HtmlToHwpConverter(visible=True) - conv.convert(html_path, output_path) - input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항 - conv.close() + converter = HtmlToHwpConverter(visible=True) + converter.convert(html_path, output_path) + + print("\n" + "=" * 60) + print("✅ 변환 완료!") + print("=" * 60) + + input("\nEnter를 누르면 HWP가 닫힙니다...") + converter.close() + + except FileNotFoundError: + print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}") + print("경로를 확인해주세요.") except Exception as e: print(f"\n[에러] {e}") - import traceback; traceback.print_exc() + import traceback + traceback.print_exc() + if __name__ == "__main__": main() \ No newline at end of file diff --git a/converters/hwp_style_mapping.py b/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/converters/hwp_style_mapping.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +""" +HWP 스타일 매핑 모듈 v2.0 +HTML 역할(Role) → HWP 스타일 매핑 + +✅ v2.0 변경사항: +- pyhwpx API에 맞게 apply_to_hwp() 재작성 +- CharShape/ParaShape 직접 설정 방식 +- 역할 → 개요 스타일 매핑 +""" + +from dataclasses import dataclass +from typing import Dict, Optional +from enum import Enum + + +class HwpStyleType(Enum): + """HWP 스타일 유형""" + PARAGRAPH = "paragraph" + CHARACTER = "character" + + +@dataclass +class HwpStyle: + """HWP 스타일 정의""" + id: int + name: str + type: HwpStyleType + font_size: float + font_bold: bool = False + font_color: str = "000000" + align: str = "justify" + line_spacing: float = 160 + space_before: float = 0 + space_after: float = 0 + indent_left: float = 0 + indent_first: float = 0 + bg_color: Optional[str] = None + + +# ============================================================================= +# 기본 스타일 템플릿 +# ============================================================================= +DEFAULT_STYLES: Dict[str, HwpStyle] = { + # 표지 + "COVER_TITLE": HwpStyle( + id=100, name="표지제목", type=HwpStyleType.PARAGRAPH, + font_size=32, font_bold=True, align="center", + space_before=20, space_after=10, font_color="1a365d" + ), + "COVER_SUBTITLE": HwpStyle( + id=101, name="표지부제", type=HwpStyleType.PARAGRAPH, + font_size=18, font_bold=False, align="center", + font_color="555555" + ), + "COVER_INFO": HwpStyle( + id=102, name="표지정보", type=HwpStyleType.PARAGRAPH, + font_size=12, align="center", font_color="666666" + ), + + # 목차 + "TOC_H1": HwpStyle( + id=110, name="목차1수준", type=HwpStyleType.PARAGRAPH, + font_size=12, font_bold=True, indent_left=0 + ), + "TOC_H2": HwpStyle( + id=111, name="목차2수준", type=HwpStyleType.PARAGRAPH, + font_size=11, indent_left=20 + ), + "TOC_H3": HwpStyle( + id=112, name="목차3수준", type=HwpStyleType.PARAGRAPH, + font_size=10, indent_left=40, font_color="666666" + ), + + # 제목 계층 (개요 1~7 매핑) + "H1": HwpStyle( + id=1, name="개요 1", type=HwpStyleType.PARAGRAPH, + font_size=20, font_bold=True, align="left", + space_before=30, space_after=15, font_color="1a365d" + ), + "H2": HwpStyle( + id=2, name="개요 2", type=HwpStyleType.PARAGRAPH, + font_size=16, font_bold=True, align="left", + space_before=20, space_after=10, font_color="2c5282" + ), + "H3": HwpStyle( + id=3, name="개요 3", type=HwpStyleType.PARAGRAPH, + font_size=14, font_bold=True, align="left", + space_before=15, space_after=8, font_color="2b6cb0" + ), + "H4": HwpStyle( + id=4, name="개요 4", type=HwpStyleType.PARAGRAPH, + font_size=12, font_bold=True, align="left", + space_before=10, space_after=5, indent_left=10 + ), + "H5": HwpStyle( + id=5, name="개요 5", type=HwpStyleType.PARAGRAPH, + font_size=11, font_bold=True, align="left", + space_before=8, space_after=4, indent_left=20 + ), + "H6": HwpStyle( + id=6, name="개요 6", type=HwpStyleType.PARAGRAPH, + font_size=11, font_bold=False, align="left", + indent_left=30 + ), + "H7": HwpStyle( + id=7, name="개요 7", type=HwpStyleType.PARAGRAPH, + font_size=10.5, font_bold=False, align="left", + indent_left=40 + ), + + # 본문 + "BODY": HwpStyle( + id=20, name="바탕글", type=HwpStyleType.PARAGRAPH, + font_size=11, align="justify", + line_spacing=180, indent_first=10 + ), + "LIST_ITEM": HwpStyle( + id=8, name="개요 8", type=HwpStyleType.PARAGRAPH, + font_size=11, align="left", + indent_left=15, line_spacing=160 + ), + "HIGHLIGHT_BOX": HwpStyle( + id=21, name="강조박스", type=HwpStyleType.PARAGRAPH, + font_size=10.5, align="left", + bg_color="f7fafc", indent_left=10, indent_first=0 + ), + + # 표 + "TABLE": HwpStyle( + id=30, name="표", type=HwpStyleType.PARAGRAPH, + font_size=10, align="center" + ), + "TH": HwpStyle( + id=11, name="표제목", type=HwpStyleType.PARAGRAPH, + font_size=10, font_bold=True, align="center", + bg_color="e2e8f0" + ), + "TD": HwpStyle( + id=31, name="표내용", type=HwpStyleType.PARAGRAPH, + font_size=10, align="left" + ), + "TABLE_CAPTION": HwpStyle( + id=19, name="표캡션", type=HwpStyleType.PARAGRAPH, + font_size=10, font_bold=True, align="center", + space_before=5, space_after=3 + ), + + # 그림 + "FIGURE": HwpStyle( + id=32, name="그림", type=HwpStyleType.PARAGRAPH, + font_size=10, align="center" + ), + "FIGURE_CAPTION": HwpStyle( + id=18, name="그림캡션", type=HwpStyleType.PARAGRAPH, + font_size=9.5, align="center", + font_color="666666", space_before=5 + ), + + # 기타 + "UNKNOWN": HwpStyle( + id=0, name="바탕글", type=HwpStyleType.PARAGRAPH, + font_size=10, align="left" + ), +} + +# 역할 → 개요 번호 매핑 (StyleShortcut 용) +ROLE_TO_OUTLINE_NUM = { + "H1": 1, + "H2": 2, + "H3": 3, + "H4": 4, + "H5": 5, + "H6": 6, + "H7": 7, + "LIST_ITEM": 8, + "BODY": 0, # 바탕글 + "COVER_TITLE": 0, + "COVER_SUBTITLE": 0, + "COVER_INFO": 0, +} + +# 역할 → HWP 스타일 이름 매핑 +ROLE_TO_STYLE_NAME = { + "H1": "개요 1", + "H2": "개요 2", + "H3": "개요 3", + "H4": "개요 4", + "H5": "개요 5", + "H6": "개요 6", + "H7": "개요 7", + "LIST_ITEM": "개요 8", + "BODY": "바탕글", + "COVER_TITLE": "표지제목", + "COVER_SUBTITLE": "표지부제", + "TH": "표제목", + "TD": "표내용", + "TABLE_CAPTION": "표캡션", + "FIGURE_CAPTION": "그림캡션", + "UNKNOWN": "바탕글", +} + + +class HwpStyleMapper: + """HTML 역할 → HWP 스타일 매퍼""" + + def __init__(self, custom_styles: Optional[Dict[str, HwpStyle]] = None): + self.styles = DEFAULT_STYLES.copy() + if custom_styles: + self.styles.update(custom_styles) + + def get_style(self, role: str) -> HwpStyle: + return self.styles.get(role, self.styles["UNKNOWN"]) + + def get_style_id(self, role: str) -> int: + return self.get_style(role).id + + def get_all_styles(self) -> Dict[str, HwpStyle]: + return self.styles + + +class HwpStyGenerator: + """ + HTML 스타일 → HWP 스타일 적용기 + + pyhwpx API를 사용하여: + 1. 역할별 스타일 정보 저장 + 2. 텍스트 삽입 시 CharShape/ParaShape 직접 적용 + 3. 개요 스타일 번호 매핑 반환 + """ + + def __init__(self): + self.styles: Dict[str, HwpStyle] = {} + self.hwp = None + + def update_from_html(self, html_styles: Dict[str, Dict]): + """HTML에서 추출한 스타일로 업데이트""" + for role, style_dict in html_styles.items(): + if role in DEFAULT_STYLES: + base = DEFAULT_STYLES[role] + + # color 처리 - # 제거 + color = style_dict.get('color', base.font_color) + if isinstance(color, str): + color = color.lstrip('#') + + self.styles[role] = HwpStyle( + id=base.id, + name=base.name, + type=base.type, + font_size=style_dict.get('font_size', base.font_size), + font_bold=style_dict.get('bold', base.font_bold), + font_color=color, + align=style_dict.get('align', base.align), + line_spacing=style_dict.get('line_spacing', base.line_spacing), + space_before=style_dict.get('space_before', base.space_before), + space_after=style_dict.get('space_after', base.space_after), + indent_left=style_dict.get('indent_left', base.indent_left), + indent_first=style_dict.get('indent_first', base.indent_first), + bg_color=style_dict.get('bg_color', base.bg_color), + ) + else: + # 기본 스타일 사용 + self.styles[role] = DEFAULT_STYLES.get('UNKNOWN') + + # 누락된 역할은 기본값으로 채움 + for role in DEFAULT_STYLES: + if role not in self.styles: + self.styles[role] = DEFAULT_STYLES[role] + + def apply_to_hwp(self, hwp) -> Dict[str, HwpStyle]: + """역할 → HwpStyle 매핑 반환""" + self.hwp = hwp + + # 🚫 스타일 생성 비활성화 (API 문제) + # for role, style in self.styles.items(): + # self._create_or_update_style(hwp, role, style) + + if not self.styles: + self.styles = DEFAULT_STYLES.copy() + + print(f" ✅ 스타일 매핑 완료: {len(self.styles)}개") + return self.styles + + def _create_or_update_style(self, hwp, role: str, style: HwpStyle): + """HWP에 스타일 생성 또는 수정""" + try: + # 1. 스타일 편집 모드 + hwp.HAction.GetDefault("ModifyStyle", hwp.HParameterSet.HStyle.HSet) + hwp.HParameterSet.HStyle.StyleName = style.name + + # 2. 글자 모양 + color_hex = style.font_color.lstrip('#') + if len(color_hex) == 6: + r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16) + text_color = hwp.RGBColor(r, g, b) + else: + text_color = hwp.RGBColor(0, 0, 0) + + hwp.HParameterSet.HStyle.CharShape.Height = hwp.PointToHwpUnit(style.font_size) + hwp.HParameterSet.HStyle.CharShape.Bold = style.font_bold + hwp.HParameterSet.HStyle.CharShape.TextColor = text_color + + # 3. 문단 모양 + align_map = {'left': 0, 'center': 1, 'right': 2, 'justify': 3} + hwp.HParameterSet.HStyle.ParaShape.Align = align_map.get(style.align, 3) + hwp.HParameterSet.HStyle.ParaShape.LineSpacing = int(style.line_spacing) + hwp.HParameterSet.HStyle.ParaShape.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before) + hwp.HParameterSet.HStyle.ParaShape.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after) + + # 4. 실행 + hwp.HAction.Execute("ModifyStyle", hwp.HParameterSet.HStyle.HSet) + print(f" ✓ 스타일 '{style.name}' 정의됨") + + except Exception as e: + print(f" [경고] 스타일 '{style.name}' 생성 실패: {e}") + + def get_style(self, role: str) -> HwpStyle: + """역할에 해당하는 스타일 반환""" + return self.styles.get(role, DEFAULT_STYLES.get('UNKNOWN')) + + def apply_char_shape(self, hwp, role: str): + """현재 선택 영역에 글자 모양 적용""" + style = self.get_style(role) + + try: + # RGB 색상 변환 + color_hex = style.font_color.lstrip('#') if style.font_color else '000000' + if len(color_hex) == 6: + r = int(color_hex[0:2], 16) + g = int(color_hex[2:4], 16) + b = int(color_hex[4:6], 16) + text_color = hwp.RGBColor(r, g, b) + else: + text_color = hwp.RGBColor(0, 0, 0) + + # 글자 모양 설정 + hwp.HAction.GetDefault("CharShape", hwp.HParameterSet.HCharShape.HSet) + hwp.HParameterSet.HCharShape.Height = hwp.PointToHwpUnit(style.font_size) + hwp.HParameterSet.HCharShape.Bold = style.font_bold + hwp.HParameterSet.HCharShape.TextColor = text_color + hwp.HAction.Execute("CharShape", hwp.HParameterSet.HCharShape.HSet) + + except Exception as e: + print(f" [경고] 글자 모양 적용 실패 ({role}): {e}") + + def apply_para_shape(self, hwp, role: str): + """현재 문단에 문단 모양 적용""" + style = self.get_style(role) + + try: + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + 'justify': "ParagraphShapeAlignJustify" + } + if style.align in align_actions: + hwp.HAction.Run(align_actions[style.align]) + + # 문단 모양 상세 설정 + hwp.HAction.GetDefault("ParagraphShape", hwp.HParameterSet.HParaShape.HSet) + p = hwp.HParameterSet.HParaShape + p.LineSpaceType = 0 # 퍼센트 + p.LineSpacing = int(style.line_spacing) + p.LeftMargin = hwp.MiliToHwpUnit(style.indent_left) + p.IndentMargin = hwp.MiliToHwpUnit(style.indent_first) + p.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before) + p.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after) + hwp.HAction.Execute("ParagraphShape", p.HSet) + + except Exception as e: + print(f" [경고] 문단 모양 적용 실패 ({role}): {e}") + + def apply_style(self, hwp, role: str): + """역할에 맞는 전체 스타일 적용 (글자 + 문단)""" + self.apply_char_shape(hwp, role) + self.apply_para_shape(hwp, role) + + def export_sty(self, hwp, output_path: str) -> bool: + """스타일 파일 내보내기 (현재 미지원)""" + print(f" [알림] .sty 내보내기는 현재 미지원") + return False + + +# ============================================================================= +# 번호 제거 유틸리티 +# ============================================================================= +import re + +NUMBERING_PATTERNS = { + 'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → "" + 'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → "" + 'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → "" + 'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → "" + 'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → "" + 'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → "" + 'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → "" + 'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → "" +} + +def strip_numbering(text: str, role: str) -> str: + """ + 역할에 따라 텍스트 앞의 번호/기호 제거 + HWP 개요 기능이 번호를 자동 생성하므로 중복 방지 + """ + if not text: + return text + + pattern = NUMBERING_PATTERNS.get(role) + if pattern: + return pattern.sub('', text).strip() + + return text.strip() + + +if __name__ == "__main__": + # 테스트 + print("=== 스타일 매핑 테스트 ===") + + gen = HwpStyGenerator() + + # HTML 스타일 시뮬레이션 + html_styles = { + 'H1': {'font_size': 20, 'color': '#1a365d', 'bold': True}, + 'H2': {'font_size': 16, 'color': '#2c5282', 'bold': True}, + 'BODY': {'font_size': 11, 'align': 'justify'}, + } + + gen.update_from_html(html_styles) + + for role, style in gen.styles.items(): + print(f"{role:15} → size={style.font_size}pt, bold={style.font_bold}, color=#{style.font_color}") \ No newline at end of file diff --git a/converters/hwpx_generator.py b/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/converters/hwpx_generator.py @@ -0,0 +1,431 @@ +""" +HWPX 파일 생성기 +StyleAnalyzer 결과를 받아 스타일이 적용된 HWPX 파일 생성 +""" + +import os +import zipfile +import xml.etree.ElementTree as ET +from typing import List, Dict, Optional +from dataclasses import dataclass +from pathlib import Path + +from style_analyzer import StyleAnalyzer, StyledElement +from hwp_style_mapping import HwpStyleMapper, HwpStyle, ROLE_TO_STYLE_NAME + + +@dataclass +class HwpxConfig: + """HWPX 생성 설정""" + paper_width: int = 59528 # A4 너비 (hwpunit, 1/7200 inch) + paper_height: int = 84188 # A4 높이 + margin_left: int = 8504 + margin_right: int = 8504 + margin_top: int = 5668 + margin_bottom: int = 4252 + default_font: str = "함초롬바탕" + default_font_size: int = 1000 # 10pt (hwpunit) + + +class HwpxGenerator: + """HWPX 파일 생성기""" + + def __init__(self, config: Optional[HwpxConfig] = None): + self.config = config or HwpxConfig() + self.mapper = HwpStyleMapper() + self.used_styles: set = set() + + def generate(self, elements: List[StyledElement], output_path: str) -> str: + """ + StyledElement 리스트로부터 HWPX 파일 생성 + + Args: + elements: StyleAnalyzer로 분류된 요소 리스트 + output_path: 출력 파일 경로 (.hwpx) + + Returns: + 생성된 파일 경로 + """ + # 사용된 스타일 수집 + self.used_styles = {e.role for e in elements} + + # 임시 디렉토리 생성 + temp_dir = Path(output_path).with_suffix('.temp') + temp_dir.mkdir(parents=True, exist_ok=True) + + try: + # HWPX 구조 생성 + self._create_mimetype(temp_dir) + self._create_meta_inf(temp_dir) + self._create_version(temp_dir) + self._create_header(temp_dir) + self._create_content(temp_dir, elements) + self._create_settings(temp_dir) + + # ZIP으로 압축 + self._create_hwpx(temp_dir, output_path) + + return output_path + + finally: + # 임시 파일 정리 + import shutil + if temp_dir.exists(): + shutil.rmtree(temp_dir) + + def _create_mimetype(self, temp_dir: Path): + """mimetype 파일 생성""" + mimetype_path = temp_dir / "mimetype" + mimetype_path.write_text("application/hwp+zip") + + def _create_meta_inf(self, temp_dir: Path): + """META-INF/manifest.xml 생성""" + meta_dir = temp_dir / "META-INF" + meta_dir.mkdir(exist_ok=True) + + manifest = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (temp_dir / "version.xml").write_text(version, encoding='utf-8') + + def _create_header(self, temp_dir: Path): + """Contents/header.xml 생성 (스타일 정의 포함)""" + contents_dir = temp_dir / "Contents" + contents_dir.mkdir(exist_ok=True) + + # 스타일별 속성 생성 + char_props_xml = self._generate_char_properties() + para_props_xml = self._generate_para_properties() + styles_xml = self._generate_styles_xml() + + header = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """글자 속성 XML 생성""" + lines = [f' '] + + # 기본 글자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 글자 속성 + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + height = int(style.font_size * 100) # pt → hwpunit + color = style.font_color.lstrip('#') + font_id = "1" if style.font_bold else "0" # 굵게면 함초롬돋움 + + lines.append(f''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """문단 속성 XML 생성""" + lines = [f' '] + + # 기본 문단 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 문단 속성 + align_map = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", "justify": "JUSTIFY"} + + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + align_val = align_map.get(style.align, "JUSTIFY") + line_spacing = int(style.line_spacing) + left_margin = int(style.indent_left * 100) + indent = int(style.indent_first * 100) + space_before = int(style.space_before * 100) + space_after = int(style.space_after * 100) + + lines.append(f''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """스타일 정의 XML 생성 (charPrIDRef, paraPrIDRef 참조)""" + lines = [f' '] + + # 기본 스타일 (id=0, 바탕글) + lines.append(' ') + + # 역할별 스타일 (charPrIDRef, paraPrIDRef 참조) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + return '\n'.join(lines) + + def _create_content(self, temp_dir: Path, elements: List[StyledElement]): + """Contents/section0.xml 생성 (본문 + 스타일 참조)""" + contents_dir = temp_dir / "Contents" + + # 문단 XML 생성 + paragraphs = [] + current_table = None + + # 역할 → 스타일 인덱스 매핑 생성 + role_to_idx = {role: idx for idx, role in enumerate(sorted(self.used_styles), start=1)} + + for elem in elements: + style = self.mapper.get_style(elem.role) + style_idx = role_to_idx.get(elem.role, 0) + + # 테이블 요소는 특수 처리 + if elem.role in ["TH", "TD", "TABLE_CAPTION", "TABLE", "FIGURE"]: + continue # 테이블/그림은 별도 처리 필요 + + # 일반 문단 + para_xml = self._create_paragraph(elem.text, style, style_idx) + paragraphs.append(para_xml) + + section = f""" + +{"".join(paragraphs)} +""" + + (contents_dir / "section0.xml").write_text(section, encoding='utf-8') + + def _create_paragraph(self, text: str, style: HwpStyle, style_idx: int) -> str: + """단일 문단 XML 생성""" + text = self._escape_xml(text) + + return f''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수문자 이스케이프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + settings = """ + + + + + +""" + + (temp_dir / "settings.xml").write_text(settings, encoding='utf-8') + + def _create_hwpx(self, temp_dir: Path, output_path: str): + """HWPX 파일 생성 (ZIP 압축)""" + with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: + # mimetype은 압축하지 않고 첫 번째로 + mimetype_path = temp_dir / "mimetype" + zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED) + + # 나머지 파일들 + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file == "mimetype": + continue + file_path = Path(root) / file + arcname = file_path.relative_to(temp_dir) + zf.write(file_path, arcname) + + +def convert_html_to_hwpx(html: str, output_path: str) -> str: + """ + HTML → HWPX 변환 메인 함수 + + Args: + html: HTML 문자열 + output_path: 출력 파일 경로 + + Returns: + 생성된 파일 경로 + """ + # 1. HTML 분석 → 역할 분류 + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html) + + print(f"📊 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # 2. HWPX 생성 + generator = HwpxGenerator() + result_path = generator.generate(elements, output_path) + + print(f"✅ 생성 완료: {result_path}") + return result_path + + +if __name__ == "__main__": + # 테스트 + test_html = """ + + +
+

건설·토목 측량 DX 실무지침

+

드론/UAV·GIS·지형/지반 모델 기반

+

2024년 1월

+
+ +

1. 개요

+

본 보고서는 건설 및 토목 분야의 측량 디지털 전환에 대한 실무 지침을 제공합니다.

+ +

1.1 배경

+

최근 드론과 GIS 기술의 발전으로 측량 업무가 크게 변화하고 있습니다.

+ +

1.1.1 기술 동향

+

1) 드론 측량의 발전

+

드론을 활용한 측량은 기존 방식 대비 효율성이 크게 향상되었습니다.

+ +

(1) RTK 드론

+

실시간 보정 기능을 갖춘 RTK 드론이 보급되고 있습니다.

+ +
    +
  • 고정밀 GPS 수신기 내장
  • +
  • 센티미터 단위 정확도
  • +
+ + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/converters/style_analyzer.py b/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 스타일 분석기 v3.0 +HTML 요소를 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변경사항: +- 글벗 HTML 구조 완벽 지원 (.sheet, .body-content) +- 머리말/꼬리말/페이지번호 제거 +- 강력한 중복 콘텐츠 필터링 +- 제목 계층 구조 정확한 인식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """문서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목차 + CONTENT = "content" # 본문 + + +@dataclass +class StyledElement: + """스타일이 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍스트 내용 + tag: str # 원본 HTML 태그 + html: str # 원본 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (이미지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 문서를 분석하여 역할 분류""" + + # 번호 패턴 정의 + PATTERNS = { + # 장 번호: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1단계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 중항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 세부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 세세부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나다: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나다 괄호: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원문자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페이지 번호 패턴: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # 꼬리말 패턴: "문서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍스트 패턴들 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "문서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (이미지 크기) + re.compile(r'^\[이미지 없음:.*\]$'), # "[이미지 없음: xxx]" + re.compile(r'^\[그림\s*\d+-\d+\]$'), # "[그림 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 중복 방지용 + self.document_title = "" # 문서 제목 (꼬리말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 문서 분석하여 역할 분류된 요소 리스트 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처리: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 문서 제목 추출 (꼬리말 패턴 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처리: 중복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처리 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처리 중...") + + # 1. 스크립트/스타일 태그 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 머리말/꼬리말 영역 제거 (글벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 실제 콘텐츠 헤더가 아닌 페이지 헤더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 머리말/꼬리말 {header_footer_count}개 제거") + + # 3. 숨겨진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 외부의 .sheet 제거 (글벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - 글벗 구조 감지: #raw-container 우선 사용") + # raw-container 외부의 모든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """문서 제목 추출 (꼬리말 패턴 감지용)""" + # 표지에서 제목 찾기 + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 문서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """머리말/꼬리말 텍스트인지 판단""" + if not text: + return False + + # 페이지 번호 패턴 + if self.PATTERNS['page_number'].match(text): + return True + + # "문서제목- 1 -" 패턴 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 문서 제목 + 페이지번호 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """건너뛸 텍스트인지 판단""" + if not text: + return True + + # 제거 패턴 체크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 머리말/꼬리말 체크 + if self._is_header_footer_text(text): + return True + + # 문서 제목만 있는 줄 (꼬리말에서 온 것) + if self.document_title and text.strip() == self.document_title: + # 이미 표지에서 처리했으면 스킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손인지 확인""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처리""" + + # 글벗 구조 (#raw-container) 우선 처리 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처리 (렌더링된 페이지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 일반 HTML 구조 처리 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """글벗 HTML #raw-container 구조 처리""" + print(" 📄 글벗 #raw-container 구조 처리 중...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목차 + toc = raw.find(id='box-toc') + if toc: + print(" - 목차 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요약 + summary = raw.find(id='box-summary') + if summary: + print(" - 요약 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 본문 + content = raw.find(id='box-content') + if content: + print(" - 본문 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """글벗 .sheet 페이지 구조 처리""" + print(f" 📄 .sheet 페이지 구조 처리 중... ({len(sheets)}페이지)") + + for i, sheet in enumerate(sheets): + # 페이지 내 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없으면 머리말/꼬리말 제외하고 처리 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 머리말/꼬리말 스킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """일반 HTML 구조 처리""" + print(" 📄 일반 HTML 구조 처리 중...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목차 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 본문 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처리""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정보 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목차 처리""" + # UL/OL 기반 목차 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벨 판단 (구체적 → 일반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 먼저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 그다음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 그다음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """본문 요소 재귀 처리""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 머리말/꼬리말 클래스 스킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테이블 특수 처리 + if tag_name == 'table': + self._process_table(element) + return + + # 그림 특수 처리 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍스트 추출 + text = self._get_direct_text(element) + + if text: + # 건너뛸 텍스트 체크 + if self._should_skip_text(text): + pass # 자식은 계속 처리 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처리 (컨테이너 태그) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍스트만 추출 (자식 컨테이너 제외)""" + # 제목 태그는 전체 텍스트 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컨테이너 태그는 직접 텍스트만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """중복 텍스트인지 확인""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍스트는 중복 허용 (번호 등) + if len(normalized) < 10: + return False + + # 첫 50자로 체크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠️ 중요: 패턴 매칭은 반드시 구체적인 것 → 일반적인 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태그 (HTML 태그 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 본문 (p, div 등) - 번호 패턴으로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 패턴 (구체적 → 일반 순서!) ------ + + # "1.1.1" 패턴 (가장 구체적 - 먼저 체크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 패턴 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 패턴 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나다..." 패턴 (숫자+공백+한글) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ 괄호 패턴 (구체적 → 일반 순서!) ------ + + # "(1)" 패턴 (괄호로 감싼 게 더 구체적 - 먼저 체크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 패턴 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한글 패턴 (구체적 → 일반 순서!) ------ + + # "가)" 패턴 (괄호가 더 구체적 - 먼저 체크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 패턴 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 기호 패턴 ------ + + # "①②③" 패턴 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ 기타 ------ + + # 강조 박스 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 일반 본문 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테이블 처리 - 구조 데이터 포함""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데이터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배경색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """그림 처리""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "이미지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처리: 불필요 요소 제거""" + print(f" 🧹 후처리 중... (처리 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍스트 제거 + if not elem.text or not elem.text.strip(): + continue + + # 머리말/꼬리말 텍스트 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 패턴 체크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처리 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요약""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 스타일 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
+ +
+

1 DX 개요와 기본 개념

+

1.1 측량 DX 프레임

+

1.1.1 측량 DX 발전 단계

+

1) Digitization 정의

+

본문 내용입니다. 이것은 충분히 긴 텍스트로 본문으로 인식되어야 합니다.

+

(1) 단계별 정의 및 진화

+

측량 기술의 발전은 장비의 변화와 성과물의 차원에 따라 구분된다.

+
+ +
+ +
+ +
+

① 첫 번째 항목

+ + + + +
표 1. 데이터 비교
구분내용
항목1설명1
+
+ +
+ + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결과") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요약") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..7c7e687 --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +handlers 패키지 +문서 유형별 처리 로직을 분리하여 관리 +""" \ No newline at end of file diff --git a/handlers/briefing/__init__.py b/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +기획서(briefing) 처리 모듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/handlers/briefing/processor.py b/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +기획서(briefing) 처리 로직 +- 1~2페이지 압축형 보고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """기획서 처리 클래스""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프트 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1단계: 구조 추출 프롬프트""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 문서를 분석하여 JSON 구조로 추출하세요. +원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5단계: 배치 계획 프롬프트""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요.""" + + def _get_step2_prompt(self) -> str: + """2단계: HTML 생성 프롬프트""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조를 각인된 양식의 HTML로 변환하세요. +Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페이지당 콘텐츠 양 체크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """기획서 생성""" + try: + if not content.strip(): + return {'error': '내용을 입력하거나 파일을 업로드해주세요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '총괄기획실') + additional_prompt = options.get('instruction', '') + + # ============== 1단계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요. + +## 원본 HTML +{content} + +--- +위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5단계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요. + +## 문서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페이지 수 +{page_option}페이지 + +--- +배치 계획 JSON만 출력하세요. 설명 없이 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2단계: HTML 생성 ============== + page_instructions = { + '1': '1페이지로 핵심 내용만 압축하여 작성하세요.', + '2': '2페이지로 작성하세요. 1페이지는 본문, 2페이지는 [첨부]입니다.', + 'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부] 형태로 분할합니다.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 문서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페이지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요. +코드 블록(```) 없이 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처리 검증 + if self._content_too_long(html_content): + compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다. +각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요. + +{html_content} + +코드 블록 없이 압축된 완전한 HTML만 출력하세요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 세션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """피드백 반영""" + try: + if not feedback.strip(): + return {'error': '피드백 내용을 입력해주세요.'} + + if not current_html: + return {'error': '수정할 HTML이 없습니다.'} + + refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다. + +사용자의 피드백을 반영하여 현재 HTML을 수정합니다. + +## 규칙 +1. 피드백에서 언급된 부분만 정확히 수정 +2. 나머지 구조와 스타일은 그대로 유지 +3. 완전한 HTML 문서로 출력 ( ~ ) +4. 코드 블록(```) 없이 순수 HTML만 출력 +5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능 + +## 원본 HTML (참고용) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 피드백 +{feedback} + +--- +위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데이터가 없습니다.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 문서에서 지정된 부분만 수정해주세요. + +## 전체 문서 (컨텍스트 파악용) +{current_html} + +## 수정 대상 텍스트 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판단: + - TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등) + - STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등) + +2. 반드시 다음 형식으로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 내용) + +3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이) +4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지) +5. 개조식 문체 유지 (~임, ~함, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/handlers/briefing/prompts/step1_5_plan.txt b/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원보고용 문서 구성 전문가입니다. +step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다. + +## 입력 +- step1에서 추출된 JSON 구조 데이터 + +## 출력 +- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페이지 (본문) - "왜? 무엇이 문제?" +- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정 +- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심 +- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장) + +### 2페이지~ (첨부) - "어떻게? 상세 기준" +- **첨부 제목**: 해당 페이지 내용을 대표하는 제목 +- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드 +- **bottom-box**: 해당 페이지 내용 요약 + +--- + +## 요소 역할 분류 기준 + +| 역할 | 설명 | 배치 | +|------|------|------| +| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box | +| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 | +| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 | +| 주의사항 | 실무상 유의점, 제언 | 1p 본문 | +| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box | +| 프로세스 | 단계별 절차, Step | 첨부 | +| 기준표 | 할인율, 판정 기준 등 표 | 첨부 | +| 체크리스트 | 항목별 점검사항 | 첨부 | +| 상세가이드 | 세부 설명, 예시 | 첨부 | +| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box | + +--- + +## 출력 JSON 스키마 +```json +{ + "page_plan": { + "page_1": { + "type": "본문", + "lead": { + "source_section": "원본 섹션명 또는 null", + "text": "lead-box에 들어갈 핵심 명제 문장" + }, + "sections": [ + { + "source": "원본 섹션 제목", + "role": "논리/근거 | 리스크 | 주의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 결론", + "source": "원본에서 가져올 문장 또는 조합할 키워드", + "text": "bottom-box에 들어갈 최종 문장" + } + }, + "page_2": { + "type": "첨부", + "title": "[첨부] 페이지 제목", + "sections": [ + { + "source": "원본 섹션 제목", + "role": "프로세스 | 기준표 | 체크리스트 | 상세가이드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "라벨 (예: 실무 핵심, 체크포인트 등)", + "source": "원본에서 가져올 문장", + "text": "bottom-box에 들어갈 최종 문장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판단 규칙 + +1. **프로세스/Step 있으면** → 무조건 첨부로 +2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능) +3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시 +4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장 +5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트 + +--- + +## 주의사항 + +1. 원본에 없는 내용 추가/추론 금지 +2. 원본 문장을 선별/조합만 허용 +3. 개조식 변환 필요한 문장 표시 (is_formal: true) +4. JSON만 출력 (설명 없이) \ No newline at end of file diff --git a/handlers/briefing/prompts/step1_extract.txt b/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 문서 구조 분석 전문가입니다. +사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다. + +## 규칙 + +1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지) +2. 문서의 논리적 구조를 정확히 파악 +3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이) + +## 출력 JSON 스키마 + +```json +{ + "title": "문서 제목 (원문 그대로)", + "title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)", + "department": "부서명 (있으면 추출, 없으면 '총괄기획실')", + "lead": { + "text": "핵심 요약/기조 텍스트 (원문 그대로)", + "highlight_keywords": ["강조할 키워드1", "키워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원문 그대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따라 다름 (아래 참조) + } + } + ], + "conclusion": { + "label": "라벨 (예: 핵심 결론, 요약 등)", + "text": "결론 텍스트 (원문 그대로, 한 문장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "키워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럼1", "컬럼2", "컬럼3"], + "rows": [ + { + "cells": [ + {"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하단 주석 (있으면)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true면 빨간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "설명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "내용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "내용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "단계명", "text": "설명"}, + {"number": 2, "title": "단계명", "text": "설명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질문?", "answer": "답변"}, + {"question": "질문?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["문단1 텍스트", "문단2 텍스트"] +} +``` + +## 중요 + +1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것 +2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 설명 없이 순수 JSON만 +4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑 diff --git a/handlers/briefing/prompts/step2_generate.txt b/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 보고서 생성 전문가입니다. +사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다. + +## 출력 규칙 + +1. 완전한 HTML 문서 출력 ( ~ ) +2. 코드 블록(```) 없이 **순수 HTML만** 출력 +3. JSON의 텍스트를 **그대로** 사용 (수정 금지) +4. 아래 CSS를 **정확히** 사용 + +## 페이지 옵션 + +- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정) +- **2페이지**: 1페이지 본문 + 2페이지 [첨부] +- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]... + +## HTML 템플릿 구조 + +```html + + + + + {{title}} + + + +
+ +
+

{{title}}

+
+
+
+
+
{{lead.text}} - 키워드 강조
+
+ +
+
{{conclusion.label}}
+
{{conclusion.text}}
+
+
+
- 1 -
+
+ + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
+
{{section.title}}
+
    +
  • {{item.keyword}}: {{item.text}} {{highlight}}
  • +
+
+``` + +### table → data-table +```html +
+
{{section.title}}
+ + + + + + + + + + + + + +
{{col1}}{{col2}}
{{text}}{{text}}
+
+``` +- badge가 있으면: `{{text}}` +- highlight가 true면: `class="highlight-red"` + +### grid → strategy-grid +```html +
+
{{section.title}}
+
+
+
{{item.title}}
+

{{item.text}} {{highlight}}

+
+
+
+``` + +### two-column → two-col +```html +
+
{{section.title}}
+
+
+
{{item.title}}
+

{{item.text}} {{highlight}}

+
+
+
+``` + +### process → process-container +```html +
+
{{section.title}}
+
+
+
{{step.number}}
+
{{step.title}}: {{step.text}}
+
+
+ +
+
+``` + +### qa → qa-grid +```html +
+
{{section.title}}
+
+
+ Q. {{question}}
+ A. {{answer}} +
+
+
+``` + +## 완전한 CSS (반드시 이대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페이지 본문 구성 논리 + +1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치 +2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등) +3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합 + +## 첨부 페이지 구성 + +1. **제목**: `

[첨부] 해당 내용에 맞는 제목

` +2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등) +3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약 + +## 중요 규칙 + +1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용: + - 위치 재편성, 통합/분할 + - 표 ↔ 본문 ↔ 리스트 형식 변환 + +2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결: + - lead-box, bottom-box, 표 내부, 리스트, 모든 문장 + - ❌ "~입니다", "~합니다", "~됩니다" + - ✅ "~임", "~함", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다" + - ✅ "부당행위계산 부인 및 증여세 부과 대상" + +3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2줄, 핵심 키워드만 로 강조 + +5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요 + +6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬 + +## 첨부 페이지 규칙 +- 제목: `

[첨부] 해당 페이지 내용에 맞는 제목

` +- 제목은 좌측 정렬, 16pt +- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함 \ No newline at end of file diff --git a/handlers/common.py b/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틸리티 함수 +- Claude API 호출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 클라이언트 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 호출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍스트에서 JSON 추출""" + # 코드 블록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍스트에서 HTML 추출""" + # 코드 블록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프트 파일 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/handlers/report/__init__.py b/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +보고서(report) 처리 모듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/handlers/report/processor.py b/handlers/report/processor.py new file mode 100644 index 0000000..eeaa2f7 --- /dev/null +++ b/handlers/report/processor.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +""" +보고서(report) 처리 로직 +- 다페이지 보고서 +- 원본 구조 유지 +- RAG 파이프라인 연동 (긴 문서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """보고서 처리 클래스""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프트 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """보고서 생성""" + try: + if not content.strip(): + return {'error': '내용이 비어있습니다.'} + + # 이미지 경로 변환 + processed_html = convert_image_paths(content) + + # router를 통해 분량에 따라 파이프라인 분기 + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """피드백 반영""" + try: + if not feedback.strip(): + return {'error': '피드백 내용을 입력해주세요.'} + + if not current_html: + return {'error': '수정할 HTML이 없습니다.'} + + refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다. + +사용자의 피드백을 반영하여 현재 HTML을 수정합니다. + +## 규칙 +1. 피드백에서 언급된 부분만 정확히 수정 +2. **페이지 구조(sheet, body-content, page-header 등)는 절대 변경하지 마세요** +3. 완전한 HTML 문서로 출력 ( ~ ) +4. 코드 블록(```) 없이 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 피드백 +{feedback} + +--- +위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (보고서용 - 페이지 구조 보존)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데이터가 없습니다.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 문서에서 지정된 부분만 수정해주세요. + +## 전체 문서 (컨텍스트 파악용) +{current_html[:5000]} + +## 수정 대상 텍스트 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페이지 구조(sheet, body-content, page-header, page-footer)를 변경하지 마세요** +2. 선택된 텍스트만 수정하고, 주변 HTML 태그는 그대로 유지 +3. 요청을 분석하여 수정 유형을 판단: + - TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등) + - STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가 등) + +4. 반드시 다음 형식으로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 내용만 - 선택된 텍스트의 수정본만) + +5. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이, 선택된 텍스트의 수정본만) +6. STRUCTURE인 경우: 해당 요소만 출력 (전체 페이지 구조 X) +7. 개조식 문체 유지 (~임, ~함, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/handlers/report/prompts/refine_selection.txt b/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원보고용 문서 구성 전문가입니다. +step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다. + +## 입력 +- step1에서 추출된 JSON 구조 데이터 + +## 출력 +- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페이지 (본문) - "왜? 무엇이 문제?" +- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정 +- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심 +- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장) + +### 2페이지~ (첨부) - "어떻게? 상세 기준" +- **첨부 제목**: 해당 페이지 내용을 대표하는 제목 +- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드 +- **bottom-box**: 해당 페이지 내용 요약 + +--- + +## 요소 역할 분류 기준 + +| 역할 | 설명 | 배치 | +|------|------|------| +| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box | +| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 | +| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 | +| 주의사항 | 실무상 유의점, 제언 | 1p 본문 | +| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box | +| 프로세스 | 단계별 절차, Step | 첨부 | +| 기준표 | 할인율, 판정 기준 등 표 | 첨부 | +| 체크리스트 | 항목별 점검사항 | 첨부 | +| 상세가이드 | 세부 설명, 예시 | 첨부 | +| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box | + +--- + +## 출력 JSON 스키마 +```json +{ + "page_plan": { + "page_1": { + "type": "본문", + "lead": { + "source_section": "원본 섹션명 또는 null", + "text": "lead-box에 들어갈 핵심 명제 문장" + }, + "sections": [ + { + "source": "원본 섹션 제목", + "role": "논리/근거 | 리스크 | 주의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 결론", + "source": "원본에서 가져올 문장 또는 조합할 키워드", + "text": "bottom-box에 들어갈 최종 문장" + } + }, + "page_2": { + "type": "첨부", + "title": "[첨부] 페이지 제목", + "sections": [ + { + "source": "원본 섹션 제목", + "role": "프로세스 | 기준표 | 체크리스트 | 상세가이드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "라벨 (예: 실무 핵심, 체크포인트 등)", + "source": "원본에서 가져올 문장", + "text": "bottom-box에 들어갈 최종 문장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판단 규칙 + +1. **프로세스/Step 있으면** → 무조건 첨부로 +2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능) +3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시 +4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장 +5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트 + +--- + +## 주의사항 + +1. 원본에 없는 내용 추가/추론 금지 +2. 원본 문장을 선별/조합만 허용 +3. 개조식 변환 필요한 문장 표시 (is_formal: true) +4. JSON만 출력 (설명 없이) \ No newline at end of file diff --git a/railway.json b/railway.json deleted file mode 100644 index 4667ab2..0000000 --- a/railway.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://railway.app/railway.schema.json", - "build": { - "builder": "NIXPACKS" - }, - "deploy": { - "startCommand": "gunicorn app:app", - "healthcheckPath": "/health", - "healthcheckTimeout": 100, - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 10 - } -} diff --git a/static/css/editor.css b/static/css/editor.css index fc9d982..013e99c 100644 --- a/static/css/editor.css +++ b/static/css/editor.css @@ -5,12 +5,25 @@ padding: 8px 12px; background: var(--ui-panel); border-bottom: 1px solid var(--ui-border); - gap: 4px; + gap: 6px; flex-wrap: wrap; } .format-bar.active { display: flex; } +/* 편집 바 2줄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + .format-btn { padding: 6px 10px; background: none; @@ -61,6 +74,26 @@ .format-btn:hover .tooltip { opacity: 1; } +/* 페이지 버튼 스타일 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페이지 브레이크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + /* 색상 선택기 */ .color-picker-btn { position: relative; @@ -185,6 +218,65 @@ animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; } +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '⤡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 이미지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크기 표시 툴팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + @keyframes toastIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } diff --git a/static/js/editor.js b/static/js/editor.js index 0aafac8..1294ff3 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -11,6 +11,7 @@ let redoStack = []; const MAX_HISTORY = 50; let isApplyingFormat = false; +// ===== 편집 바 HTML 생성 ===== // ===== 편집 바 HTML 생성 ===== function createFormatBar() { const formatBarHTML = ` @@ -21,52 +22,120 @@ function createFormatBar() { +
- - - - + + + +
- - + +
A - 글자 색상
A - 배경 색상
-
- - - -
- - - - -
- - - -
- + + + + + +
+ + + `; return formatBarHTML; } +// ===== 로컬 폰트 불러오기 ===== +async function loadLocalFonts() { + // API 지원 여부 확인 + if (!('queryLocalFonts' in window)) { + toast('⚠️ 이 브라우저는 폰트 불러오기를 지원하지 않습니다 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰트 불러오는 중...'); + + // 사용자 권한 요청 & 폰트 목록 가져오기 + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // 기존 옵션들의 값 수집 (중복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 중복 제거 (family 기준) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 내 컴퓨터 ────'; + fontSelect.appendChild(separator); + + // 새 폰트 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰트 추가됨 (총 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠️ 폰트 접근 권한이 거부되었습니다'); + } else { + console.error('폰트 로드 오류:', e); + toast('❌ 폰트 불러오기 실패: ' + e.message); + } + } +} + +// ===== 삽입 핸들러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + // ===== 표 삽입 모달 HTML 생성 ===== function createTableModal() { const modalHTML = ` @@ -457,11 +526,196 @@ function handleEditorKeydown(e) { } } + +// ===== 리사이즈 핸들 추가 함수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 초기 크기 설정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크기 표시 툴팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 리사이즈 핸들 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래그하여 크기 조절'; + + // DOM 구조 변경 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 리사이즈 이벤트 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비율 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 이미지인 경우 width, height 둘 다 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크기 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 내부에 편집용 스타일 주입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 리사이즈 컨테이너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 리사이즈 핸들 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '⤡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핸들 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 이미지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크기 표시 툴팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* 열 리사이즈 핸들 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 편집 중 하이라이트 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 편집 이벤트 바인딩 ===== // ===== iframe 편집 이벤트 바인딩 ===== function bindIframeEditEvents() { const doc = getIframeDoc(); if (!doc) return; + // 편집용 스타일 주입 + injectEditStyles(doc); + // 키보드 이벤트 doc.removeEventListener('keydown', handleEditorKeydown); doc.addEventListener('keydown', handleEditorKeydown); @@ -479,6 +733,81 @@ function bindIframeEditEvents() { } clearActiveBlock(); }); + + // ===== 표에 리사이즈 핸들 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // 열 리사이즈 추가 + }); + + // ===== 이미지에 리사이즈 핸들 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 열 리사이즈 핸들 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테이블에 position relative 설정 + table.style.position = 'relative'; + + // 첫 번째 행의 셀들을 기준으로 열 핸들 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 열은 제외 + + // 이미 핸들이 있으면 스킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 다음 열도 조정 (테이블 전체 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 열 너비 조절됨'); + } + }); } // ===== 편집 모드 토글 ===== @@ -533,7 +862,7 @@ function toggleEditMode() { function initEditor() { // 편집 바가 없으면 생성 if (!document.getElementById('formatBar')) { - const previewContainer = document.querySelector('.preview-container'); + const previewContainer = document.querySelector('.main'); if (previewContainer) { previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); } @@ -550,5 +879,330 @@ function initEditor() { console.log('Editor initialized'); } +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠️ 문서가 로드되지 않았습니다'); + return; + } + + // ===== 현재 스크롤 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠️ 정렬할 본문 페이지가 없습니다'); + return; + } + + toast('지능형 정렬 실행 중...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 보고서 제목 추출 + let reportTitle = "보고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 콘텐츠 수집 (표지 제외) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 설정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목차"; + let pageNum = 1; + + // 5. 새 페이지 생성 함수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
+ `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페이지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페이지 브레이크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 남은 공간 부족하면 새 페이지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 노드 추가 + currentBody.appendChild(node); + + // 전 페이지로 강제 이동 설정된 경우 스킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높이 초과 시 새 페이지로 이동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 편집 모드였으면 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데이트 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 스크롤 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페이지)'); + + + } catch (e) { + console.error('smartAlign 오류:', e); + toast('❌ 정렬 중 오류: ' + e.message); + } + }, 100); +} + +// ===== 새페이지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠️ 문서가 로드되지 않았습니다'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠️ 분리할 위치를 클릭하세요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠️ 본문 블록을 먼저 클릭하세요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 클릭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠️ 이동할 내용이 없습니다'); + return; + } + + // 다음 페이지 찾기 + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
+ `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순으로 맨 앞에 삽입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번째 요소에 페이지 브레이크 마커 추가 (나중에 지능형 정렬이 존중함) + targetEl.classList.add('page-break-forced'); + + // 페이지 번호만 재정렬 (smartAlign 호출 안 함!) + renumberPages(doc); + + toast('✅ 다음 페이지로 이동됨'); +} + + +// ===== 전페이지로 이동 (즉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠️ 문서가 로드되지 않았습니다'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠️ 이동할 블록을 클릭하세요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 찾기 + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠️ 본문 블록을 먼저 클릭하세요'); + return; + } + + saveState(); + + // 현재 sheet 찾기 + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 이전 페이지 찾기 (표지 제외) + if (currentIndex <= 1) { + toast('⚠️ 이전 페이지가 없습니다'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠️ 이전 페이지에 본문 영역이 없습니다'); + return; + } + + // 요소를 이전 페이지 맨 아래로 이동 + prevBody.appendChild(targetEl); + + // 현재 페이지가 비었으면 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페이지 번호 재정렬 + renumberPages(doc); + + toast('✅ 전 페이지로 이동됨'); +} + +// ===== 페이지 번호 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번호 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + // DOM 로드 시 초기화 -document.addEventListener('DOMContentLoaded', initEditor); \ No newline at end of file +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/templates/index.html b/templates/index.html index 0be4db3..e496d71 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1074,6 +1074,8 @@ } + + @@ -1217,17 +1219,7 @@
- -
- - - -
- - - -
- +
@@ -1531,7 +1523,8 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ html: html, - doc_type: currentDocType + doc_type: currentDocType, + style_grouping: true }) }); @@ -1628,7 +1621,8 @@ body: JSON.stringify({ current_html: generatedHTML, selected_text: selectedText, - request: request + request: request, + doc_type: currentDocType // ← 추가 }) }); @@ -1696,10 +1690,38 @@ if (targetEl.nodeType === 3) { targetEl = targetEl.parentElement; } - // 적절한 부모 찾기 (div, section 등) - const parent = targetEl.closest('.lead-box, .section, .info-table, .data-table, .process-flow') || targetEl.closest('div'); - if (parent) { - parent.outerHTML = modifiedContent; + + if (currentDocType === 'report') { + // ===== 보고서: 안전한 부모만 교체 ===== + // sheet, body-content 등 페이지 구조는 절대 건드리지 않음 + const dangerousClasses = ['sheet', 'body-content', 'page-header', 'page-footer', 'a4-preview']; + + // p, li, td, h1~h6 등 안전한 요소 찾기 + const safeParent = targetEl.closest('p, li, td, th, h1, h2, h3, h4, h5, h6, ul, ol, table, figure'); + + if (safeParent && !dangerousClasses.some(cls => safeParent.classList.contains(cls))) { + // 선택된 요소 뒤에 새 구조 삽입 + safeParent.insertAdjacentHTML('afterend', modifiedContent); + // 기존 요소는 유지하거나 숨김 처리 + // safeParent.style.display = 'none'; // 선택: 기존 숨기기 + safeParent.remove(); // 또는 삭제 + console.log('보고서 - 안전한 구조 교체:', safeParent.tagName); + } else { + // 안전한 부모를 못 찾으면 텍스트 앞에 구조 삽입 + console.log('보고서 - 안전한 부모 없음, 선택 위치에 삽입'); + const range = selectedRange.cloneRange(); + range.deleteContents(); + const temp = document.createElement('div'); + temp.innerHTML = modifiedContent; + range.insertNode(temp.firstElementChild || temp); + } + + } else { + // ===== 기획서: 기존 로직 ===== + const parent = targetEl.closest('.lead-box, .section, .info-table, .data-table, .process-flow') || targetEl.closest('div'); + if (parent) { + parent.outerHTML = modifiedContent; + } } } } @@ -2196,6 +2218,93 @@ URL.revokeObjectURL(url); } + // ===== HWP 다운로드 (스타일 그루핑) ===== + async function downloadHwpStyled() { + if (!generatedHTML) { + alert('먼저 문서를 생성해주세요.'); + return; + } + + // 편집된 내용 가져오기 + const frame = document.getElementById('previewFrame'); + const html = frame.contentDocument ? + '' + frame.contentDocument.documentElement.outerHTML : + generatedHTML; + + try { + setStatus('HWP 변환 중...', true); + + const response = await fetch('/export-hwp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + html: html, + doc_type: currentDocType || 'report', + style_grouping: true // ★ 스타일 그루핑 활성화 + }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'HWP 변환 실패'); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `report_${new Date().toISOString().slice(0,10)}.hwp`; + a.click(); + URL.revokeObjectURL(url); + + setStatus('HWP 저장 완료', true); + + } catch (error) { + alert('HWP 변환 오류: ' + error.message); + setStatus('오류 발생', false); + } + } + + // ===== 스타일 분석 미리보기 (선택사항) ===== + async function analyzeStyles() { + if (!generatedHTML) { + alert('먼저 문서를 생성해주세요.'); + return; + } + + const frame = document.getElementById('previewFrame'); + const html = frame.contentDocument ? + '' + frame.contentDocument.documentElement.outerHTML : + generatedHTML; + + try { + const response = await fetch('/analyze-styles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: html }) + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + // 결과 표시 + let summary = `📊 스타일 분석 결과\n\n총 ${data.total_elements}개 요소\n\n`; + summary += Object.entries(data.summary) + .map(([k, v]) => `${k}: ${v}개`) + .join('\n'); + + alert(summary); + console.log('스타일 분석 상세:', data); + + } catch (error) { + alert('분석 오류: ' + error.message); + } + } + + function printDoc() { const frame = document.getElementById('previewFrame'); if (frame.contentWindow) {