📦 Initialize Geulbeot structure and merge Prompts & test projects
This commit is contained in:
32
03. Code/geulbeot_2nd/.gitignore
vendored
Normal file
32
03. Code/geulbeot_2nd/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# API Keys - Gitea에 올리지 않기!
|
||||
api_keys.json
|
||||
1
03. Code/geulbeot_2nd/Procfile
Normal file
1
03. Code/geulbeot_2nd/Procfile
Normal file
@@ -0,0 +1 @@
|
||||
web: gunicorn app:app
|
||||
82
03. Code/geulbeot_2nd/README.md
Normal file
82
03. Code/geulbeot_2nd/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 글벗 Light v1.0
|
||||
|
||||
상시 업무용 HTML 보고서 자동 생성기
|
||||
|
||||
## 🎯 기능
|
||||
|
||||
- **문서 입력**: HTML 파일 업로드 또는 텍스트 직접 입력
|
||||
- **페이지 옵션**: 1페이지 / 2페이지 / N페이지 선택
|
||||
- **Claude API**: 각인된 양식으로 자동 변환
|
||||
- **다운로드**: HTML, PDF 지원
|
||||
- **HWP 변환**: 로컬 스크립트 제공
|
||||
|
||||
## 🚀 Railway 배포
|
||||
|
||||
### 1. GitHub에 푸시
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
git remote add origin https://github.com/YOUR_USERNAME/geulbeot-light.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 2. Railway 연동
|
||||
|
||||
1. [Railway](https://railway.app) 접속
|
||||
2. "New Project" → "Deploy from GitHub repo"
|
||||
3. 저장소 선택
|
||||
4. 환경변수 설정:
|
||||
- `ANTHROPIC_API_KEY`: Claude API 키
|
||||
- `SECRET_KEY`: 임의의 비밀 키
|
||||
|
||||
### 3. 배포 완료
|
||||
|
||||
Railway가 자동으로 빌드 및 배포합니다.
|
||||
|
||||
## 🖥️ 로컬 실행
|
||||
|
||||
```bash
|
||||
# 가상환경 생성
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 패키지 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 환경변수 설정
|
||||
export ANTHROPIC_API_KEY="your-api-key"
|
||||
|
||||
# 실행
|
||||
python app.py
|
||||
```
|
||||
|
||||
http://localhost:5000 접속
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
geulbeot-light/
|
||||
├── app.py # Flask 메인 앱
|
||||
├── templates/
|
||||
│ ├── index.html # 메인 페이지
|
||||
│ └── hwp_guide.html # HWP 변환 가이드
|
||||
├── prompts/
|
||||
│ └── system_prompt.txt # Claude 시스템 프롬프트
|
||||
├── requirements.txt
|
||||
├── Procfile
|
||||
├── railway.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎨 각인된 양식
|
||||
|
||||
- A4 인쇄 최적화 (210mm × 297mm)
|
||||
- Noto Sans KR 폰트
|
||||
- Navy 계열 색상 (#1a365d 기본)
|
||||
- 구성요소: page-header, lead-box, section, data-table, bottom-box 등
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
Private - GPD 내부 사용
|
||||
17
03. Code/geulbeot_2nd/api_config.py
Normal file
17
03. Code/geulbeot_2nd/api_config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""API 키 관리 - api_keys.json에서 읽기"""
|
||||
import json
|
||||
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 {}
|
||||
|
||||
API_KEYS = load_api_keys()
|
||||
492
03. Code/geulbeot_2nd/app.py
Normal file
492
03. Code/geulbeot_2nd/app.py
Normal file
@@ -0,0 +1,492 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
글벗 Light v2.0
|
||||
2단계 API 변환 + 대화형 피드백 시스템
|
||||
|
||||
Flask + Claude API + Railway
|
||||
"""
|
||||
|
||||
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 api_config import API_KEYS
|
||||
|
||||
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', ''))
|
||||
|
||||
|
||||
# ============== 프롬프트 로드 ==============
|
||||
|
||||
def load_prompt(filename):
|
||||
"""프롬프트 파일 로드"""
|
||||
prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename)
|
||||
try:
|
||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_step1_prompt():
|
||||
"""1단계: 구조 추출 프롬프트"""
|
||||
prompt = load_prompt('step1_extract.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
# 기본 프롬프트 (파일 없을 경우)
|
||||
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
|
||||
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
|
||||
|
||||
|
||||
def get_step2_prompt():
|
||||
"""2단계: HTML 생성 프롬프트"""
|
||||
prompt = load_prompt('step2_generate.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
# 기본 프롬프트 (파일 없을 경우)
|
||||
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
|
||||
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
|
||||
|
||||
def get_step1_5_prompt():
|
||||
"""1.5단계: 배치 계획 프롬프트"""
|
||||
prompt = load_prompt('step1_5_plan.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
|
||||
|
||||
def get_refine_prompt():
|
||||
"""피드백 반영 프롬프트"""
|
||||
return """당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. 나머지 구조와 스타일은 그대로 유지
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
|
||||
# ============== API 호출 함수 ==============
|
||||
|
||||
def call_claude(system_prompt, user_message, max_tokens=8000):
|
||||
"""Claude API 호출"""
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}]
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
|
||||
def extract_json(text):
|
||||
"""텍스트에서 JSON 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```json' in text:
|
||||
text = text.split('```json')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
text = text.split('```')[1].split('```')[0]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 부분만 추출 시도
|
||||
match = re.search(r'\{[\s\S]*\}', text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def extract_html(text):
|
||||
"""텍스트에서 HTML 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```html' in text:
|
||||
text = text.split('```html')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
parts = text.split('```')
|
||||
if len(parts) >= 2:
|
||||
text = parts[1]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# <!DOCTYPE 또는 <html로 시작하는지 확인
|
||||
if not text.startswith('<!DOCTYPE') and not text.startswith('<html'):
|
||||
# HTML 부분만 추출
|
||||
match = re.search(r'(<!DOCTYPE html[\s\S]*</html>)', text, re.IGNORECASE)
|
||||
if match:
|
||||
text = match.group(1)
|
||||
|
||||
return text
|
||||
|
||||
def content_too_long(html, max_sections_per_page=4):
|
||||
"""페이지당 콘텐츠 양 체크"""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
sheets = soup.find_all('div', class_='sheet')
|
||||
for sheet in sheets:
|
||||
sections = sheet.find_all('div', class_='section')
|
||||
if len(sections) > max_sections_per_page:
|
||||
return True
|
||||
|
||||
# 리스트 항목 체크
|
||||
all_li = sheet.find_all('li')
|
||||
if len(all_li) > 12:
|
||||
return True
|
||||
|
||||
# 프로세스 스텝 체크
|
||||
steps = sheet.find_all('div', class_='process-step')
|
||||
if len(steps) > 6:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ============== 라우트 ==============
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""메인 페이지"""
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/generate', methods=['POST'])
|
||||
def generate():
|
||||
"""보고서 생성 API (2단계 처리)"""
|
||||
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] 형태로 분할합니다.'
|
||||
}
|
||||
|
||||
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 문서를 생성하세요.
|
||||
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력."""
|
||||
|
||||
step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000)
|
||||
html_content = extract_html(step2_response)
|
||||
|
||||
# 후처리 검증: 콘텐츠가 너무 많으면 압축 재요청
|
||||
if content_too_long(html_content):
|
||||
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
|
||||
각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요.
|
||||
텍스트를 줄이거나 덜 중요한 내용은 생략하세요.
|
||||
|
||||
{html_content}
|
||||
|
||||
코드 블록 없이 압축된 완전한 HTML만 출력하세요."""
|
||||
|
||||
compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000)
|
||||
html_content = extract_html(compress_response)
|
||||
|
||||
# 세션에 저장 (피드백용)
|
||||
session['original_html'] = content
|
||||
session['current_html'] = html_content
|
||||
session['structure_json'] = json.dumps(structure_json, ensure_ascii=False)
|
||||
session['conversation'] = []
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'html': html_content,
|
||||
'structure': structure_json
|
||||
})
|
||||
|
||||
except anthropic.APIError as e:
|
||||
return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return jsonify({'error': f'서버 오류: {str(e)}', 'trace': traceback.format_exc()}), 500
|
||||
|
||||
|
||||
@app.route('/refine', methods=['POST'])
|
||||
def refine():
|
||||
"""피드백 반영 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', '')
|
||||
|
||||
# 피드백 반영 프롬프트
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. 나머지 구조와 스타일은 그대로 유지
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능
|
||||
|
||||
## 원본 HTML (참고용)
|
||||
{original_html[:3000] if original_html else '없음'}...
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
# 세션 업데이트
|
||||
session['current_html'] = new_html
|
||||
|
||||
# 대화 히스토리 저장
|
||||
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
|
||||
|
||||
|
||||
@app.route('/refine-selection', methods=['POST'])
|
||||
def refine_selection():
|
||||
"""선택된 부분만 수정"""
|
||||
try:
|
||||
data = request.json
|
||||
current_html = data.get('current_html', '')
|
||||
selected_text = data.get('selected_text', '')
|
||||
user_request = data.get('request', '')
|
||||
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return jsonify({'error': '필수 데이터가 없습니다.'}), 400
|
||||
|
||||
# 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
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/download/html', methods=['POST'])
|
||||
def download_html():
|
||||
"""HTML 파일 다운로드"""
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.html'
|
||||
|
||||
return Response(
|
||||
html_content,
|
||||
mimetype='text/html',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
|
||||
|
||||
@app.route('/download/pdf', methods=['POST'])
|
||||
def download_pdf():
|
||||
"""PDF 파일 다운로드"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html_content = request.form.get('html', '')
|
||||
if not html_content:
|
||||
return "No content", 400
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
HTML(string=html_content).write_pdf(pdf_buffer)
|
||||
pdf_buffer.seek(0)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'report_{timestamp}.pdf'
|
||||
|
||||
return Response(
|
||||
pdf_buffer.getvalue(),
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
||||
)
|
||||
except ImportError:
|
||||
return jsonify({'error': 'PDF 변환 미지원. HTML 다운로드 후 브라우저에서 인쇄하세요.'}), 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('/health')
|
||||
def health():
|
||||
"""헬스 체크"""
|
||||
return jsonify({'status': 'healthy', 'version': '2.0.0'})
|
||||
|
||||
|
||||
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)
|
||||
104
03. Code/geulbeot_2nd/prompts/step1_5_plan.txt
Normal file
104
03. Code/geulbeot_2nd/prompts/step1_5_plan.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
122
03. Code/geulbeot_2nd/prompts/step1_extract.txt
Normal file
122
03. Code/geulbeot_2nd/prompts/step1_extract.txt
Normal file
@@ -0,0 +1,122 @@
|
||||
당신은 HTML 문서 구조 분석 전문가입니다.
|
||||
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
|
||||
2. 문서의 논리적 구조를 정확히 파악
|
||||
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
|
||||
|
||||
## 출력 JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "문서 제목 (원문 그대로)",
|
||||
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
|
||||
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
|
||||
"lead": {
|
||||
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
|
||||
"highlight_keywords": ["강조할 키워드1", "키워드2"]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "섹션 제목 (원문 그대로)",
|
||||
"type": "list | table | grid | process | qa | text",
|
||||
"content": {
|
||||
// type에 따라 다름 (아래 참조)
|
||||
}
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"label": "라벨 (예: 핵심 결론, 요약 등)",
|
||||
"text": "결론 텍스트 (원문 그대로, 한 문장)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 섹션 type별 content 구조
|
||||
|
||||
### type: "list"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
|
||||
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "table"
|
||||
```json
|
||||
{
|
||||
"columns": ["컬럼1", "컬럼2", "컬럼3"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
|
||||
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
|
||||
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnote": "표 하단 주석 (있으면)"
|
||||
}
|
||||
```
|
||||
- badge 값: "safe" | "caution" | "risk" | null
|
||||
- highlight: true면 빨간색 강조
|
||||
|
||||
### type: "grid"
|
||||
```json
|
||||
{
|
||||
"columns": 2,
|
||||
"items": [
|
||||
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
|
||||
{"title": "② 항목 제목", "text": "설명", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "two-column"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
|
||||
{"title": "② 제목", "text": "내용", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "process"
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{"number": 1, "title": "단계명", "text": "설명"},
|
||||
{"number": 2, "title": "단계명", "text": "설명"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "qa"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"question": "질문?", "answer": "답변"},
|
||||
{"question": "질문?", "answer": "답변"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "text"
|
||||
```json
|
||||
{
|
||||
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
|
||||
}
|
||||
```
|
||||
|
||||
## 중요
|
||||
|
||||
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
|
||||
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
|
||||
3. **JSON만 출력** - 설명 없이 순수 JSON만
|
||||
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑
|
||||
440
03. Code/geulbeot_2nd/prompts/step2_generate.txt
Normal file
440
03. Code/geulbeot_2nd/prompts/step2_generate.txt
Normal file
@@ -0,0 +1,440 @@
|
||||
당신은 HTML 보고서 생성 전문가입니다.
|
||||
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
|
||||
|
||||
## 출력 규칙
|
||||
|
||||
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
|
||||
2. 코드 블록(```) 없이 **순수 HTML만** 출력
|
||||
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
|
||||
4. 아래 CSS를 **정확히** 사용
|
||||
|
||||
## 페이지 옵션
|
||||
|
||||
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
|
||||
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
|
||||
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
|
||||
|
||||
## HTML 템플릿 구조
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* 아래 CSS 전체 포함 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">{{department}}</div>
|
||||
<div class="header-right">{{title_en}}</div>
|
||||
</header>
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{{title}}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>{{lead.text}} - <b>키워드</b> 강조</div>
|
||||
</div>
|
||||
<!-- sections -->
|
||||
<div class="bottom-box">
|
||||
<div class="bottom-left">{{conclusion.label}}</div>
|
||||
<div class="bottom-right">{{conclusion.text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="page-footer">- 1 -</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 섹션 type별 HTML 변환
|
||||
|
||||
### list → ul/li
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<ul>
|
||||
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### table → data-table
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">{{col1}}</th>
|
||||
<th width="25%">{{col2}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{text}}</strong></td>
|
||||
<td class="highlight-red">{{text}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
|
||||
- highlight가 true면: `class="highlight-red"`
|
||||
|
||||
### grid → strategy-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### two-column → two-col
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="two-col">
|
||||
<div class="info-box">
|
||||
<div class="info-box-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### process → process-container
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="process-container">
|
||||
<div class="process-step">
|
||||
<div class="step-num">{{step.number}}</div>
|
||||
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
|
||||
</div>
|
||||
<div class="arrow">▼</div>
|
||||
<!-- 반복 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### qa → qa-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="qa-grid">
|
||||
<div class="qa-item">
|
||||
<strong>Q. {{question}}</strong><br>
|
||||
A. {{answer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 완전한 CSS (반드시 이대로 사용)
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-navy: #1a365d;
|
||||
--secondary-navy: #2c5282;
|
||||
--accent-navy: #3182ce;
|
||||
--dark-gray: #2d3748;
|
||||
--medium-gray: #4a5568;
|
||||
--light-gray: #e2e8f0;
|
||||
--bg-light: #f7fafc;
|
||||
--text-black: #1a202c;
|
||||
--border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; gap: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
font-size: 9pt;
|
||||
color: var(--medium-gray);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 23pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary-navy);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary-navy);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 11.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lead-box b { color: var(--primary-navy); font-weight: 700; }
|
||||
|
||||
.body-content { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-navy);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--secondary-navy);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 19pt;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--primary-navy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul { list-style: none; padding-left: 10px; }
|
||||
|
||||
li {
|
||||
font-size: 10.5pt;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--secondary-navy);
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.bottom-box {
|
||||
border: 1.5px solid var(--border-color);
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
min-height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
width: 18%;
|
||||
background-color: var(--primary-navy);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10.5pt;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
width: 82%;
|
||||
background-color: var(--bg-light);
|
||||
padding: 12px 18px;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.bottom-right b { display: inline; }
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 10mm;
|
||||
left: 20mm;
|
||||
right: 20mm;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 8.5pt;
|
||||
color: var(--medium-gray);
|
||||
border-top: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
b { font-weight: 700; color: var(--primary-navy); display: inline; }
|
||||
.keyword { font-weight: 600; color: var(--text-black); }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9.5pt;
|
||||
border-top: 2px solid var(--primary-navy);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--secondary-navy);
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 7px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight-red { color: #c53030; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
|
||||
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
|
||||
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
|
||||
|
||||
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
|
||||
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
|
||||
.qa-item strong { color: var(--primary-navy); }
|
||||
|
||||
.two-col { display: flex; gap: 12px; margin-top: 6px; }
|
||||
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
|
||||
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
|
||||
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
|
||||
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
|
||||
.step-content strong { color: var(--primary-navy); font-weight: 600; }
|
||||
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
|
||||
```
|
||||
|
||||
## 1페이지 본문 구성 논리
|
||||
|
||||
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
|
||||
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
|
||||
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
|
||||
|
||||
## 첨부 페이지 구성
|
||||
|
||||
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
|
||||
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
|
||||
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
|
||||
|
||||
## 중요 규칙
|
||||
|
||||
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
|
||||
- 위치 재편성, 통합/분할
|
||||
- 표 ↔ 본문 ↔ 리스트 형식 변환
|
||||
|
||||
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
|
||||
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
|
||||
- ❌ "~입니다", "~합니다", "~됩니다"
|
||||
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
|
||||
- 예시:
|
||||
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
|
||||
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
|
||||
|
||||
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
|
||||
|
||||
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
|
||||
|
||||
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
|
||||
|
||||
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
|
||||
|
||||
## 첨부 페이지 규칙
|
||||
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
|
||||
- 제목은 좌측 정렬, 16pt
|
||||
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함
|
||||
13
03. Code/geulbeot_2nd/railway.json
Normal file
13
03. Code/geulbeot_2nd/railway.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "NIXPACKS"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "gunicorn app:app",
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 100,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10
|
||||
}
|
||||
}
|
||||
5
03. Code/geulbeot_2nd/requirements.txt
Normal file
5
03. Code/geulbeot_2nd/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
flask==3.0.0
|
||||
anthropic==0.39.0
|
||||
gunicorn==21.2.0
|
||||
python-dotenv==1.0.0
|
||||
weasyprint==60.1
|
||||
205
03. Code/geulbeot_2nd/static/css/editor.css
Normal file
205
03. Code/geulbeot_2nd/static/css/editor.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* ===== 편집 바 스타일 ===== */
|
||||
.format-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--ui-panel);
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.format-bar.active { display: flex; }
|
||||
|
||||
.format-btn {
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ui-text);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-btn:hover { background: var(--ui-hover); }
|
||||
.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); }
|
||||
|
||||
.format-select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.format-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--ui-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* 툴팁 */
|
||||
.format-btn .tooltip {
|
||||
position: absolute;
|
||||
bottom: -28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.format-btn:hover .tooltip { opacity: 1; }
|
||||
|
||||
/* 색상 선택기 */
|
||||
.color-picker-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-picker-btn input[type="color"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 편집 모드 활성 블록 */
|
||||
.active-block {
|
||||
outline: 2px dashed var(--ui-accent) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 표 삽입 모달 */
|
||||
.table-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-modal.active { display: flex; }
|
||||
|
||||
.table-modal-content {
|
||||
background: var(--ui-panel);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 320px;
|
||||
border: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.table-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ui-text);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-modal-row label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--ui-dim);
|
||||
}
|
||||
|
||||
.table-modal-row input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-bg);
|
||||
color: var(--ui-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-modal-row input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.table-modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.table-modal-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-modal-btn.primary {
|
||||
background: var(--ui-accent);
|
||||
color: #003300;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-modal-btn.secondary {
|
||||
background: var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
/* 토스트 메시지 */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 인쇄 시 숨김 */
|
||||
@media print {
|
||||
.format-bar,
|
||||
.table-modal,
|
||||
.toast-container {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
554
03. Code/geulbeot_2nd/static/js/editor.js
Normal file
554
03. Code/geulbeot_2nd/static/js/editor.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* 글벗 Light - 편집 바 모듈
|
||||
* editor.js
|
||||
*/
|
||||
|
||||
// ===== 전역 변수 =====
|
||||
let isEditing = false;
|
||||
let activeBlock = null;
|
||||
let historyStack = [];
|
||||
let redoStack = [];
|
||||
const MAX_HISTORY = 50;
|
||||
let isApplyingFormat = false;
|
||||
|
||||
// ===== 편집 바 HTML 생성 =====
|
||||
function createFormatBar() {
|
||||
const formatBarHTML = `
|
||||
<div class="format-bar" id="formatBar">
|
||||
<select class="format-select" id="fontFamily" onchange="applyFontFamily(this.value)">
|
||||
<option value="Noto Sans KR">Noto Sans KR</option>
|
||||
<option value="맑은 고딕">맑은 고딕</option>
|
||||
<option value="나눔고딕">나눔고딕</option>
|
||||
<option value="돋움">돋움</option>
|
||||
</select>
|
||||
<input type="number" class="format-select" id="fontSizeInput" value="12" min="8" max="72"
|
||||
style="width:55px;" onchange="applyFontSizeInput(this.value)">
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" id="btnBold" onclick="formatText('bold')"><b>B</b><span class="tooltip">굵게 (Ctrl+B)</span></button>
|
||||
<button class="format-btn" id="btnItalic" onclick="formatText('italic')"><i>I</i><span class="tooltip">기울임 (Ctrl+I)</span></button>
|
||||
<button class="format-btn" id="btnUnderline" onclick="formatText('underline')"><u>U</u><span class="tooltip">밑줄 (Ctrl+U)</span></button>
|
||||
<button class="format-btn" id="btnStrike" onclick="formatText('strikeThrough')"><s>S</s><span class="tooltip">취소선</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="adjustLetterSpacing(-0.5)">A⇠<span class="tooltip">자간 줄이기</span></button>
|
||||
<button class="format-btn" onclick="adjustLetterSpacing(0.5)">A⇢<span class="tooltip">자간 늘리기</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<div class="color-picker-btn format-btn">
|
||||
<span style="border-bottom:3px solid #000;">A</span>
|
||||
<input type="color" id="textColor" value="#000000" onchange="applyTextColor(this.value)">
|
||||
<span class="tooltip">글자 색상</span>
|
||||
</div>
|
||||
<div class="color-picker-btn format-btn">
|
||||
<span style="background:#ff0;padding:0 4px;">A</span>
|
||||
<input type="color" id="bgColor" value="#ffff00" onchange="applyBgColor(this.value)">
|
||||
<span class="tooltip">배경 색상</span>
|
||||
</div>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="formatText('justifyLeft')">⫷<span class="tooltip">왼쪽 정렬</span></button>
|
||||
<button class="format-btn" onclick="formatText('justifyCenter')">☰<span class="tooltip">가운데 정렬</span></button>
|
||||
<button class="format-btn" onclick="formatText('justifyRight')">⫸<span class="tooltip">오른쪽 정렬</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="toggleBulletList()">•≡<span class="tooltip">글머리 기호</span></button>
|
||||
<button class="format-btn" onclick="toggleNumberList()">1.<span class="tooltip">번호 목록</span></button>
|
||||
<button class="format-btn" onclick="adjustIndent(-1)">⇤<span class="tooltip">내어쓰기</span></button>
|
||||
<button class="format-btn" onclick="adjustIndent(1)">⇥<span class="tooltip">들여쓰기</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<button class="format-btn" onclick="openTableModal()">▦<span class="tooltip">표 삽입</span></button>
|
||||
<button class="format-btn" onclick="insertImage()">🖼️<span class="tooltip">그림 삽입</span></button>
|
||||
<button class="format-btn" onclick="insertHR()">―<span class="tooltip">구분선</span></button>
|
||||
<div class="format-divider"></div>
|
||||
<select class="format-select" onchange="applyHeading(this.value)" style="min-width:100px;">
|
||||
<option value="">본문</option>
|
||||
<option value="h1">제목 1</option>
|
||||
<option value="h2">제목 2</option>
|
||||
<option value="h3">제목 3</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
return formatBarHTML;
|
||||
}
|
||||
|
||||
// ===== 표 삽입 모달 HTML 생성 =====
|
||||
function createTableModal() {
|
||||
const modalHTML = `
|
||||
<div class="table-modal" id="tableModal">
|
||||
<div class="table-modal-content">
|
||||
<div class="table-modal-title">▦ 표 삽입</div>
|
||||
<div class="table-modal-row">
|
||||
<label>행 수</label>
|
||||
<input type="number" id="tableRows" value="3" min="1" max="20">
|
||||
</div>
|
||||
<div class="table-modal-row">
|
||||
<label>열 수</label>
|
||||
<input type="number" id="tableCols" value="3" min="1" max="10">
|
||||
</div>
|
||||
<div class="table-modal-row">
|
||||
<label>헤더 행 포함</label>
|
||||
<input type="checkbox" id="tableHeader" checked>
|
||||
</div>
|
||||
<div class="table-modal-buttons">
|
||||
<button class="table-modal-btn secondary" onclick="closeTableModal()">취소</button>
|
||||
<button class="table-modal-btn primary" onclick="insertTable()">삽입</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return modalHTML;
|
||||
}
|
||||
|
||||
// ===== 토스트 컨테이너 생성 =====
|
||||
function createToastContainer() {
|
||||
if (!document.getElementById('toastContainer')) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 토스트 메시지 =====
|
||||
function toast(message) {
|
||||
createToastContainer();
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toastEl = document.createElement('div');
|
||||
toastEl.className = 'toast';
|
||||
toastEl.textContent = message;
|
||||
container.appendChild(toastEl);
|
||||
setTimeout(() => toastEl.remove(), 3000);
|
||||
}
|
||||
|
||||
// ===== iframe 참조 가져오기 =====
|
||||
function getPreviewIframe() {
|
||||
return document.getElementById('previewFrame');
|
||||
}
|
||||
|
||||
function getIframeDoc() {
|
||||
const iframe = getPreviewIframe();
|
||||
if (!iframe) return null;
|
||||
return iframe.contentDocument || iframe.contentWindow.document;
|
||||
}
|
||||
|
||||
// ===== 기본 포맷 명령 =====
|
||||
function formatText(command, value = null) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
saveState();
|
||||
doc.execCommand(command, false, value);
|
||||
}
|
||||
|
||||
// ===== 자간 조절 =====
|
||||
function adjustLetterSpacing(delta) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
|
||||
isApplyingFormat = true;
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
toast('텍스트를 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
saveState();
|
||||
const range = selection.getRangeAt(0);
|
||||
let targetNode = range.commonAncestorContainer;
|
||||
if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode;
|
||||
|
||||
const computed = doc.defaultView.getComputedStyle(targetNode);
|
||||
const currentSpacing = parseFloat(computed.letterSpacing) || 0;
|
||||
const newSpacing = currentSpacing + delta;
|
||||
|
||||
if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) {
|
||||
targetNode.style.letterSpacing = newSpacing + 'px';
|
||||
} else {
|
||||
try {
|
||||
const span = doc.createElement('span');
|
||||
span.style.letterSpacing = newSpacing + 'px';
|
||||
range.surroundContents(span);
|
||||
} catch (e) {
|
||||
const fragment = range.extractContents();
|
||||
const span = doc.createElement('span');
|
||||
span.style.letterSpacing = newSpacing + 'px';
|
||||
span.appendChild(fragment);
|
||||
range.insertNode(span);
|
||||
}
|
||||
}
|
||||
toast('자간: ' + newSpacing.toFixed(1) + 'px');
|
||||
setTimeout(() => { isApplyingFormat = false; }, 100);
|
||||
}
|
||||
|
||||
// ===== 색상 적용 =====
|
||||
function applyTextColor(color) { formatText('foreColor', color); }
|
||||
function applyBgColor(color) { formatText('hiliteColor', color); }
|
||||
|
||||
// ===== 목록 =====
|
||||
function toggleBulletList() { formatText('insertUnorderedList'); }
|
||||
function toggleNumberList() { formatText('insertOrderedList'); }
|
||||
|
||||
// ===== 들여쓰기 =====
|
||||
function adjustIndent(direction) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
|
||||
if (activeBlock) {
|
||||
saveState();
|
||||
const current = parseInt(activeBlock.style.marginLeft) || 0;
|
||||
activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px';
|
||||
toast(direction > 0 ? '→ 들여쓰기' : '← 내어쓰기');
|
||||
} else {
|
||||
formatText(direction > 0 ? 'indent' : 'outdent');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 제목 스타일 =====
|
||||
function applyHeading(tag) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing || !activeBlock) return;
|
||||
|
||||
saveState();
|
||||
const content = activeBlock.innerHTML;
|
||||
let newEl;
|
||||
|
||||
if (tag === '') {
|
||||
newEl = doc.createElement('p');
|
||||
newEl.innerHTML = content;
|
||||
newEl.style.fontSize = '12pt';
|
||||
newEl.style.lineHeight = '1.6';
|
||||
} else {
|
||||
newEl = doc.createElement(tag);
|
||||
newEl.innerHTML = content;
|
||||
if (tag === 'h1') {
|
||||
newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;';
|
||||
} else if (tag === 'h2') {
|
||||
newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;';
|
||||
} else if (tag === 'h3') {
|
||||
newEl.style.cssText = 'font-size:14pt; color:#2c5282;';
|
||||
}
|
||||
}
|
||||
|
||||
newEl.setAttribute('contenteditable', 'true');
|
||||
activeBlock.replaceWith(newEl);
|
||||
setActiveBlock(newEl);
|
||||
}
|
||||
|
||||
// ===== 폰트 =====
|
||||
function applyFontFamily(fontName) {
|
||||
if (!isEditing) return;
|
||||
formatText('fontName', fontName);
|
||||
}
|
||||
|
||||
function applyFontSizeInput(size) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
|
||||
|
||||
saveState();
|
||||
const range = selection.getRangeAt(0);
|
||||
try {
|
||||
const span = doc.createElement('span');
|
||||
span.style.fontSize = size + 'pt';
|
||||
range.surroundContents(span);
|
||||
} catch (e) {
|
||||
const fragment = range.extractContents();
|
||||
const span = doc.createElement('span');
|
||||
span.style.fontSize = size + 'pt';
|
||||
span.appendChild(fragment);
|
||||
range.insertNode(span);
|
||||
}
|
||||
toast('글씨 크기: ' + size + 'pt');
|
||||
}
|
||||
|
||||
// ===== 표 삽입 =====
|
||||
function openTableModal() {
|
||||
document.getElementById('tableModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeTableModal() {
|
||||
document.getElementById('tableModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function insertTable() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
|
||||
const rows = parseInt(document.getElementById('tableRows').value) || 3;
|
||||
const cols = parseInt(document.getElementById('tableCols').value) || 3;
|
||||
const hasHeader = document.getElementById('tableHeader').checked;
|
||||
|
||||
saveState();
|
||||
|
||||
let tableHTML = '<table style="width:100%; border-collapse:collapse; margin:15px 0; font-size:9.5pt; border-top:2px solid #1a365d;"><tbody>';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
tableHTML += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
if (i === 0 && hasHeader) {
|
||||
tableHTML += '<th style="border:1px solid #ddd; padding:8px; background:#1a365d; color:#fff; font-weight:700;">헤더</th>';
|
||||
} else {
|
||||
tableHTML += '<td style="border:1px solid #ddd; padding:8px;">내용</td>';
|
||||
}
|
||||
}
|
||||
tableHTML += '</tr>';
|
||||
}
|
||||
tableHTML += '</tbody></table>';
|
||||
|
||||
insertAtCursor(tableHTML);
|
||||
closeTableModal();
|
||||
toast('▦ 표가 삽입되었습니다');
|
||||
}
|
||||
|
||||
// ===== 이미지 삽입 =====
|
||||
function insertImage() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => {
|
||||
saveState();
|
||||
const html = `<figure style="margin:15px 0; text-align:center;">
|
||||
<img src="${ev.target.result}" style="max-width:100%; height:auto; cursor:pointer;" onclick="selectImageForResize(this)">
|
||||
<figcaption style="font-size:9pt; color:#666; margin-top:5px;">그림 설명</figcaption>
|
||||
</figure>`;
|
||||
insertAtCursor(html);
|
||||
toast('🖼️ 이미지가 삽입되었습니다');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ===== 이미지 리사이즈 =====
|
||||
function selectImageForResize(img) {
|
||||
if (!isEditing) return;
|
||||
|
||||
// 기존 선택 해제
|
||||
const doc = getIframeDoc();
|
||||
doc.querySelectorAll('img.selected-image').forEach(i => {
|
||||
i.classList.remove('selected-image');
|
||||
i.style.outline = '';
|
||||
});
|
||||
|
||||
// 새 선택
|
||||
img.classList.add('selected-image');
|
||||
img.style.outline = '3px solid #00c853';
|
||||
|
||||
// 크기 조절 핸들러
|
||||
img.onmousedown = function(e) {
|
||||
if (!isEditing) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = img.offsetWidth;
|
||||
|
||||
function onMouseMove(e) {
|
||||
const diff = e.clientX - startX;
|
||||
const newWidth = Math.max(50, startWidth + diff);
|
||||
img.style.width = newWidth + 'px';
|
||||
img.style.height = 'auto';
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
saveState();
|
||||
toast('이미지 크기 조절됨');
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 구분선 삽입 =====
|
||||
function insertHR() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || !isEditing) return;
|
||||
saveState();
|
||||
insertAtCursor('<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">');
|
||||
toast('― 구분선 삽입');
|
||||
}
|
||||
|
||||
// ===== 커서 위치에 HTML 삽입 =====
|
||||
function insertAtCursor(html) {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
|
||||
const selection = doc.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const temp = doc.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
const frag = doc.createDocumentFragment();
|
||||
while (temp.firstChild) frag.appendChild(temp.firstChild);
|
||||
range.insertNode(frag);
|
||||
} else if (activeBlock) {
|
||||
activeBlock.insertAdjacentHTML('afterend', html);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 블록 선택/관리 =====
|
||||
function setActiveBlock(el) {
|
||||
clearActiveBlock();
|
||||
activeBlock = el;
|
||||
if (activeBlock) activeBlock.classList.add('active-block');
|
||||
}
|
||||
|
||||
function clearActiveBlock() {
|
||||
if (activeBlock) activeBlock.classList.remove('active-block');
|
||||
activeBlock = null;
|
||||
}
|
||||
|
||||
// ===== Undo/Redo =====
|
||||
function saveState() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
|
||||
if (redoStack.length > 0) redoStack.length = 0;
|
||||
historyStack.push(doc.body.innerHTML);
|
||||
if (historyStack.length > MAX_HISTORY) historyStack.shift();
|
||||
}
|
||||
|
||||
function performUndo() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || historyStack.length <= 1) return;
|
||||
|
||||
redoStack.push(doc.body.innerHTML);
|
||||
historyStack.pop();
|
||||
doc.body.innerHTML = historyStack[historyStack.length - 1];
|
||||
bindIframeEditEvents();
|
||||
toast('↩️ 실행 취소');
|
||||
}
|
||||
|
||||
function performRedo() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc || redoStack.length === 0) return;
|
||||
|
||||
const nextState = redoStack.pop();
|
||||
historyStack.push(nextState);
|
||||
doc.body.innerHTML = nextState;
|
||||
bindIframeEditEvents();
|
||||
toast('↪️ 다시 실행');
|
||||
}
|
||||
|
||||
// ===== 키보드 단축키 =====
|
||||
function handleEditorKeydown(e) {
|
||||
if (!isEditing) return;
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b': e.preventDefault(); formatText('bold'); break;
|
||||
case 'i': e.preventDefault(); formatText('italic'); break;
|
||||
case 'u': e.preventDefault(); formatText('underline'); break;
|
||||
case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break;
|
||||
case 'y': e.preventDefault(); performRedo(); break;
|
||||
case '=':
|
||||
case '+': e.preventDefault(); adjustLetterSpacing(0.5); break;
|
||||
case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
adjustIndent(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== iframe 편집 이벤트 바인딩 =====
|
||||
function bindIframeEditEvents() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
|
||||
// 키보드 이벤트
|
||||
doc.removeEventListener('keydown', handleEditorKeydown);
|
||||
doc.addEventListener('keydown', handleEditorKeydown);
|
||||
|
||||
// 블록 클릭 이벤트
|
||||
doc.body.addEventListener('click', function(e) {
|
||||
if (!isEditing) return;
|
||||
let target = e.target;
|
||||
while (target && target !== doc.body) {
|
||||
if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) {
|
||||
setActiveBlock(target);
|
||||
return;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
clearActiveBlock();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 편집 모드 토글 =====
|
||||
function toggleEditMode() {
|
||||
const doc = getIframeDoc();
|
||||
if (!doc) return;
|
||||
|
||||
isEditing = !isEditing;
|
||||
|
||||
const formatBar = document.getElementById('formatBar');
|
||||
const editBtn = document.getElementById('editModeBtn');
|
||||
|
||||
if (isEditing) {
|
||||
// 편집 모드 ON
|
||||
doc.designMode = 'on';
|
||||
if (formatBar) formatBar.classList.add('active');
|
||||
if (editBtn) {
|
||||
editBtn.textContent = '✏️ 편집 중';
|
||||
editBtn.classList.add('active');
|
||||
}
|
||||
|
||||
// contenteditable 설정
|
||||
doc.querySelectorAll('.sheet *').forEach(el => {
|
||||
if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) {
|
||||
el.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
bindIframeEditEvents();
|
||||
saveState();
|
||||
toast('✏️ 편집 모드 시작');
|
||||
} else {
|
||||
// 편집 모드 OFF
|
||||
doc.designMode = 'off';
|
||||
if (formatBar) formatBar.classList.remove('active');
|
||||
if (editBtn) {
|
||||
editBtn.textContent = '✏️ 편집하기';
|
||||
editBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
// contenteditable 제거
|
||||
doc.querySelectorAll('[contenteditable]').forEach(el => {
|
||||
el.removeAttribute('contenteditable');
|
||||
});
|
||||
|
||||
clearActiveBlock();
|
||||
toast('✏️ 편집 모드 종료');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 편집기 초기화 =====
|
||||
function initEditor() {
|
||||
// 편집 바가 없으면 생성
|
||||
if (!document.getElementById('formatBar')) {
|
||||
const previewContainer = document.querySelector('.preview-container');
|
||||
if (previewContainer) {
|
||||
previewContainer.insertAdjacentHTML('afterbegin', createFormatBar());
|
||||
}
|
||||
}
|
||||
|
||||
// 표 모달이 없으면 생성
|
||||
if (!document.getElementById('tableModal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', createTableModal());
|
||||
}
|
||||
|
||||
// 토스트 컨테이너 생성
|
||||
createToastContainer();
|
||||
|
||||
console.log('Editor initialized');
|
||||
}
|
||||
|
||||
// DOM 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initEditor);
|
||||
343
03. Code/geulbeot_2nd/templates/hwp_guide.html
Normal file
343
03. Code/geulbeot_2nd/templates/hwp_guide.html
Normal file
@@ -0,0 +1,343 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HWP 변환 가이드 - 글벗 Light</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Noto Sans KR', sans-serif; }
|
||||
.gradient-bg { background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%); }
|
||||
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
|
||||
code { font-family: 'Consolas', 'Monaco', monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="gradient-bg text-white py-6 shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/" class="text-blue-200 hover:text-white text-sm">← 메인으로</a>
|
||||
<h1 class="text-2xl font-bold mt-2">HWP 변환 가이드</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- 안내 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
|
||||
<h2 class="font-bold text-yellow-800 mb-2">⚠️ HWP 변환 요구사항</h2>
|
||||
<ul class="text-yellow-700 text-sm space-y-1">
|
||||
<li>• Windows 운영체제</li>
|
||||
<li>• 한글 프로그램 (한컴오피스) 설치</li>
|
||||
<li>• Python 3.8 이상</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 설치 방법 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-4">1. 필요 라이브러리 설치</h2>
|
||||
<pre><code>pip install pyhwpx beautifulsoup4</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 사용 방법 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-4">2. 사용 방법</h2>
|
||||
<ol class="list-decimal list-inside space-y-2 text-gray-700">
|
||||
<li>글벗 Light에서 HTML 파일을 다운로드합니다.</li>
|
||||
<li>아래 Python 스크립트를 다운로드합니다.</li>
|
||||
<li>스크립트 내 경로를 수정합니다.</li>
|
||||
<li>스크립트를 실행합니다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">3. HWP 변환 스크립트</h2>
|
||||
<button onclick="copyScript()" class="px-4 py-2 bg-blue-900 text-white rounded hover:bg-blue-800 text-sm">
|
||||
📋 복사
|
||||
</button>
|
||||
</div>
|
||||
<pre id="scriptCode"><code># -*- coding: utf-8 -*-
|
||||
"""
|
||||
글벗 Light - HTML → HWP 변환기
|
||||
Windows + 한글 프로그램 필요
|
||||
"""
|
||||
|
||||
from pyhwpx import Hwp
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
self.colors = {}
|
||||
|
||||
def _init_colors(self):
|
||||
self.colors = {
|
||||
'primary-navy': self.hwp.RGBColor(26, 54, 93),
|
||||
'secondary-navy': self.hwp.RGBColor(44, 82, 130),
|
||||
'dark-gray': self.hwp.RGBColor(45, 55, 72),
|
||||
'medium-gray': self.hwp.RGBColor(74, 85, 104),
|
||||
'bg-light': self.hwp.RGBColor(247, 250, 252),
|
||||
'white': self.hwp.RGBColor(255, 255, 255),
|
||||
'black': self.hwp.RGBColor(0, 0, 0),
|
||||
}
|
||||
|
||||
def _mm(self, mm):
|
||||
return self.hwp.MiliToHwpUnit(mm)
|
||||
|
||||
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 _align(self, align):
|
||||
actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
|
||||
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 _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 _create_header(self, left_text, right_text):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self._font(9, 'medium-gray')
|
||||
self.hwp.insert_text(left_text)
|
||||
self.hwp.insert_text("\t" * 12)
|
||||
self.hwp.insert_text(right_text)
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f"머리말 생성 실패: {e}")
|
||||
|
||||
def _create_footer(self, text):
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
|
||||
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
|
||||
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
|
||||
self._align('center')
|
||||
self._font(8.5, 'medium-gray')
|
||||
self.hwp.insert_text(text)
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
except Exception as e:
|
||||
print(f"꼬리말 생성 실패: {e}")
|
||||
|
||||
def _convert_lead_box(self, elem):
|
||||
content = elem.find("div")
|
||||
if not content:
|
||||
return
|
||||
text = ' '.join(content.get_text().split())
|
||||
self.hwp.create_table(1, 1, treat_as_char=True)
|
||||
self._set_cell_bg('bg-light')
|
||||
self._font(11.5, 'dark-gray', False)
|
||||
self.hwp.insert_text(text)
|
||||
self._exit_table()
|
||||
|
||||
def _convert_bottom_box(self, elem):
|
||||
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)
|
||||
|
||||
self.hwp.create_table(1, 2, treat_as_char=True)
|
||||
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):
|
||||
title = section.find(class_="section-title")
|
||||
if title:
|
||||
self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
|
||||
|
||||
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')
|
||||
self._para()
|
||||
|
||||
def _convert_sheet(self, sheet, is_first_page=False):
|
||||
if is_first_page:
|
||||
header = sheet.find(class_="page-header")
|
||||
if header:
|
||||
left = header.find(class_="header-left")
|
||||
right = header.find(class_="header-right")
|
||||
left_text = left.get_text(strip=True) if left else ""
|
||||
right_text = right.get_text(strip=True) if right else ""
|
||||
if left_text or right_text:
|
||||
self._create_header(left_text, right_text)
|
||||
|
||||
footer = sheet.find(class_="page-footer")
|
||||
if footer:
|
||||
self._create_footer(footer.get_text(strip=True))
|
||||
|
||||
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')
|
||||
else:
|
||||
self._para(title_text, 23, 'primary-navy', True, 'center')
|
||||
self._font(10, 'secondary-navy')
|
||||
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(f"[입력] {html_path}")
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
soup = BeautifulSoup(f.read(), 'html.parser')
|
||||
|
||||
self.hwp.FileNew()
|
||||
self._init_colors()
|
||||
|
||||
# 페이지 설정
|
||||
try:
|
||||
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
|
||||
sec = self.hwp.HParameterSet.HSecDef
|
||||
sec.PageDef.LeftMargin = self._mm(20)
|
||||
sec.PageDef.RightMargin = self._mm(20)
|
||||
sec.PageDef.TopMargin = self._mm(20)
|
||||
sec.PageDef.BottomMargin = self._mm(20)
|
||||
sec.PageDef.HeaderLen = self._mm(10)
|
||||
sec.PageDef.FooterLen = self._mm(10)
|
||||
self.hwp.HAction.Execute("PageSetup", sec.HSet)
|
||||
except Exception as e:
|
||||
print(f"페이지 설정 실패: {e}")
|
||||
|
||||
sheets = soup.find_all(class_="sheet")
|
||||
total = len(sheets)
|
||||
print(f"[변환] 총 {total} 페이지")
|
||||
|
||||
for i, sheet in enumerate(sheets, 1):
|
||||
print(f"[{i}/{total}] 페이지 처리 중...")
|
||||
self._convert_sheet(sheet, is_first_page=(i == 1))
|
||||
if i < total:
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"✅ 저장 완료: {output_path}")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.hwp.Quit()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
# ====================================
|
||||
# 경로 설정 (본인 환경에 맞게 수정)
|
||||
# ====================================
|
||||
html_path = r"C:\Users\User\Downloads\report.html"
|
||||
output_path = r"C:\Users\User\Downloads\report.hwp"
|
||||
|
||||
print("=" * 50)
|
||||
print("글벗 Light - HTML → HWP 변환기")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
converter = HtmlToHwpConverter(visible=True)
|
||||
converter.convert(html_path, output_path)
|
||||
print("\n✅ 변환 완료!")
|
||||
input("Enter를 누르면 HWP가 닫힙니다...")
|
||||
converter.close()
|
||||
except FileNotFoundError:
|
||||
print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}")
|
||||
except Exception as e:
|
||||
print(f"\n[에러] {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 경로 수정 안내 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-4">4. 경로 수정</h2>
|
||||
<p class="text-gray-700 mb-4">스크립트 하단의 <code class="bg-gray-100 px-2 py-1 rounded">main()</code> 함수에서 경로를 수정하세요:</p>
|
||||
<pre><code>html_path = r"C:\다운로드경로\report.html"
|
||||
output_path = r"C:\저장경로\report.hwp"</code></pre>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function copyScript() {
|
||||
const code = document.getElementById('scriptCode').innerText;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
alert('스크립트가 클립보드에 복사되었습니다!');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2099
03. Code/geulbeot_2nd/templates/index.html
Normal file
2099
03. Code/geulbeot_2nd/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user