📦 Initialize Geulbeot structure and merge Prompts & test projects

This commit is contained in:
2026-03-05 11:32:29 +09:00
commit 555a954458
687 changed files with 205247 additions and 0 deletions

32
03. Code/geulbeot_3rd/.gitignore vendored Normal file
View 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

View File

@@ -0,0 +1 @@
web: gunicorn app:app

View File

@@ -0,0 +1,146 @@
# 글벗 Light v3.0
AI 기반 문서 자동화 시스템 — 9단계 RAG 파이프라인 + 웹 편집기 + HWP 변환
## 🎯 개요
다양한 형식의 입력 문서(PDF, HWP, 이미지, 동영상 등)를 분석하여 표준 HTML 보고서를 자동 생성하고, 웹 편집기로 수정한 뒤 HTML/PDF/HWP로 출력하는 시스템입니다.
## 📁 프로젝트 구조
```
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
```
## ⚙️ 프로세스 플로우
```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
subgraph OUTPUT["📤 Output"]
direction TB
I --> J["step8: 콘텐츠 생성\n(Gemini API)"]
J --> K["step9: HTML 생성\n(Gemini API)"]
end
subgraph EDIT["✏️ Edit"]
direction TB
K --> M["웹 편집기\neditor.js"]
K --> N["AI 편집\n/refine (Claude API)"]
end
subgraph EXPORT["📦 Export"]
direction TB
M & N --> P["HTML / PDF"]
M & N --> Q["HWP 변환\nhtml_to_hwp.py"]
end
```
## 🌐 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 | 서버 상태 확인 |
## 🤖 활용 AI
| 단계 | AI | 역할 |
|------|-----|------|
| step2 (추출) | GPT | PDF에서 텍스트/이미지 메타데이터 추출 |
| step7 (목차) | GPT | 인덱싱 및 목차 자동 생성 |
| step8 (콘텐츠) | Gemini | 섹션별 본문 초안 생성 |
| step9 (HTML) | Gemini | 최종 HTML 보고서 생성 |
| 기획서 생성 | Claude | HTML 구조 추출 + 변환 |
| AI 편집 | Claude | 피드백 반영 수정 |
## 🎨 글벗 표준 HTML 양식
- A4 인쇄 최적화 (210mm × 297mm)
- Noto Sans KR 폰트
- Navy 계열 색상 (#1a365d 기본)
- 구성: page-header, lead-box, section, data-table, bottom-box, footer
## 🖥️ 로컬 실행
```bash
pip install -r requirements.txt
python app.py
```
http://localhost:5000 접속
## 🔑 API 키 설정
`api_keys.json` 파일을 프로젝트 루트에 생성:
```json
{
"CLAUDE_API_KEY": "sk-ant-...",
"GPT_API_KEY": "sk-proj-...",
"GEMINI_API_KEY": "AIzaSy..."
}
```
> ⚠️ `api_keys.json`은 `.gitignore`에 포함되어 Git에 올라가지 않습니다.
## 📝 v1 → v3 변경 이력
| 버전 | 변경 내용 |
|------|----------|
| v1 | Flask + Claude API 기획서 생성기 (12파일, 422줄) |
| v2 | 웹 편집기 추가 (editor.js, editor.css) |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 추가 (40파일+, 6000줄+) |
## 📝 라이선스
Private — GPD 내부 사용

View 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()

View File

@@ -0,0 +1,579 @@
# -*- 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 flask import send_file
from datetime import datetime
import tempfile
from converters.pipeline.router import process_document
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('/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
@app.route('/assets/<path:filename>')
def serve_assets(filename):
"""로컬 assets 폴더 서빙"""
assets_dir = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
return send_file(os.path.join(assets_dir, filename))
@app.route('/health')
def health():
"""헬스 체크"""
return jsonify({'status': 'healthy', 'version': '2.0.0'})
# ===== HWP 변환 =====
@app.route('/export-hwp', methods=['POST'])
def export_hwp():
try:
data = request.get_json()
html_content = data.get('html', '')
doc_type = data.get('doc_type', 'briefing')
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()
# 파일 전송
return send_file(
hwp_path,
as_attachment=True,
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp',
mimetype='application/x-hwp'
)
except ImportError as e:
return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 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)

View File

@@ -0,0 +1,573 @@
# -*- coding: utf-8 -*-
"""
HTML → HWP 변환기 v11
✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정
✅ 페이지번호: ctrl 코드 방식으로 수정
✅ 나머지는 v10 유지
pip install pyhwpx beautifulsoup4 pillow
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup, NavigableString
import os, re
# 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']
class HtmlToHwpConverter:
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.cfg = Config()
self.sp = StyleParser()
self.base_path = ""
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 _setup_page(self):
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
s = self.hwp.HParameterSet.HSecDef
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
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
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)
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.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
if right_text:
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말: {e}")
# ═══════════════════════════════════════════════════════════════
# 꼬리말 - 페이지 번호 (수정)
# ═══════════════════════════════════════════════════════════════
def _create_footer(self, left_text=""):
print(f" → 꼬리말: {left_text}")
# 1. 꼬리말 열기
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)
# 2. 좌측 정렬 + 제목 8pt
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
self._set_font(8, False, '#666666')
self.hwp.insert_text(left_text)
# 3. 꼬리말 닫기
self.hwp.HAction.Run("CloseEx")
# 4. 쪽번호 (우측 하단)
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}")
try:
self.hwp.HAction.Run("BreakSection")
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.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
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 _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()
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:
return
# 상대경로 → 절대경로
if not os.path.isabs(src):
full_path = os.path.normpath(os.path.join(self.base_path, src))
else:
full_path = src
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()
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)
return
if tag == 'img':
self._insert_image(elem.get('src', ''))
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)
def convert(self, html_path, output_path):
print("="*60)
print("HTML → HWP 변환기 v11")
print(" ✓ 이미지: sizeoption 수정")
print(" ✓ 페이지번호: 다중 방법 시도")
print("="*60)
self.base_path = os.path.dirname(os.path.abspath(html_path))
self.is_first_h1 = True
self.image_count = 0
print(f"\n입력: {html_path}")
print(f"출력: {output_path}\n")
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() # ":" 이전
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.SaveAs(output_path)
print(f"\n✅ 저장: {output_path}")
print(f" 이미지: {self.image_count}개 처리")
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"
try:
conv = HtmlToHwpConverter(visible=True)
conv.convert(html_path, output_path)
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
conv.close()
except Exception as e:
print(f"\n[에러] {e}")
import traceback; traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,573 @@
# -*- coding: utf-8 -*-
"""
HTML → HWP 변환기 v11
✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정
✅ 페이지번호: ctrl 코드 방식으로 수정
✅ 나머지는 v10 유지
pip install pyhwpx beautifulsoup4 pillow
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup, NavigableString
import os, re
# 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']
class HtmlToHwpConverter:
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.cfg = Config()
self.sp = StyleParser()
self.base_path = ""
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 _setup_page(self):
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
s = self.hwp.HParameterSet.HSecDef
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
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
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)
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.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
if right_text:
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말: {e}")
# ═══════════════════════════════════════════════════════════════
# 꼬리말 - 페이지 번호 (수정)
# ═══════════════════════════════════════════════════════════════
def _create_footer(self, left_text=""):
print(f" → 꼬리말: {left_text}")
# 1. 꼬리말 열기
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)
# 2. 좌측 정렬 + 제목 8pt
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
self._set_font(8, False, '#666666')
self.hwp.insert_text(left_text)
# 3. 꼬리말 닫기
self.hwp.HAction.Run("CloseEx")
# 4. 쪽번호 (우측 하단)
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}")
try:
self.hwp.HAction.Run("BreakSection")
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.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
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 _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()
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:
return
# 상대경로 → 절대경로
if not os.path.isabs(src):
full_path = os.path.normpath(os.path.join(self.base_path, src))
else:
full_path = src
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()
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)
return
if tag == 'img':
self._insert_image(elem.get('src', ''))
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)
def convert(self, html_path, output_path):
print("="*60)
print("HTML → HWP 변환기 v11")
print(" ✓ 이미지: sizeoption 수정")
print(" ✓ 페이지번호: 다중 방법 시도")
print("="*60)
self.base_path = os.path.dirname(os.path.abspath(html_path))
self.is_first_h1 = True
self.image_count = 0
print(f"\n입력: {html_path}")
print(f"출력: {output_path}\n")
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() # ":" 이전
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.SaveAs(output_path)
print(f"\n✅ 저장: {output_path}")
print(f" 이미지: {self.image_count}개 처리")
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"
try:
conv = HtmlToHwpConverter(visible=True)
conv.convert(html_path, output_path)
input("\nEnter를 누르면 HWP가 닫힙니다...") # ← 선택사항
conv.close()
except Exception as e:
print(f"\n[에러] {e}")
import traceback; traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
from .router import process_document, is_long_document

View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""
router.py
기능:
- HTML 입력의 분량을 판단하여 적절한 파이프라인으로 분기
- 긴 문서 (5000자 이상): RAG 파이프라인 (step3→4→5→6→7→8→9)
- 짧은 문서 (5000자 미만): 직접 생성 (step7→8→9)
"""
import re
import os
from typing import Dict, Any
# 분량 판단 기준
LONG_DOC_THRESHOLD = 5000 # 5000자 이상이면 긴 문서
# 이미지 assets 경로 (개발용 고정) - r prefix 필수!
ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
def count_characters(html_content: str) -> int:
"""HTML 태그 제외한 순수 텍스트 글자 수 계산"""
# HTML 태그 제거
text_only = re.sub(r'<[^>]+>', '', html_content)
# 공백 정리
text_only = ' '.join(text_only.split())
return len(text_only)
def is_long_document(html_content: str) -> bool:
"""긴 문서 여부 판단"""
char_count = count_characters(html_content)
return char_count >= LONG_DOC_THRESHOLD
def convert_image_paths(html_content: str) -> str:
"""
HTML 내 상대 이미지 경로를 서버 경로로 변환
assets/xxx.png → /assets/xxx.png
"""
result = re.sub(r'src="assets/', 'src="/assets/', html_content)
return result
def replace_src(match):
original_path = match.group(1)
# 이미 절대 경로이거나 URL이면 그대로
if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')):
return match.group(0)
# assets/로 시작하면 절대 경로로 변환
if original_path.startswith('assets/'):
filename = original_path.replace('assets/', '')
absolute_path = os.path.join(ASSETS_BASE_PATH, filename)
return f'src="{absolute_path}"'
return match.group(0)
# src="..." 패턴 찾아서 변환
result = re.sub(r'src="([^"]+)"', replace_src, html_content)
return result
def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
"""
짧은 문서 파이프라인 (5000자 미만)
"""
try:
# 이미지 경로 변환
processed_html = convert_image_paths(html_content)
# TODO: step7, step8, step9 연동
return {
'success': True,
'pipeline': 'short',
'char_count': count_characters(html_content),
'html': processed_html
}
except Exception as e:
return {
'success': False,
'error': str(e),
'pipeline': 'short'
}
def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]:
"""
긴 문서 파이프라인 (5000자 이상)
"""
try:
# 이미지 경로 변환
processed_html = convert_image_paths(html_content)
# TODO: step3~9 순차 실행
return {
'success': True,
'pipeline': 'long',
'char_count': count_characters(html_content),
'html': processed_html
}
except Exception as e:
return {
'success': False,
'error': str(e),
'pipeline': 'long'
}
def process_document(content: str, options: dict = None) -> Dict[str, Any]:
"""
메인 라우터 함수
- 분량에 따라 적절한 파이프라인으로 분기
Args:
content: HTML 문자열
options: 추가 옵션 (page_option, instruction 등)
Returns:
{'success': bool, 'html': str, 'pipeline': str, ...}
"""
if options is None:
options = {}
if not content or not content.strip():
return {
'success': False,
'error': '내용이 비어있습니다.'
}
char_count = count_characters(content)
if is_long_document(content):
result = run_long_pipeline(content, options)
else:
result = run_short_pipeline(content, options)
# 공통 정보 추가
result['char_count'] = char_count
result['threshold'] = LONG_DOC_THRESHOLD
return result

View File

@@ -0,0 +1,784 @@
"""
측량/GIS/드론 관련 자료 PDF 변환 및 정리 시스템
- 모든 파일 형식을 PDF로 변환
- DWG 파일: DWG TrueView를 사용한 자동 PDF 변환
- 동영상 파일: Whisper를 사용한 음성→텍스트 변환 후 PDF 생성
- 원본 경로와 변환 파일 경로를 엑셀로 관리
"""
import os
import shutil
from pathlib import Path
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
import win32com.client
import pythoncom
from PIL import Image
import subprocess
import json
class SurveyingFileConverter:
def _dbg(self, msg):
if getattr(self, "debug", False):
print(msg)
def _ensure_ffmpeg_on_path(self):
import os
import shutil
from pathlib import Path
found = shutil.which("ffmpeg")
self._dbg(f"DEBUG ffmpeg which before: {found}")
if found:
self.ffmpeg_exe = found
return True
try:
import imageio_ffmpeg
src = Path(imageio_ffmpeg.get_ffmpeg_exe())
self._dbg(f"DEBUG imageio ffmpeg exe: {src}")
self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}")
if not src.exists():
return False
tools_dir = Path(self.output_dir) / "tools_ffmpeg"
tools_dir.mkdir(parents=True, exist_ok=True)
dst = tools_dir / "ffmpeg.exe"
if not dst.exists():
shutil.copyfile(str(src), str(dst))
os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "")
found2 = shutil.which("ffmpeg")
self._dbg(f"DEBUG ffmpeg which after: {found2}")
if found2:
self.ffmpeg_exe = found2
return True
return False
except Exception as e:
self._dbg(f"DEBUG ensure ffmpeg error: {e}")
return False
def __init__(self, source_dir, output_dir):
self.source_dir = Path(source_dir)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.debug = True
self.ffmpeg_exe = None
ok = self._ensure_ffmpeg_on_path()
self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}")
# 변환 로그를 저장할 리스트
self.conversion_log = []
# ★ 추가: 도메인 용어 사전
self.domain_terms = ""
# HWP 보안 모듈 후보 목록 추가
self.hwp_security_modules = [
"FilePathCheckerModuleExample",
"SecurityModule",
""
]
# 지원 파일 확장자 정의
self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'}
self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'}
self.text_extensions = {'.txt', '.csv', '.log', '.md'}
self.pdf_extension = {'.pdf'}
self.dwg_extensions = {'.dwg', '.dxf'}
# DWG TrueView 경로 설정 (설치 버전에 맞게 조정)
self.trueview_path = self._find_trueview()
def _find_trueview(self):
"""DWG TrueView 설치 경로 자동 탐색"""
possible_paths = [
r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe",
r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe",
r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe",
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe",
r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe",
]
for path in possible_paths:
if Path(path).exists():
return path
return None
def get_all_files(self):
"""하위 모든 폴더의 파일 목록 가져오기"""
all_files = []
for file_path in self.source_dir.rglob('*'):
if file_path.is_file():
all_files.append(file_path)
return all_files
def extract_audio_from_video(self, video_path, audio_output_path):
try:
import imageio_ffmpeg
from pathlib import Path
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}")
self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}")
self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}")
self._dbg(f"DEBUG extract out path: {audio_output_path}")
cmd = [
ffmpeg_exe,
"-i", str(video_path),
"-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
"-ac", "1",
"-y",
str(audio_output_path),
]
self._dbg("DEBUG extract cmd: " + " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True)
self._dbg(f"DEBUG extract returncode: {result.returncode}")
self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}")
return True
except subprocess.CalledProcessError as e:
self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}")
self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}")
return False
except Exception as e:
self._dbg(f"DEBUG extract exception: {e}")
return False
def transcribe_audio_with_whisper(self, audio_path):
try:
self._ensure_ffmpeg_on_path()
import shutil
from pathlib import Path
ffmpeg_path = shutil.which("ffmpeg")
self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}")
if not ffmpeg_path:
if self.ffmpeg_exe:
import os
os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "")
audio_file = Path(audio_path)
self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}")
self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}")
if not audio_file.exists() or audio_file.stat().st_size == 0:
return "[오디오 파일이 비어있거나 존재하지 않음]"
import whisper
model = whisper.load_model("medium") # ★ base → medium 변경
# ★ domain_terms를 initial_prompt로 사용
result = model.transcribe(
str(audio_path),
language="ko",
task="transcribe",
initial_prompt=self.domain_terms if self.domain_terms else None,
condition_on_previous_text=True, # ★ 다시 True로
)
# ★ 후처리: 반복 및 이상한 텍스트 제거
text = result["text"]
text = self.clean_transcript(text)
return text
except Exception as e:
import traceback
self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}")
return f"[음성 인식 실패: {str(e)}]"
def clean_transcript(self, text):
"""Whisper 결과 후처리 - 반복/환각 제거"""
import re
# 1. 영어/일본어/중국어 환각 제거
text = re.sub(r'[A-Za-z]{3,}', '', text) # 3글자 이상 영어 제거
text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 일본어 제거
text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시)
# 2. 반복 문장 제거
sentences = text.split('.')
seen = set()
unique_sentences = []
for s in sentences:
s_clean = s.strip()
if s_clean and s_clean not in seen:
seen.add(s_clean)
unique_sentences.append(s_clean)
text = '. '.join(unique_sentences)
# 3. 이상한 문자 정리
text = re.sub(r'\s+', ' ', text) # 다중 공백 제거
text = text.strip()
return text
def get_video_transcript(self, video_path):
"""동영상 파일의 음성을 텍스트로 변환"""
try:
# 임시 오디오 파일 경로
temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav"
# 1. 동영상에서 오디오 추출
if not self.extract_audio_from_video(video_path, temp_audio):
return self.get_basic_file_info(video_path) + "\n\n[오디오 추출 실패]"
if (not temp_audio.exists()) or temp_audio.stat().st_size == 0:
return self.get_basic_file_info(video_path) + "\n\n[오디오 파일 생성 실패]"
# 2. Whisper로 음성 인식
transcript = self.transcribe_audio_with_whisper(temp_audio)
# 3. 임시 오디오 파일 삭제
if temp_audio.exists():
temp_audio.unlink()
# 4. 결과 포맷팅
stat = video_path.stat()
lines = []
lines.append(f"동영상 파일 음성 전사 (Speech-to-Text)")
lines.append(f"=" * 60)
lines.append(f"파일명: {video_path.name}")
lines.append(f"경로: {video_path}")
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
lines.append("=" * 60)
lines.append("음성 내용:")
lines.append("=" * 60)
lines.append("")
lines.append(transcript)
return "\n".join(lines)
except Exception as e:
return self.get_basic_file_info(video_path) + f"\n\n[음성 인식 오류: {str(e)}]"
def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path):
"""DWG TrueView를 사용한 DWG → PDF 변환"""
if not self.trueview_path:
return False, "DWG TrueView가 설치되지 않음"
try:
# AutoCAD 스크립트 생성
script_content = f"""_-EXPORT_PDF{pdf_path}_Y"""
script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr"
with open(script_path, 'w') as f:
f.write(script_content)
# TrueView 실행
cmd = [
self.trueview_path,
str(dwg_path.absolute()),
"/b", str(script_path.absolute()),
"/nologo"
]
result = subprocess.run(cmd, timeout=120, capture_output=True)
# 스크립트 파일 삭제
if script_path.exists():
try:
script_path.unlink()
except:
pass
# PDF 생성 확인
if pdf_path.exists():
return True, "성공"
else:
return False, "PDF 생성 실패"
except subprocess.TimeoutExpired:
return False, "변환 시간 초과"
except Exception as e:
return False, f"DWG 변환 실패: {str(e)}"
def get_basic_file_info(self, file_path):
"""기본 파일 정보 반환"""
stat = file_path.stat()
lines = []
lines.append(f"파일 정보")
lines.append(f"=" * 60)
lines.append(f"파일명: {file_path.name}")
lines.append(f"경로: {file_path}")
lines.append(f"파일 크기: {self.format_file_size(stat.st_size)}")
lines.append(f"생성일: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")
lines.append(f"수정일: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(lines)
def format_file_size(self, size_bytes):
"""파일 크기를 읽기 쉬운 형식으로 변환"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} TB"
def convert_image_to_pdf(self, image_path, output_path):
"""이미지 파일을 PDF로 변환"""
try:
img = Image.open(image_path)
# RGB 모드로 변환 (RGBA나 다른 모드 처리)
if img.mode in ('RGBA', 'LA', 'P'):
# 흰색 배경 생성
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
img.save(output_path, 'PDF', resolution=100.0)
return True, "성공"
except Exception as e:
return False, f"이미지 변환 실패: {str(e)}"
def convert_office_to_pdf(self, file_path, output_path):
"""Office 문서를 PDF로 변환"""
pythoncom.CoInitialize()
try:
ext = file_path.suffix.lower()
if ext in {'.hwp', '.hwpx'}:
return self.convert_hwp_to_pdf(file_path, output_path)
elif ext in {'.doc', '.docx'}:
return self.convert_word_to_pdf(file_path, output_path)
elif ext in {'.xls', '.xlsx'}:
return self.convert_excel_to_pdf(file_path, output_path)
elif ext in {'.ppt', '.pptx'}:
return self.convert_ppt_to_pdf(file_path, output_path)
else:
return False, "지원하지 않는 Office 형식"
except Exception as e:
return False, f"Office 변환 실패: {str(e)}"
finally:
pythoncom.CoUninitialize()
def convert_word_to_pdf(self, file_path, output_path):
"""Word 문서를 PDF로 변환"""
try:
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
doc = word.Documents.Open(str(file_path.absolute()))
doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF
doc.Close()
word.Quit()
return True, "성공"
except Exception as e:
return False, f"Word 변환 실패: {str(e)}"
def convert_excel_to_pdf(self, file_path, output_path):
"""Excel 파일을 PDF로 변환 - 열 너비에 맞춰 출력"""
try:
excel = win32com.client.Dispatch("Excel.Application")
excel.Visible = False
wb = excel.Workbooks.Open(str(file_path.absolute()))
# 모든 시트에 대해 페이지 설정
for ws in wb.Worksheets:
# 페이지 설정
ws.PageSetup.Zoom = False # 자동 크기 조정 비활성화
ws.PageSetup.FitToPagesWide = 1 # 너비를 1페이지에 맞춤
ws.PageSetup.FitToPagesTall = False # 높이는 자동 (내용에 따라)
# 여백 최소화 (단위: 포인트, 1cm ≈ 28.35 포인트)
ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1)
ws.PageSetup.RightMargin = excel.CentimetersToPoints(1)
ws.PageSetup.TopMargin = excel.CentimetersToPoints(1)
ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1)
# 용지 방향 자동 결정 (가로가 긴 경우 가로 방향)
used_range = ws.UsedRange
if used_range.Columns.Count > used_range.Rows.Count:
ws.PageSetup.Orientation = 2 # xlLandscape (가로)
else:
ws.PageSetup.Orientation = 1 # xlPortrait (세로)
# PDF로 저장
wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF
wb.Close()
excel.Quit()
return True, "성공"
except Exception as e:
return False, f"Excel 변환 실패: {str(e)}"
def convert_ppt_to_pdf(self, file_path, output_path):
"""PowerPoint 파일을 PDF로 변환"""
try:
ppt = win32com.client.Dispatch("PowerPoint.Application")
ppt.Visible = True
presentation = ppt.Presentations.Open(str(file_path.absolute()))
presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF
presentation.Close()
ppt.Quit()
return True, "성공"
except Exception as e:
return False, f"PowerPoint 변환 실패: {str(e)}"
def convert_hwp_to_pdf(self, file_path, output_path):
hwp = None
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
except Exception:
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
registered = False
last_reg_error = None
for module_name in getattr(self, "hwp_security_modules", [""]):
try:
hwp.RegisterModule("FilePathCheckDLL", module_name)
registered = True
break
except Exception as e:
last_reg_error = e
if not registered:
return False, f"HWP 보안 모듈 등록 실패: {last_reg_error}"
hwp.Open(str(file_path.absolute()), "", "")
hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute())
hwp.HParameterSet.HFileOpenSave.Format = "PDF"
hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet)
if output_path.exists() and output_path.stat().st_size > 0:
return True, "성공"
return False, "PDF 생성 확인 실패"
except Exception as e:
return False, f"HWP 변환 실패: {str(e)}"
finally:
try:
if hwp:
try:
hwp.Clear(1)
except Exception:
pass
try:
hwp.Quit()
except Exception:
pass
except Exception:
pass
def convert_text_to_pdf(self, text_path, output_path):
"""텍스트 파일을 PDF로 변환 (reportlab 사용)"""
try:
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# 한글 폰트 등록 (시스템에 설치된 폰트 사용)
try:
pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf'))
font_name = 'Malgun'
except:
font_name = 'Helvetica'
# 텍스트 읽기
with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
# PDF 생성
c = canvas.Canvas(str(output_path), pagesize=A4)
width, height = A4
c.setFont(font_name, 10)
# 여백 설정
margin = 50
y = height - margin
line_height = 14
# 줄 단위로 처리
for line in content.split('\n'):
if y < margin: # 페이지 넘김
c.showPage()
c.setFont(font_name, 10)
y = height - margin
# 긴 줄은 자동으로 줄바꿈
if len(line) > 100:
chunks = [line[i:i+100] for i in range(0, len(line), 100)]
for chunk in chunks:
c.drawString(margin, y, chunk)
y -= line_height
else:
c.drawString(margin, y, line)
y -= line_height
c.save()
return True, "성공"
except Exception as e:
return False, f"텍스트 변환 실패: {str(e)}"
def process_file(self, file_path):
"""개별 파일 처리"""
ext = file_path.suffix.lower()
# 출력 파일명 생성 (원본 경로 구조 유지)
relative_path = file_path.relative_to(self.source_dir)
output_subdir = self.output_dir / relative_path.parent
output_subdir.mkdir(parents=True, exist_ok=True)
# PDF 파일명
output_pdf = output_subdir / f"{file_path.stem}.pdf"
success = False
message = ""
try:
# 이미 PDF인 경우
if ext in self.pdf_extension:
shutil.copy2(file_path, output_pdf)
success = True
message = "PDF 복사 완료"
# DWG/DXF 파일
elif ext in self.dwg_extensions:
success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf)
# 이미지 파일
elif ext in self.image_extensions:
success, message = self.convert_image_to_pdf(file_path, output_pdf)
# Office 문서
elif ext in self.office_extensions:
success, message = self.convert_office_to_pdf(file_path, output_pdf)
# 동영상 파일 - 음성을 텍스트로 변환 후 PDF 생성
elif ext in self.video_extensions:
# 음성→텍스트 변환
transcript_text = self.get_video_transcript(file_path)
# 임시 txt 파일 생성
temp_txt = output_subdir / f"{file_path.stem}_transcript.txt"
with open(temp_txt, 'w', encoding='utf-8') as f:
f.write(transcript_text)
# txt를 PDF로 변환
success, message = self.convert_text_to_pdf(temp_txt, output_pdf)
if success:
message = "성공 (음성 인식 완료)"
# 임시 txt 파일은 남겨둠 (참고용)
# 텍스트 파일
elif ext in self.text_extensions:
success, message = self.convert_text_to_pdf(file_path, output_pdf)
else:
message = f"지원하지 않는 파일 형식: {ext}"
except Exception as e:
message = f"처리 중 오류: {str(e)}"
# 로그 기록
self.conversion_log.append({
'원본 경로': str(file_path),
'파일명': file_path.name,
'파일 형식': ext,
'변환 PDF 경로': str(output_pdf) if success else "",
'상태': "성공" if success else "실패",
'메시지': message,
'처리 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
return success, message
def create_excel_report(self, excel_path):
"""변환 결과를 엑셀로 저장"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "변환 결과"
# 헤더 스타일
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
# 헤더 작성
headers = ['번호', '원본 경로', '파일명', '파일 형식', '변환 PDF 경로', '상태', '메시지', '처리 시간']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 데이터 작성
for idx, log in enumerate(self.conversion_log, 2):
ws.cell(row=idx, column=1, value=idx-1)
ws.cell(row=idx, column=2, value=log['원본 경로'])
ws.cell(row=idx, column=3, value=log['파일명'])
ws.cell(row=idx, column=4, value=log['파일 형식'])
ws.cell(row=idx, column=5, value=log['변환 PDF 경로'])
# 상태에 따라 색상 표시
status_cell = ws.cell(row=idx, column=6, value=log['상태'])
if log['상태'] == "성공":
status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
status_cell.font = Font(color="006100")
else:
status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
status_cell.font = Font(color="9C0006")
ws.cell(row=idx, column=7, value=log['메시지'])
ws.cell(row=idx, column=8, value=log['처리 시간'])
# 열 너비 자동 조정
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# 요약 시트 추가
summary_ws = wb.create_sheet(title="요약")
total_files = len(self.conversion_log)
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
fail_count = total_files - success_count
summary_data = [
['항목', ''],
['총 파일 수', total_files],
['변환 성공', success_count],
['변환 실패', fail_count],
['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"],
['', ''],
['원본 폴더', str(self.source_dir)],
['출력 폴더', str(self.output_dir)],
['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
]
for row_idx, row_data in enumerate(summary_data, 1):
for col_idx, value in enumerate(row_data, 1):
cell = summary_ws.cell(row=row_idx, column=col_idx, value=value)
if row_idx == 1:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left')
summary_ws.column_dimensions['A'].width = 20
summary_ws.column_dimensions['B'].width = 60
# 저장
wb.save(excel_path)
print(f"\n엑셀 보고서 생성 완료: {excel_path}")
def run(self):
"""전체 변환 작업 실행"""
print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"원본 폴더: {self.source_dir}")
print(f"출력 폴더: {self.output_dir}")
# DWG TrueView 확인
if self.trueview_path:
print(f"DWG TrueView 발견: {self.trueview_path}")
else:
print("경고: DWG TrueView를 찾을 수 없습니다. DWG 파일 변환이 불가능합니다.")
print("-" * 80)
# 모든 파일 가져오기
all_files = self.get_all_files()
total_files = len(all_files)
# ★ 파일 분류: 동영상 vs 나머지
video_files = []
other_files = []
for file_path in all_files:
if file_path.suffix.lower() in self.video_extensions:
video_files.append(file_path)
else:
other_files.append(file_path)
print(f"\n{total_files}개 파일 발견")
print(f" - 문서/이미지 등: {len(other_files)}")
print(f" - 동영상: {len(video_files)}")
print("\n[1단계] 문서 파일 변환 시작...\n")
# ★ 1단계: 문서 파일 먼저 처리
for idx, file_path in enumerate(other_files, 1):
print(f"[{idx}/{len(other_files)}] {file_path.name} 처리 중...", end=' ')
success, message = self.process_file(file_path)
print(f"{'' if success else ''} {message}")
# ★ 2단계: domain.txt 로드
domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테스트 중(측량)\domain.txt
if domain_path.exists():
self.domain_terms = domain_path.read_text(encoding='utf-8')
print(f"\n[2단계] 도메인 용어 사전 로드 완료: {domain_path}")
print(f" - 용어 수: 약 {len(self.domain_terms.split())}개 단어")
else:
print(f"\n[2단계] 도메인 용어 사전 없음: {domain_path}")
print(" - 기본 음성 인식으로 진행합니다.")
# ★ 3단계: 동영상 파일 처리
if video_files:
print(f"\n[3단계] 동영상 음성 인식 시작...\n")
for idx, file_path in enumerate(video_files, 1):
print(f"[{idx}/{len(video_files)}] {file_path.name} 처리 중...", end=' ')
success, message = self.process_file(file_path)
print(f"{'' if success else ''} {message}")
# 엑셀 보고서 생성
excel_path = self.output_dir / f"변환_결과_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
self.create_excel_report(excel_path)
# 최종 요약
success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공")
print("\n" + "=" * 80)
print(f"작업 완료!")
print(f"총 파일: {total_files}")
print(f"성공: {success_count}")
print(f"실패: {total_files - success_count}")
print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%")
print("=" * 80)
if __name__ == "__main__":
# 경로 설정
SOURCE_DIR = r"D:\for python\테스트 중(측량)\측량_GIS_드론 관련 자료들"
OUTPUT_DIR = r"D:\for python\테스트 중(측량)\추출"
# 변환기 실행
converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR)
converter.run()

View File

@@ -0,0 +1,789 @@
# -*- coding: utf-8 -*-
"""
extract_1_v2.py
PDF에서 텍스트(md)와 이미지(png)를 추출
- 하위 폴더 구조 유지
- 이미지 메타데이터 JSON 생성 (폴더경로, 파일명, 페이지, 위치, 캡션 등)
"""
import fitz # PyMuPDF
import os
import re
import json
import numpy as np
from pathlib import Path
from datetime import datetime
from PIL import Image
import io
# ===== OCR 설정 (선택적) =====
try:
import pytesseract
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
print("[INFO] pytesseract 미설치 - 텍스트 잘림 필터 비활성화")
# ===== 경로 설정 =====
BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원본 위치
OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치
CAPTION_PATTERN = re.compile(
r'^\s*(?:[<\[\(\{]\s*)?(그림|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-]\s*\d+)?',
re.IGNORECASE
)
def get_figure_rects(page):
"""
Identifies figure regions based on '<그림 N>' captions and vector drawings.
Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index}
"""
drawings = page.get_drawings()
blocks = page.get_text("blocks")
captions = []
for i, b in enumerate(blocks):
text = b[4]
if CAPTION_PATTERN.search(text):
captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []})
if not captions:
return []
filtered_drawings_rects = []
for d in drawings:
r = d["rect"]
if r.height > page.rect.height / 3 and r.width < 5:
continue
if r.width > page.rect.width * 0.9:
continue
filtered_drawings_rects.append(r)
page_area = page.rect.get_area()
img_rects = []
for b in page.get_text("dict")["blocks"]:
if b.get("type") == 1:
ir = fitz.Rect(b["bbox"])
if ir.get_area() < page_area * 0.01:
continue
img_rects.append(ir)
remaining_drawings = filtered_drawings_rects + img_rects
caption_clusters = {cap['index']: [cap['rect']] for cap in captions}
def is_text_between(r1, r2, text_blocks):
if r1.intersects(r2):
return False
union = r1 | r2
for b in text_blocks:
b_rect = fitz.Rect(b[:4])
text_content = b[4]
if len(text_content.strip()) < 20:
continue
if not b_rect.intersects(union):
continue
if b_rect.intersects(r1) or b_rect.intersects(r2):
continue
return True
return False
changed = True
while changed:
changed = False
to_remove = []
for d_rect in remaining_drawings:
best_cluster_key = None
min_dist = float('inf')
for cap_index, cluster_rects in caption_clusters.items():
for r in cluster_rects:
dist = 0
if d_rect.intersects(r):
dist = 0
else:
x_dist = 0
if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1
elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1
y_dist = 0
if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1
elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1
if x_dist < 150 and y_dist < 150:
dist = max(x_dist, y_dist) + 0.1
else:
dist = float('inf')
if dist < min_dist:
if not is_text_between(r, d_rect, blocks):
min_dist = dist
best_cluster_key = cap_index
if min_dist == 0:
break
if best_cluster_key is not None and min_dist < 150:
caption_clusters[best_cluster_key].append(d_rect)
to_remove.append(d_rect)
changed = True
for r in to_remove:
remaining_drawings.remove(r)
figure_regions = []
for cap in captions:
cluster_rects = caption_clusters[cap['index']]
content_rects = cluster_rects[1:]
if not content_rects:
continue
union_rect = content_rects[0]
for r in content_rects[1:]:
union_rect = union_rect | r
union_rect.x0 = max(0, union_rect.x0 - 5)
union_rect.x1 = min(page.rect.width, union_rect.x1 + 5)
union_rect.y0 = max(0, union_rect.y0 - 5)
union_rect.y1 = min(page.rect.height, union_rect.y1 + 5)
cap_rect = cap['rect']
if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2:
if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2
else:
if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2
area = union_rect.get_area()
page_area = page.rect.get_area()
if area < page_area * 0.01:
continue
if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6:
continue
if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6:
continue
text_blocks = page.get_text("blocks")
text_count = 0
for b in text_blocks:
b_rect = fitz.Rect(b[:4])
if not b_rect.intersects(union_rect):
continue
text = b[4].strip()
if len(text) < 5:
continue
text_count += 1
if text_count < 0:
continue
figure_regions.append({
'rect': union_rect,
'caption_index': cap['index'],
'caption_rect': cap['rect'],
'caption_text': cap['text'].strip() # ★ 캡션 텍스트 저장
})
return figure_regions
def pixmap_metrics(pix):
arr = np.frombuffer(pix.samples, dtype=np.uint8)
c = 4 if pix.alpha else 3
arr = arr.reshape(pix.height, pix.width, c)[:, :, :3]
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8)
white = gray > 245
nonwhite_ratio = float(1.0 - white.mean())
gx = np.abs(np.diff(gray.astype(np.int16), axis=1))
gy = np.abs(np.diff(gray.astype(np.int16), axis=0))
edge = (gx[:-1, :] + gy[:, :-1]) > 40
edge_ratio = float(edge.mean())
var = float(gray.var())
return nonwhite_ratio, edge_ratio, var
def keep_figure(pix):
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
if nonwhite_ratio < 0.004:
return False, nonwhite_ratio, edge_ratio, var
if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20:
return False, nonwhite_ratio, edge_ratio, var
return True, nonwhite_ratio, edge_ratio, var
# ===== 추가 이미지 필터 함수들 (v2.1) =====
def pix_to_pil(pix):
"""PyMuPDF Pixmap을 PIL Image로 변환"""
img_data = pix.tobytes("png")
return Image.open(io.BytesIO(img_data))
def has_cut_text_at_boundary(pix, margin=5):
"""
이미지 경계에서 텍스트가 잘렸는지 감지
- 이미지 테두리 근처에 텍스트 박스가 있으면 잘린 것으로 판단
Args:
pix: PyMuPDF Pixmap
margin: 경계로부터의 여유 픽셀 (기본 5px)
Returns:
bool: 텍스트가 잘렸으면 True
"""
if not TESSERACT_AVAILABLE:
return False # OCR 없으면 필터 비활성화
try:
img = pix_to_pil(pix)
width, height = img.size
# OCR로 텍스트 위치 추출
data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT)
for i, text in enumerate(data['text']):
text = str(text).strip()
if len(text) < 2: # 너무 짧은 텍스트는 무시
continue
x = data['left'][i]
y = data['top'][i]
w = data['width'][i]
h = data['height'][i]
# 텍스트가 이미지 경계에 너무 가까우면 = 잘린 것
# 왼쪽 경계
if x <= margin:
return True
# 오른쪽 경계
if x + w >= width - margin:
return True
# 상단 경계 (헤더 제외를 위해 좀 더 여유)
if y <= margin and h < height * 0.3:
return True
# 하단 경계
if y + h >= height - margin:
return True
return False
except Exception as e:
# OCR 실패 시 필터 통과 (이미지 유지)
return False
def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500):
"""
배경 패턴 + 텍스트만 있는 장식용 이미지인지 감지
- 엣지가 적고 (복잡한 도표/사진이 아님)
- 색상 다양성이 낮으면 (단순 그라데이션 배경)
Args:
pix: PyMuPDF Pixmap
edge_threshold: 엣지 비율 임계값 (기본 0.02 = 2%)
color_var_threshold: 색상 분산 임계값
Returns:
bool: 장식용 배경이면 True
"""
try:
nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix)
# 엣지가 거의 없고 (단순한 이미지)
# 색상 분산도 낮으면 (배경 패턴)
if edge_ratio < edge_threshold and var < color_var_threshold:
# 추가 확인: 텍스트만 있는지 OCR로 체크
if TESSERACT_AVAILABLE:
try:
img = pix_to_pil(pix)
text = pytesseract.image_to_string(img, lang='kor+eng').strip()
# 텍스트가 있고, 이미지가 단순하면 = 텍스트 배경
if len(text) > 3 and edge_ratio < 0.015:
return True
except:
pass
return True
return False
except Exception:
return False
def is_header_footer_region(rect, page_rect, height_threshold=0.12):
"""
헤더/푸터 영역에 있는 이미지인지 감지
- 페이지 상단 12% 또는 하단 12%에 위치
- 높이가 낮은 strip 형태
Args:
rect: 이미지 영역 (fitz.Rect)
page_rect: 페이지 전체 영역 (fitz.Rect)
height_threshold: 헤더/푸터 영역 비율 (기본 12%)
Returns:
bool: 헤더/푸터 영역이면 True
"""
page_height = page_rect.height
img_height = rect.height
# 상단 영역 체크
if rect.y0 < page_height * height_threshold:
# 높이가 페이지의 15% 미만인 strip이면 헤더
if img_height < page_height * 0.15:
return True
# 하단 영역 체크
if rect.y1 > page_height * (1 - height_threshold):
# 높이가 페이지의 15% 미만인 strip이면 푸터
if img_height < page_height * 0.15:
return True
return False
def should_filter_image(pix, rect, page_rect):
"""
이미지를 필터링해야 하는지 종합 판단
Args:
pix: PyMuPDF Pixmap
rect: 이미지 영역
page_rect: 페이지 전체 영역
Returns:
tuple: (필터링 여부, 필터링 사유)
"""
# 1. 헤더/푸터 영역 체크
if is_header_footer_region(rect, page_rect):
return True, "header_footer"
# 2. 텍스트 잘림 체크
if has_cut_text_at_boundary(pix):
return True, "cut_text"
# 3. 장식용 배경 체크
if is_decorative_background(pix):
return True, "decorative_background"
return False, None
def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata):
"""
PDF 내용 추출
Args:
pdf_path: PDF 파일 경로
output_md_path: 출력 MD 파일 경로
img_dir: 이미지 저장 폴더
metadata: 메타데이터 딕셔너리 (폴더 경로, 파일명 등)
Returns:
image_metadata_list: 추출된 이미지들의 메타데이터 리스트
"""
os.makedirs(img_dir, exist_ok=True)
image_metadata_list = [] # ★ 이미지 메타데이터 수집
doc = fitz.open(pdf_path)
total_pages = len(doc)
with open(output_md_path, "w", encoding="utf-8") as md_file:
# ★ 메타데이터 헤더 추가
md_file.write(f"---\n")
md_file.write(f"source_pdf: {metadata['pdf_name']}\n")
md_file.write(f"source_folder: {metadata['relative_folder']}\n")
md_file.write(f"total_pages: {total_pages}\n")
md_file.write(f"extracted_at: {datetime.now().isoformat()}\n")
md_file.write(f"---\n\n")
md_file.write(f"# {metadata['pdf_name']}\n\n")
for page_num, page in enumerate(doc):
md_file.write(f"\n## Page {page_num + 1}\n\n")
img_rel_dir = os.path.basename(img_dir)
figure_regions = get_figure_rects(page)
kept_figures = []
for i, fig in enumerate(figure_regions):
rect = fig['rect']
pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB)
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
if not ok:
continue
pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB)
# ★ 추가 필터 적용 (v2.1)
should_filter, filter_reason = should_filter_image(pix, rect, page.rect)
if should_filter:
continue
img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png"
img_path = os.path.join(img_dir, img_name)
pix.save(img_path)
fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/")
fig['img_name'] = img_name
kept_figures.append(fig)
# ★ 이미지 메타데이터 수집
image_metadata_list.append({
"image_file": img_name,
"image_path": str(Path(img_dir) / img_name),
"type": "figure",
"source_pdf": metadata['pdf_name'],
"source_folder": metadata['relative_folder'],
"full_path": metadata['full_path'],
"page": page_num + 1,
"total_pages": total_pages,
"caption": fig.get('caption_text', ''),
"rect": {
"x0": round(rect.x0, 2),
"y0": round(rect.y0, 2),
"x1": round(rect.x1, 2),
"y1": round(rect.y1, 2)
}
})
figure_regions = kept_figures
caption_present = any(
CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks")
)
uncaptioned_idx = 0
items = []
def inside_any_figure(block_rect, figures):
for fig in figures:
intersect = block_rect & fig["rect"]
if intersect.get_area() > 0.5 * block_rect.get_area():
return True
return False
def is_full_width_rect(r, page_rect):
return r.width >= page_rect.width * 0.78
def figure_anchor_rect(fig, page_rect):
cap = fig["caption_rect"]
rect = fig["rect"]
if cap.y0 >= rect.y0:
y = max(0.0, cap.y0 - 0.02)
else:
y = min(page_rect.height - 0.02, cap.y1 + 0.02)
return fitz.Rect(cap.x0, y, cap.x1, y + 0.02)
for fig in figure_regions:
anchor = figure_anchor_rect(fig, page.rect)
md = (
f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n"
f"*{fig.get('caption_text', '')}*\n\n"
)
items.append({
"kind": "figure",
"rect": anchor,
"kind_order": 0,
"md": md,
})
raw_blocks = page.get_text("dict")["blocks"]
for block in raw_blocks:
block_rect = fitz.Rect(block["bbox"])
if block.get("type") == 0:
if inside_any_figure(block_rect, figure_regions):
continue
items.append({
"kind": "text",
"rect": block_rect,
"kind_order": 2,
"block": block,
})
continue
if block.get("type") == 1:
if inside_any_figure(block_rect, figure_regions):
continue
if caption_present:
continue
page_area = page.rect.get_area()
if block_rect.get_area() < page_area * 0.005:
continue
ratio = block_rect.width / max(1.0, block_rect.height)
if ratio < 0.25 or ratio > 4.0:
continue
pix_preview = page.get_pixmap(
clip=block_rect, dpi=80, colorspace=fitz.csRGB
)
ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview)
if not ok:
continue
pix = page.get_pixmap(
clip=block_rect, dpi=150, colorspace=fitz.csRGB
)
# ★ 추가 필터 적용 (v2.1)
should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect)
if should_filter:
continue
img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png"
img_path = os.path.join(img_dir, img_name)
pix.save(img_path)
rel = os.path.join(img_rel_dir, img_name).replace("\\", "/")
r = block_rect
md = (
f'\n![Photo]({rel})\n'
f'*Page {page_num + 1} Photo*\n\n'
)
items.append({
"kind": "raster",
"rect": block_rect,
"kind_order": 1,
"md": md,
})
# ★ 캡션 없는 이미지 메타데이터
image_metadata_list.append({
"image_file": img_name,
"image_path": str(Path(img_dir) / img_name),
"type": "photo",
"source_pdf": metadata['pdf_name'],
"source_folder": metadata['relative_folder'],
"full_path": metadata['full_path'],
"page": page_num + 1,
"total_pages": total_pages,
"caption": "",
"rect": {
"x0": round(r.x0, 2),
"y0": round(r.y0, 2),
"x1": round(r.x1, 2),
"y1": round(r.y1, 2)
}
})
uncaptioned_idx += 1
continue
# 읽기 순서 정렬
text_items = [it for it in items if it["kind"] == "text"]
page_w = page.rect.width
mid = page_w / 2.0
candidates = []
for it in text_items:
r = it["rect"]
if is_full_width_rect(r, page.rect):
continue
if r.width < page_w * 0.2:
continue
candidates.append(it)
left = [it for it in candidates if it["rect"].x0 < mid * 0.95]
right = [it for it in candidates if it["rect"].x0 > mid * 1.05]
two_cols = len(left) >= 3 and len(right) >= 3
col_y0 = None
col_y1 = None
seps = []
if two_cols and left and right:
col_y0 = min(
min(it["rect"].y0 for it in left),
min(it["rect"].y0 for it in right),
)
col_y1 = max(
max(it["rect"].y1 for it in left),
max(it["rect"].y1 for it in right),
)
for it in text_items:
r = it["rect"]
if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect):
seps.append(r.y0)
seps = sorted(set(seps))
def seg_index(y0, separators):
if not separators:
return 0
n = 0
for s in separators:
if y0 >= s:
n += 1
else:
break
return n
def order_key(it):
r = it["rect"]
if not two_cols:
return (r.y0, r.x0, it["kind_order"])
if col_y0 is not None and r.y1 <= col_y0:
return (0, r.y0, r.x0, it["kind_order"])
if col_y1 is not None and r.y0 >= col_y1:
return (2, r.y0, r.x0, it["kind_order"])
seg = seg_index(r.y0, seps)
if is_full_width_rect(r, page.rect):
col = 2
else:
col = 0 if r.x0 < mid else 1
return (1, seg, col, r.y0, r.x0, it["kind_order"])
items.sort(key=order_key)
for it in items:
if it["kind"] in ("figure", "raster"):
md_file.write(it["md"])
continue
block = it["block"]
for line in block.get("lines", []):
for span in line.get("spans", []):
md_file.write(span.get("text", "") + " ")
md_file.write("\n")
md_file.write("\n")
doc.close()
return image_metadata_list
def process_all_pdfs():
"""
BASE_DIR 하위의 모든 PDF를 재귀적으로 처리
폴더 구조를 유지하면서 OUTPUT_BASE에 저장
"""
# 출력 폴더 생성
OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
# 전체 이미지 메타데이터 수집
all_image_metadata = []
# 처리 통계
stats = {
"total_pdfs": 0,
"success": 0,
"failed": 0,
"total_images": 0
}
# 실패 로그
failed_files = []
print(f"=" * 60)
print(f"PDF 추출 시작")
print(f"원본 폴더: {BASE_DIR}")
print(f"출력 폴더: {OUTPUT_BASE}")
print(f"=" * 60)
# 모든 PDF 파일 찾기
pdf_files = list(BASE_DIR.rglob("*.pdf"))
stats["total_pdfs"] = len(pdf_files)
print(f"\n{len(pdf_files)}개 PDF 발견\n")
for idx, pdf_path in enumerate(pdf_files, 1):
try:
# 상대 경로 계산
relative_path = pdf_path.relative_to(BASE_DIR)
relative_folder = str(relative_path.parent)
if relative_folder == ".":
relative_folder = ""
pdf_name = pdf_path.name
pdf_stem = pdf_path.stem
# 출력 경로 설정 (폴더 구조 유지)
output_folder = OUTPUT_BASE / relative_path.parent
output_folder.mkdir(parents=True, exist_ok=True)
output_md = output_folder / f"{pdf_stem}.md"
img_folder = output_folder / f"{pdf_stem}_img"
# 메타데이터 준비
metadata = {
"pdf_name": pdf_name,
"pdf_stem": pdf_stem,
"relative_folder": relative_folder,
"full_path": str(relative_path),
}
print(f"[{idx}/{len(pdf_files)}] {relative_path}")
# PDF 처리
image_metas = extract_pdf_content(
str(pdf_path),
str(output_md),
str(img_folder),
metadata
)
all_image_metadata.extend(image_metas)
stats["success"] += 1
stats["total_images"] += len(image_metas)
print(f" ✓ 완료 (이미지 {len(image_metas)}개)")
except Exception as e:
stats["failed"] += 1
failed_files.append({
"file": str(pdf_path),
"error": str(e)
})
print(f" ✗ 실패: {e}")
# 전체 이미지 메타데이터 저장
meta_output_path = OUTPUT_BASE / "image_metadata.json"
with open(meta_output_path, "w", encoding="utf-8") as f:
json.dump(all_image_metadata, f, ensure_ascii=False, indent=2)
# 처리 요약 저장
summary = {
"processed_at": datetime.now().isoformat(),
"source_dir": str(BASE_DIR),
"output_dir": str(OUTPUT_BASE),
"statistics": stats,
"failed_files": failed_files
}
summary_path = OUTPUT_BASE / "extraction_summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
# 결과 출력
print(f"\n" + "=" * 60)
print(f"추출 완료!")
print(f"=" * 60)
print(f"총 PDF: {stats['total_pdfs']}")
print(f"성공: {stats['success']}")
print(f"실패: {stats['failed']}")
print(f"추출된 이미지: {stats['total_images']}")
print(f"\n이미지 메타데이터: {meta_output_path}")
print(f"처리 요약: {summary_path}")
if failed_files:
print(f"\n실패한 파일:")
for f in failed_files:
print(f" - {f['file']}: {f['error']}")
if __name__ == "__main__":
process_all_pdfs()

View File

@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
"""
domain_prompt.py
기능:
- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파일들의
파일명과 내용 일부를 샘플링한다.
- 이 샘플을 기반으로, 문서 묶음의 분야/업무 맥락을 파악하고
"너는 ~~ 분야의 전문가이다. 나는 ~~를 하고 싶다..." 형식의
도메인 전용 시스템 프롬프트를 자동 생성한다.
- 결과는 output/context/domain_prompt.txt 로 저장된다.
이 domain_prompt.txt 내용은 이후 모든 GPT 호출(system role)에 공통으로 붙여 사용할 수 있다.
"""
import os
import sys
import json
from pathlib import Path
import pdfplumber
import fitz # PyMuPDF
from PIL import Image
import pytesseract
import pandas as pd
from openai import OpenAI
import pytesseract
from api_config import API_KEYS
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\extract")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조만 유지, 키는 마스터가 직접 입력) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== OCR 설정 =====
OCR_LANG = "kor+eng"
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"}
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def safe_rel(p: Path) -> str:
try:
return str(p.relative_to(DATA_ROOT))
except Exception:
return str(p)
def ocr_image(img_path: Path) -> str:
try:
return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip()
except Exception as e:
log(f"[WARN] OCR 실패: {safe_rel(img_path)} | {e}")
return ""
def sample_from_pdf(p: Path, max_chars: int = 1000) -> str:
texts = []
try:
with pdfplumber.open(str(p)) as pdf:
# 앞쪽 몇 페이지만 샘플링
for page in pdf.pages[:3]:
t = page.extract_text() or ""
if t:
texts.append(t)
if sum(len(x) for x in texts) >= max_chars:
break
except Exception as e:
log(f"[WARN] PDF 샘플 추출 실패: {safe_rel(p)} | {e}")
joined = "\n".join(texts)
return joined[:max_chars]
def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str:
texts = [f"[파일명] {p.name}"]
try:
xls = pd.ExcelFile(str(p))
for sheet_name in xls.sheet_names[:3]:
try:
df = xls.parse(sheet_name)
except Exception as e:
log(f"[WARN] 시트 로딩 실패: {safe_rel(p)} | {sheet_name} | {e}")
continue
texts.append(f"\n[시트] {sheet_name}")
texts.append("컬럼: " + ", ".join(map(str, df.columns)))
head = df.head(5)
texts.append(head.to_string(index=False))
if sum(len(x) for x in texts) >= max_chars:
break
except Exception as e:
log(f"[WARN] XLSX 샘플 추출 실패: {safe_rel(p)} | {e}")
joined = "\n".join(texts)
return joined[:max_chars]
def sample_from_text_file(p: Path, max_chars: int = 1000) -> str:
try:
t = p.read_text(encoding="utf-8", errors="ignore")
except Exception:
t = p.read_text(encoding="cp949", errors="ignore")
return t[:max_chars]
def gather_file_samples(
max_files_per_type: int = 100,
max_total_samples: int = 300,
max_chars_per_sample: int = 1000,
):
file_names = []
samples = []
count_pdf = 0
count_xlsx = 0
count_img = 0
count_txt = 0
for root, dirs, files in os.walk(DATA_ROOT):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
cur_dir = Path(root)
for fname in files:
fpath = cur_dir / fname
ext = fpath.suffix.lower()
# 파일명은 전체 다 모으되, 샘플 추출은 제한
file_names.append(safe_rel(fpath))
if len(samples) >= max_total_samples:
continue
try:
if ext == ".pdf" and count_pdf < max_files_per_type:
s = sample_from_pdf(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[PDF] {safe_rel(fpath)}\n{s}")
count_pdf += 1
continue
if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type:
s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}")
count_xlsx += 1
continue
if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type:
s = ocr_image(fpath)
if s.strip():
samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}")
count_img += 1
continue
if ext in {".txt", ".md"} and count_txt < max_files_per_type:
s = sample_from_text_file(fpath, max_chars=max_chars_per_sample)
if s.strip():
samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}")
count_txt += 1
continue
except Exception as e:
log(f"[WARN] 샘플 추출 실패: {safe_rel(fpath)} | {e}")
continue
return file_names, samples
def build_domain_prompt():
"""
파일명 + 내용 샘플을 GPT에게 넘겨
'너는 ~~ 분야의 전문가이다...' 형태의 시스템 프롬프트를 생성한다.
"""
log("도메인 프롬프트 생성을 위한 샘플 수집 중...")
file_names, samples = gather_file_samples()
if not file_names and not samples:
log("파일 샘플이 없어 도메인 프롬프트를 생성할 수 없습니다.")
sys.exit(1)
file_names_text = "\n".join(file_names[:80])
sample_text = "\n\n".join(samples[:30])
prompt = f"""
다음은 한 기업의 '이슈 리포트 및 시스템 관련 자료'로 추정되는 파일들의 목록과,
각 파일에서 일부 추출한 내용 샘플이다.
[파일명 목록]
{file_names_text}
[내용 샘플]
{sample_text}
위 자료를 바탕으로 다음을 수행하라.
1) 이 문서 묶음이 어떤 산업, 업무, 분야에 대한 것인지,
핵심 키워드를 포함해 2~3줄 정도로 설명하라.
2) 이후, 이 문서들을 다루는 AI에게 사용할 "프롬프트 머리말"을 작성하라.
이 머리말은 모든 후속 프롬프트 앞에 항상 붙일 예정이며,
다음 조건을 만족해야 한다.
- 첫 문단: "너는 ~~ 분야의 전문가이다." 형식으로, 이 문서 묶음의 분야와 역할을 정의한다.
- 두 번째 문단 이후: "나는 ~~을 하고 싶다.", "우리는 ~~ 의 문제를 분석하고 개선방안을 찾고자 한다."
사용자가 AI에게 요구하는 전반적 목적과 관점을 정리한다.
- 총 5~7줄 정도의 한국어 문장으로 작성한다.
- 이후에 붙을 프롬프트(청킹, 요약, RAG, 보고서 작성 등)와 자연스럽게 연결될 수 있도록,
역할(role), 목적, 기준(추측 금지, 사실 기반, 근거 명시 등)을 모두 포함한다.
출력 형식:
- 설명과 머리말을 한 번에 출력하되,
별도의 마크다운 없이 순수 텍스트로만 작성하라.
- 이 출력 전체를 domain_prompt.txt에 그대로 저장할 것이다.
"""
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{
"role": "system",
"content": "너는 문서 묶음의 분야를 식별하고, 그에 맞는 AI 시스템 프롬프트와 컨텍스트를 설계하는 컨설턴트이다."
},
{
"role": "user",
"content": prompt
}
],
)
content = (resp.choices[0].message.content or "").strip()
out_path = CONTEXT_DIR / "domain_prompt.txt"
out_path.write_text(content, encoding="utf-8")
log(f"도메인 프롬프트 생성 완료: {out_path}")
return content
def main():
log("=== 도메인 프롬프트 생성 시작 ===")
out_path = CONTEXT_DIR / "domain_prompt.txt"
if out_path.exists():
log(f"이미 domain_prompt.txt가 존재합니다: {out_path}")
log("기존 파일을 사용하려면 종료하고, 재생성이 필요하면 파일을 삭제한 뒤 다시 실행하십시오.")
else:
build_domain_prompt()
log("=== 도메인 프롬프트 작업 종료 ===")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,357 @@
# -*- coding: utf-8 -*-
"""
chunk_and_summary_v2.py
기능:
- 정리중 폴더 아래의 .md 파일들을 대상으로
1) domain_prompt.txt 기반 GPT 의미 청킹
2) 청크별 요약 생성
3) 청크 내 이미지 참조 보존
4) JSON 저장 (원문+청크+요약+이미지)
5) RAG용 *_chunks.json 저장
전제:
- extract_1_v2.py 실행 후 .md 파일들이 존재할 것
- step1_domainprompt.py 실행 후 domain_prompt.txt가 존재할 것
"""
import os
import sys
import json
import re
from pathlib import Path
from datetime import datetime
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
TEXT_DIR = OUTPUT_ROOT / "text"
JSON_DIR = OUTPUT_ROOT / "json"
RAG_DIR = OUTPUT_ROOT / "rag"
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 스킵할 폴더 =====
SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"}
# ===== 이미지 참조 패턴 =====
IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log(f"domain_prompt.txt가 없습니다: {p}")
log("먼저 step1_domainprompt.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def safe_rel(p: Path) -> str:
"""DATA_ROOT 기준 상대 경로 반환"""
try:
return str(p.relative_to(DATA_ROOT))
except Exception:
return str(p)
def extract_text_md(p: Path) -> str:
"""마크다운 파일 텍스트 읽기"""
try:
return p.read_text(encoding="utf-8", errors="ignore")
except Exception:
return p.read_text(encoding="cp949", errors="ignore")
def find_images_in_text(text: str) -> list:
"""텍스트에서 이미지 참조 찾기"""
matches = IMAGE_PATTERN.findall(text)
return [{"alt": m[0], "path": m[1]} for m in matches]
def semantic_chunk(domain_prompt: str, text: str, source_name: str):
"""GPT 기반 의미 청킹"""
if not text.strip():
return []
# 텍스트가 너무 짧으면 그냥 하나의 청크로
if len(text) < 500:
return [{
"title": "전체 내용",
"keywords": "",
"content": text
}]
user_prompt = f"""
아래 문서를 의미 단위(문단/항목/섹션 등)로 분리하고,
각 청크는 title / keywords / content 를 포함한 JSON 배열로 출력하라.
규칙:
1. 추측 금지, 문서 내용 기반으로만 분리
2. 이미지 참조(![...](...))는 관련 텍스트와 같은 청크에 포함
3. 각 청크는 최소 100자 이상
4. keywords는 쉼표로 구분된 핵심 키워드 3~5개
문서:
{text[:12000]}
JSON 배열만 출력하라. 다른 설명 없이.
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 의미 기반 청킹 전문가이다. JSON 배열만 출력한다."},
{"role": "user", "content": user_prompt},
],
)
data = resp.choices[0].message.content.strip()
# JSON 파싱 시도
# ```json ... ``` 형식 처리
if "```json" in data:
data = data.split("```json")[1].split("```")[0].strip()
elif "```" in data:
data = data.split("```")[1].split("```")[0].strip()
if data.startswith("["):
return json.loads(data)
except json.JSONDecodeError as e:
log(f"[WARN] JSON 파싱 실패 ({source_name}): {e}")
except Exception as e:
log(f"[WARN] semantic_chunk API 실패 ({source_name}): {e}")
# fallback: 페이지/섹션 기반 분리
log(f"[INFO] Fallback 청킹 적용: {source_name}")
return fallback_chunk(text)
def fallback_chunk(text: str) -> list:
"""GPT 실패 시 대체 청킹 (페이지/섹션 기반)"""
chunks = []
# 페이지 구분자로 분리 시도
if "## Page " in text:
pages = re.split(r'\n## Page \d+\n', text)
for i, page_content in enumerate(pages):
if page_content.strip():
chunks.append({
"title": f"Page {i+1}",
"keywords": "",
"content": page_content.strip()
})
else:
# 빈 줄 2개 이상으로 분리
sections = re.split(r'\n{3,}', text)
for i, section in enumerate(sections):
if section.strip() and len(section.strip()) > 50:
chunks.append({
"title": f"섹션 {i+1}",
"keywords": "",
"content": section.strip()
})
# 청크가 없으면 전체를 하나로
if not chunks:
chunks.append({
"title": "전체 내용",
"keywords": "",
"content": text.strip()
})
return chunks
def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str:
"""청크 요약 생성"""
if not text.strip():
return ""
# 이미지 참조 제거 후 요약 (텍스트만)
text_only = IMAGE_PATTERN.sub('', text).strip()
if len(text_only) < 100:
return text_only
prompt = f"""
아래 텍스트를 {limit}자 이내로 사실 기반으로 요약하라.
추측 금지, 고유명사와 수치는 보존.
{text_only[:8000]}
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 사실만 요약하는 전문가이다."},
{"role": "user", "content": prompt},
],
)
return resp.choices[0].message.content.strip()
except Exception as e:
log(f"[WARN] summary 실패: {e}")
return text_only[:limit]
def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int:
"""
의미 청킹 → 요약 → JSON 저장
Returns:
생성된 청크 수
"""
stem = src.stem
folder_ctx = safe_rel(src.parent)
# 원문 저장
(TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore")
# 의미 청킹
chunks = semantic_chunk(domain_prompt, text, src.name)
if not chunks:
log(f"[WARN] 청크 없음: {src.name}")
return 0
rag_items = []
for idx, ch in enumerate(chunks, start=1):
content = ch.get("content", "")
# 요약 생성
summ = summary_chunk(domain_prompt, content, 300)
# 이 청크에 포함된 이미지 찾기
images_in_chunk = find_images_in_text(content)
rag_items.append({
"source": src.name,
"source_path": safe_rel(src),
"chunk": idx,
"total_chunks": len(chunks),
"title": ch.get("title", ""),
"keywords": ch.get("keywords", ""),
"text": content,
"summary": summ,
"folder_context": folder_ctx,
"images": images_in_chunk,
"has_images": len(images_in_chunk) > 0
})
# JSON 저장
(JSON_DIR / f"{stem}.json").write_text(
json.dumps(rag_items, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# RAG용 JSON 저장
(RAG_DIR / f"{stem}_chunks.json").write_text(
json.dumps(rag_items, ensure_ascii=False, indent=2),
encoding="utf-8"
)
return len(chunks)
def main():
log("=" * 60)
log("청킹/요약 파이프라인 시작")
log(f"데이터 폴더: {DATA_ROOT}")
log(f"출력 폴더: {OUTPUT_ROOT}")
log("=" * 60)
# 도메인 프롬프트 로드
domain_prompt = load_domain_prompt()
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
# 통계
stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0}
# .md 파일 찾기
md_files = []
for root, dirs, files in os.walk(DATA_ROOT):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")]
for fname in files:
if fname.lower().endswith(".md"):
md_files.append(Path(root) / fname)
log(f"\n{len(md_files)}개 .md 파일 발견\n")
for idx, fpath in enumerate(md_files, 1):
try:
rel_path = safe_rel(fpath)
log(f"[{idx}/{len(md_files)}] {rel_path}")
# 텍스트 읽기
text = extract_text_md(fpath)
if not text.strip():
log(f" ⚠ 빈 파일, 스킵")
continue
# 이미지 개수 확인
images = find_images_in_text(text)
stats["images"] += len(images)
# 청킹 및 저장
chunk_count = save_chunk_files(fpath, text, domain_prompt)
stats["docs"] += 1
stats["chunks"] += chunk_count
log(f"{chunk_count}개 청크, {len(images)}개 이미지")
except Exception as e:
stats["errors"] += 1
log(f" ✗ 오류: {e}")
# 전체 통계 저장
summary = {
"processed_at": datetime.now().isoformat(),
"data_root": str(DATA_ROOT),
"output_root": str(OUTPUT_ROOT),
"statistics": stats
}
(LOG_DIR / "chunk_summary_stats.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8"
)
# 결과 출력
log("\n" + "=" * 60)
log("청킹/요약 완료!")
log("=" * 60)
log(f"처리된 문서: {stats['docs']}")
log(f"생성된 청크: {stats['chunks']}")
log(f"포함된 이미지: {stats['images']}")
log(f"오류: {stats['errors']}")
log(f"\n결과 저장 위치:")
log(f" - 원문: {TEXT_DIR}")
log(f" - JSON: {JSON_DIR}")
log(f" - RAG: {RAG_DIR}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""
build_rag.py
기능:
- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파일들을 읽어서
text + summary 를 임베딩(text-embedding-3-small)한다.
- FAISS IndexFlatIP 인덱스를 구축하여
output/rag/faiss.index, meta.json, vectors.npy 를 생성한다.
"""
import os
import sys
import json
from pathlib import Path
import numpy as np
import faiss
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
RAG_DIR = OUTPUT_ROOT / "rag"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [RAG_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조 유지) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
EMBED_MODEL = "text-embedding-3-small"
client = OpenAI(api_key=OPENAI_API_KEY)
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def embed_texts(texts):
if not texts:
return np.zeros((0, 1536), dtype="float32")
embs = []
B = 96
for i in range(0, len(texts), B):
batch = texts[i:i+B]
resp = client.embeddings.create(model=EMBED_MODEL, input=batch)
for d in resp.data:
embs.append(np.array(d.embedding, dtype="float32"))
return np.vstack(embs)
def _build_embed_input(u: dict) -> str:
"""
text + summary 를 합쳐 임베딩 입력을 만든다.
- text, summary 중 없는 것은 생략
- 공백 정리
- 최대 길이 제한
"""
sum_ = (u.get("summary") or "").strip()
txt = (u.get("text") or "").strip()
if txt and sum_:
merged = txt + "\n\n요약: " + sum_[:1000]
else:
merged = txt or sum_
merged = " ".join(merged.split())
if not merged:
return ""
if len(merged) > 4000:
merged = merged[:4000]
return merged
def build_faiss_index():
docs = []
metas = []
rag_files = list(RAG_DIR.glob("*_chunks.json"))
if not rag_files:
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
sys.exit(1)
for f in rag_files:
try:
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
except Exception as e:
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
continue
for u in units:
embed_input = _build_embed_input(u)
if not embed_input:
continue
if len(embed_input) < 40:
continue
docs.append(embed_input)
metas.append({
"source": u.get("source", ""),
"chunk": int(u.get("chunk", 0)),
"folder_context": u.get("folder_context", "")
})
if not docs:
log("임베딩할 텍스트가 없습니다.")
sys.exit(1)
log(f"임베딩 대상 텍스트 수: {len(docs)}")
E = embed_texts(docs)
if E.shape[0] != len(docs):
log(f"[WARN] 임베딩 수 불일치: E={E.shape[0]}, docs={len(docs)}")
faiss.normalize_L2(E)
index = faiss.IndexFlatIP(E.shape[1])
index.add(E)
np.save(str(RAG_DIR / "vectors.npy"), E)
(RAG_DIR / "meta.json").write_text(
json.dumps(metas, ensure_ascii=False, indent=2),
encoding="utf-8"
)
faiss.write_index(index, str(RAG_DIR / "faiss.index"))
log(f"FAISS 인덱스 구축 완료: 벡터 수={len(metas)}")
def main():
log("=== FAISS RAG 인덱스 구축 시작 ===")
build_faiss_index()
log("=== FAISS RAG 인덱스 구축 종료 ===")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""
make_corpus_v2.py
기능:
- output/rag/*_chunks.json 에서 모든 청크의 summary를 모아
- AI가 CEL 목적(교육+자사솔루션 홍보)에 맞게 압축 정리
- 중복은 빈도 표시, 희귀하지만 중요한 건 [핵심] 표시
- 결과를 output/context/corpus.txt 로 저장
전제:
- chunk_and_summary.py 실행 후 *_chunks.json 들이 존재해야 한다.
- domain_prompt.txt가 존재해야 한다.
"""
import os
import sys
import json
from pathlib import Path
from datetime import datetime
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
RAG_DIR = OUTPUT_ROOT / "rag"
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 압축 설정 =====
BATCH_SIZE = 80 # 한 번에 처리할 요약 개수
MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결과 글자수
MAX_FINAL_CHARS = 8000 # 최종 corpus 글자수
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 step1을 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_all_summaries() -> list:
"""모든 청크의 summary + 출처 정보 수집"""
summaries = []
rag_files = sorted(RAG_DIR.glob("*_chunks.json"))
if not rag_files:
log("RAG 파일(*_chunks.json)이 없습니다. 먼저 chunk_and_summary.py를 실행해야 합니다.")
sys.exit(1)
for f in rag_files:
try:
units = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
except Exception as e:
log(f"[WARN] RAG 파일 읽기 실패: {f.name} | {e}")
continue
for u in units:
summ = (u.get("summary") or "").strip()
source = (u.get("source") or "").strip()
keywords = (u.get("keywords") or "")
if summ:
# 출처와 키워드 포함
entry = f"[{source}] {summ}"
if keywords:
entry += f" (키워드: {keywords})"
summaries.append(entry)
return summaries
def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str:
"""배치 단위로 요약들을 AI가 압축"""
batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)])
prompt = f"""
아래는 문서에서 추출한 요약 {len(batch)}개이다. (배치 {batch_num}/{total_batches})
[요약 목록]
{batch_text}
다음 기준으로 이 요약들을 압축 정리하라:
1) 중복/유사 내용: 하나로 통합하되, 여러 문서에서 언급되면 "(N회 언급)" 표시
2) domain_prompt에 명시된 핵심 솔루션/시스템: 반드시 보존하고 [솔루션] 표시
3) domain_prompt의 목적에 중요한 내용 우선 보존:
- 해당 분야의 기초 개념
- 기존 방식의 한계점과 문제점
- 새로운 기술/방식의 장점
4) 단순 나열/절차만 있는 내용: 과감히 축약
5) 희귀하지만 핵심적인 인사이트: [핵심] 표시
출력 형식:
- 주제별로 그룹핑
- 각 항목은 1~2문장으로 간결하게
- 전체 {MAX_CHARS_PER_BATCH}자 이내
- 마크다운 없이 순수 텍스트로
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 문서 요약을 주제별로 압축 정리하는 전문가이다."},
{"role": "user", "content": prompt}
]
)
result = resp.choices[0].message.content.strip()
log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)")
return result
except Exception as e:
log(f"[ERROR] 배치 {batch_num} 압축 실패: {e}")
# 실패 시 원본 일부 반환
return "\n".join(batch[:10])
def merge_compressed_parts(domain_prompt: str, parts: list) -> str:
"""배치별 압축 결과를 최종 통합"""
if len(parts) == 1:
return parts[0]
all_parts = "\n\n---\n\n".join([f"[파트 {i+1}]\n{p}" for i, p in enumerate(parts)])
prompt = f"""
아래는 대량의 문서 요약을 배치별로 압축한 결과이다.
이것을 최종 corpus로 통합하라.
[배치별 압축 결과]
{all_parts}
통합 기준:
1) 파트 간 중복 내용 제거 및 통합
2) domain_prompt에 명시된 목적과 흐름에 맞게 재구성
3) [솔루션], [핵심], (N회 언급) 표시는 유지
4) 전체 {MAX_FINAL_CHARS}자 이내
출력: 주제별로 정리된 최종 corpus (마크다운 없이)
"""
try:
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[
{"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 콘텐츠 기획을 위한 corpus를 설계하는 전문가이다."},
{"role": "user", "content": prompt}
]
)
return resp.choices[0].message.content.strip()
except Exception as e:
log(f"[ERROR] 최종 통합 실패: {e}")
return "\n\n".join(parts)
def main():
log("=" * 60)
log("corpus 생성 시작 (AI 압축 버전)")
log("=" * 60)
# 도메인 프롬프트 로드
domain_prompt = load_domain_prompt()
log(f"도메인 프롬프트 로드 완료 ({len(domain_prompt)}자)")
# 모든 요약 수집
summaries = load_all_summaries()
if not summaries:
log("summary가 없습니다. corpus를 생성할 수 없습니다.")
sys.exit(1)
log(f"원본 요약 수집 완료: {len(summaries)}")
# 원본 저장 (백업)
raw_corpus = "\n".join(summaries)
raw_path = CONTEXT_DIR / "corpus_raw.txt"
raw_path.write_text(raw_corpus, encoding="utf-8")
log(f"원본 corpus 백업: {raw_path} ({len(raw_corpus)}자)")
# 배치별 압축
total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE
log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 총 {total_batches}배치)")
compressed_parts = []
for i in range(0, len(summaries), BATCH_SIZE):
batch = summaries[i:i+BATCH_SIZE]
batch_num = (i // BATCH_SIZE) + 1
compressed = compress_batch(domain_prompt, batch, batch_num, total_batches)
compressed_parts.append(compressed)
# 최종 통합
log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파트)")
final_corpus = merge_compressed_parts(domain_prompt, compressed_parts)
# 저장
out_path = CONTEXT_DIR / "corpus.txt"
out_path.write_text(final_corpus, encoding="utf-8")
# 통계
log("\n" + "=" * 60)
log("corpus 생성 완료!")
log("=" * 60)
log(f"원본 요약: {len(summaries)}개 ({len(raw_corpus)}자)")
log(f"압축 corpus: {len(final_corpus)}")
log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%")
log(f"\n저장 위치:")
log(f" - 원본: {raw_path}")
log(f" - 압축: {out_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,504 @@
# -*- coding: utf-8 -*-
"""
make_outline.py
기능:
- output_context/context/domain_prompt.txt
- output_context/context/corpus.txt
을 기반으로 목차를 생성하고,
1) outline_issue_report.txt 저장
2) outline_issue_report.html 저장 (테스트.html 레이아웃 기반 표 형태)
"""
import os
import sys
import re
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Tuple
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조 유지) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
RE_KEYWORDS = re.compile(r"(#\S+)")
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$")
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_corpus() -> str:
p = CONTEXT_DIR / "corpus.txt"
if not p.exists():
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
# 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$")
RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$")
def generate_outline(domain_prompt: str, corpus: str) -> str:
sys_msg = {
"role": "system",
"content": (
domain_prompt + "\n\n"
"너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
"주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
),
}
user_msg = {
"role": "user",
"content": f"""
아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
[corpus]
{corpus}
요구 사항:
1) 첫 줄에 보고서 제목 1개를 작성하라.
2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
- 대목차: 1. / 2. / 3. ...
- 중목차: 1.1 / 1.2 / ...
- 소목차: 1.1.1 / 1.1.2 / ...
3) **수량 제약 (중요)**:
- 대목차(1.)는 5~8개로 구성하라.
- **중목차(1.1) 하나당 소목차(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사이로 구성하라.** (절대 1개만 만들지 말 것)
- 소목차(1.1.1) 하나당 '핵심주제(꼭지)'는 반드시 2개에서 3개 사이로 구성하라.
[소목차 작성 형식]
1.1.1 소목차 제목
- 핵심주제 1 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
- 핵심주제 2 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
5) [유형] 분류 가이드:
- [비교형]: 기존 vs DX 방식의 비교표(Table)가 필수적인 경우
- [기술형]: RMSE, GSD, 중복도 등 정밀 수치와 사양 설명이 핵심인 경우
- [절차형]: 단계별 워크플로 및 체크리스트가 중심인 경우
- [인사이트형]: 한계점 분석 및 전문가 제언(☞)이 중심인 경우
6) 집필가이드는 50자 내외로, "어떤 데이터를 검색해서 어떤 표를 그려라"와 같이 구체적으로 지시하라.
7) 대목차는 최대 8개 이내로 구성하라.
"""
}
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[sys_msg, user_msg],
)
return (resp.choices[0].message.content or "").strip()
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()]
if not lines: return "", []
title = lines[0].strip() # 첫 줄은 보고서 제목
rows = []
current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
for ln in lines[1:]:
raw = ln.strip()
# 1. 소목차 헤더(1.1.1 제목) 발견 시
m3_head = RE_L3_HEAD.match(raw)
if m3_head:
num, s_title = m3_head.groups()
current_section = {
"depth": 3,
"num": num,
"title": s_title,
"sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
}
rows.append(current_section)
continue
# 2. 세부 꼭지(- 주제 | #키워드 | [유형] | 가이드) 발견 시
m_topic = RE_L3_TOPIC.match(raw)
if m_topic and current_section:
t_title, kws_raw, t_type, guide = m_topic.groups()
# 키워드 추출 (#키워드 형태)
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
# 현재 소목차(current_section)의 리스트에 추가
current_section["sub_topics"].append({
"topic_title": t_title,
"keywords": kws,
"type": t_type,
"guide": guide
})
continue
# 3. 대목차(1.) 처리
m1 = RE_L1.match(raw)
if m1:
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
# 4. 중목차(1.1) 처리
m2 = RE_L2.match(raw)
if m2:
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
return title, rows
def html_escape(s: str) -> str:
s = s or ""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;"))
def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]:
"""
A4 1장에 표가 길어지면 넘치므로, 단순 행 개수로 페이지 분할한다.
"""
out = []
cur = []
for r in rows:
cur.append(r)
if len(cur) >= max_rows_per_page:
out.append(cur)
cur = []
if cur:
out.append(cur)
return out
def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
"""
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
"""
head = """
<table>
<thead>
<tr>
<th>구분</th>
<th>번호</th>
<th>제목</th>
<th>키워드</th>
</tr>
</thead>
<tbody>
"""
body_parts = []
for r in rows:
depth = r["depth"]
num = html_escape(r["num"])
title = html_escape(r["title"])
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
kw = html_escape(kw)
if depth == 1:
body_parts.append(
f"""
<tr>
<td class="group-cell">대목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
elif depth == 2:
body_parts.append(
f"""
<tr>
<td class="group-cell">중목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
else:
body_parts.append(
f"""
<tr>
<td class="group-cell">소목차</td>
<td>{num}</td>
<td>{title}</td>
<td>{kw}</td>
</tr>
"""
)
tail = """
</tbody>
</table>
"""
return head + "\n".join(body_parts) + tail
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str:
"""
테스트.html 레이아웃 구조를 그대로 따라 A4 시트 형태로 HTML 생성
"""
css = r"""
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary-blue: #3057B9;
--gray-light: #F2F2F2;
--gray-medium: #E6E6E6;
--gray-dark: #666666;
--border-light: #DDDDDD;
--text-black: #000000;
}
* {
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.35;
display: flex;
justify-content: center;
padding: 10px 0;
}
.sheet {
background-color: white;
width: 210mm;
height: 297mm;
padding: 20mm 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: 12px;
}
@media print {
body { background: none; padding: 0; }
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
font-size: 8.5pt;
color: var(--gray-dark);
}
.header-title {
font-size: 24pt;
font-weight: 900;
margin-bottom: 8px;
letter-spacing: -1.5px;
color: #111;
}
.title-divider {
height: 4px;
background-color: var(--primary-blue);
width: 100%;
margin-bottom: 20px;
}
.lead-box {
background-color: var(--gray-light);
padding: 18px 20px;
margin-bottom: 5px;
border-radius: 2px;
text-align: center;
}
.lead-box div {
font-size: 13pt;
font-weight: 700;
color: var(--primary-blue);
letter-spacing: -0.5px;
}
.lead-notes {
font-size: 8.5pt;
color: #777;
margin-bottom: 20px;
padding-left: 5px;
text-align: right;
}
.body-content { flex: 1; }
.section { margin-bottom: 22px; }
.section-title {
font-size: 13pt;
font-weight: 700;
display: flex;
align-items: center;
margin-bottom: 10px;
color: #111;
}
.section-title::before {
content: "";
display: inline-block;
width: 10px;
height: 10px;
background-color: #999;
margin-right: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 9.5pt;
border-top: 1.5px solid #333;
}
th {
background-color: var(--gray-medium);
font-weight: 700;
padding: 10px;
border: 1px solid var(--border-light);
}
td {
padding: 10px;
border: 1px solid var(--border-light);
vertical-align: middle;
}
.group-cell {
background-color: #F9F9F9;
font-weight: 700;
width: 16%;
text-align: center;
color: var(--primary-blue);
white-space: nowrap;
}
.page-footer {
margin-top: 15px;
padding-top: 10px;
display: flex;
justify-content: space-between;
font-size: 8.5pt;
color: var(--gray-dark);
border-top: 1px solid #EEE;
}
.footer-page { flex: 1; text-align: center; }
"""
pages = chunk_rows(rows, max_rows_per_page=26)
html_pages = []
total_pages = len(pages) if pages else 1
for i, page_rows in enumerate(pages, start=1):
table_html = build_outline_table_html(page_rows)
html_pages.append(f"""
<div class="sheet">
<header class="page-header">
<div class="header-left">
보고서: 목차 자동 생성 결과
</div>
<div class="header-right">
작성일자: {datetime.now().strftime("%Y. %m. %d.")}
</div>
</header>
<div class="title-block">
<h1 class="header-title">{html_escape(report_title)}</h1>
<div class="title-divider"></div>
</div>
<div class="body-content">
<div class="lead-box">
<div>확정 목차 표 형태 정리본</div>
</div>
<div class="lead-notes">목차는 outline_issue_report.txt를 기반으로 표로 재구성됨</div>
<div class="section">
<div class="section-title">목차</div>
{table_html}
</div>
</div>
<footer class="page-footer">
<div class="footer-slogan">Word Style v2 Outline</div>
<div class="footer-page">- {i} / {total_pages} -</div>
<div class="footer-info">outline_issue_report.html</div>
</footer>
</div>
""")
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{html_escape(report_title)} - Outline</title>
<style>{css}</style>
</head>
<body>
{''.join(html_pages)}
</body>
</html>
"""
def main():
log("=== 목차 생성 시작 ===")
domain_prompt = load_domain_prompt()
corpus = load_corpus()
outline = generate_outline(domain_prompt, corpus)
# TXT 저장 유지
out_txt = CONTEXT_DIR / "outline_issue_report.txt"
out_txt.write_text(outline, encoding="utf-8")
log(f"목차 TXT 저장 완료: {out_txt}")
# HTML 추가 저장
title, rows = parse_outline(outline)
out_html = CONTEXT_DIR / "outline_issue_report.html"
out_html.write_text(build_outline_html(title, rows), encoding="utf-8")
log(f"목차 HTML 저장 완료: {out_html}")
log("=== 목차 생성 종료 ===")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View 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만 출력 (설명 없이)

View 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 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑

View 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로 해당 페이지 요약 포함

View 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
}
}

View 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

View 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;
}
}

View 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);

View 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>

File diff suppressed because it is too large Load Diff