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