# -*- coding: utf-8 -*-
"""
템플릿 관리자 (Template Manager) v5.2
- 템플릿의 독립적 CRUD (생성/조회/삭제/교체)
- 문서 유형(DocType)과 분리된 저장 구조
- HWPX에서 템플릿 추출 → templates/user/templates/{tpl_id}/ 에 저장
★ v5.2 변경:
- _build_body_html() 재설계: content_order 기반 본문 생성
→ 문단·이미지·표를 원본 순서 그대로 HTML 조립
→ content_order 없으면 기존 legacy 방식 자동 fallback
- _build_title_block_html() 분리 (재사용성)
★ 저장 구조:
templates/user/
├── doc_types/{type_id}/
│ ├── config.json ← 유형 정보 (맥락/구조/가이드)
│ └── template_id: "tpl_xxx" ← 어떤 템플릿 참조하는지
│
└── templates/{tpl_id}/
├── template.html ← HTML 골격 + placeholder
├── style.json ← 테두리/폰트/색상/여백/borderFill
└── meta.json ← 이름, 출처, 생성일
★ 사용 흐름:
1) "템플릿 추가" → extract_and_save(hwpx_path, name) → tpl_id
2) "문서 유형 추가" → doc_type_analyzer가 내부적으로 extract_and_save 호출
3) "템플릿 교체" → change_template(type_id, new_tpl_id)
4) "문서 생성" → load_template(tpl_id) → template.html + style.json
"""
import json
import time
import shutil
from pathlib import Path
from typing import Optional
class TemplateManager:
"""템플릿 독립 관리"""
# 기본 경로
TEMPLATES_USER = Path('templates/user/templates')
TEMPLATES_DEFAULT = Path('templates/default/templates')
DOC_TYPES_USER = Path('templates/user/doc_types')
def __init__(self, base_path: str = None):
if base_path:
self.TEMPLATES_USER = Path(base_path) / 'user' / 'templates'
self.TEMPLATES_DEFAULT = Path(base_path) / 'default' / 'templates'
self.DOC_TYPES_USER = Path(base_path) / 'user' / 'doc_types'
# ================================================================
# 핵심 API
# ================================================================
def extract_and_save(self, parsed: dict, name: str,
source_file: str = "", description: str = "") -> dict:
"""
HWPX 파싱 결과에서 템플릿 추출 후 저장
Args:
parsed: HWPX 파서 결과 (raw_xml, tables, section_xml, header_xml, footer_xml)
name: 템플릿 이름 (예: "GPD 발표기획서 양식")
source_file: 원본 파일명
description: 설명
Returns:
{"success": True, "template_id": "tpl_xxx", "path": "...", "template_info": {...}}
"""
from .doc_template_analyzer import DocTemplateAnalyzer
try:
analyzer = DocTemplateAnalyzer()
# ① 구조 추출 (template_info)
template_info = analyzer.analyze(parsed)
# ①-b semantic_map 생성 (표 역할 분류, 섹션 감지)
from . import semantic_mapper
semantic_map = semantic_mapper.generate(template_info, parsed)
# ② HTML 생성 (semantic_map으로 표 필터링)
template_html = self._generate_basic_html(template_info, parsed, semantic_map)
# 저장
tpl_id = f"tpl_{int(time.time())}"
tpl_path = self.TEMPLATES_USER / tpl_id
tpl_path.mkdir(parents=True, exist_ok=True)
# template.html
(tpl_path / 'template.html').write_text(template_html, encoding='utf-8')
# style.json (template_info + 추출된 스타일)
style_data = {
"version": "v4",
"source": "doc_template_analyzer",
"template_info": template_info,
"css": "", # 추후 커스텀 CSS 오버라이드용
"fonts": {},
"colors": self._extract_colors(template_info),
"border_fills": template_info.get("border_fills", {}),
"tables": [],
"style_summary": {}
}
(tpl_path / 'style.json').write_text(
json.dumps(style_data, ensure_ascii=False, indent=2),
encoding='utf-8'
)
# meta.json
meta = {
"id": tpl_id,
"name": name,
"original_file": source_file,
"file_type": Path(source_file).suffix if source_file else ".hwpx",
"description": description,
"features": self._summarize_features(template_info, semantic_map),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"source": "doc_template_analyzer"
}
(tpl_path / 'meta.json').write_text(
json.dumps(meta, ensure_ascii=False, indent=2),
encoding='utf-8'
)
# semantic_map.json
(tpl_path / 'semantic_map.json').write_text(
json.dumps(semantic_map, ensure_ascii=False, indent=2),
encoding='utf-8'
)
return {
"success": True,
"template_id": tpl_id,
"path": str(tpl_path),
"template_info": template_info,
"semantic_map": semantic_map,
"meta": meta
}
except Exception as e:
import traceback
return {"error": str(e), "trace": traceback.format_exc()}
def load_template(self, tpl_id: str) -> dict:
"""
템플릿 로드 (template.html + style.json)
Returns:
{"html": "...", "style": {...}, "meta": {...}}
"""
# 사용자 템플릿 → 기본 템플릿 순서로 탐색
for base in [self.TEMPLATES_USER, self.TEMPLATES_DEFAULT]:
tpl_path = base / tpl_id
if tpl_path.exists():
result = {}
html_file = tpl_path / 'template.html'
if html_file.exists():
result["html"] = html_file.read_text(encoding='utf-8')
style_file = tpl_path / 'style.json'
if style_file.exists():
result["style"] = json.loads(style_file.read_text(encoding='utf-8'))
meta_file = tpl_path / 'meta.json'
if meta_file.exists():
result["meta"] = json.loads(meta_file.read_text(encoding='utf-8'))
result["template_id"] = tpl_id
result["path"] = str(tpl_path)
return result
return {"error": f"템플릿을 찾을 수 없습니다: {tpl_id}"}
def list_templates(self) -> list:
"""모든 템플릿 목록 조회"""
templates = []
for base, is_default in [(self.TEMPLATES_DEFAULT, True), (self.TEMPLATES_USER, False)]:
if not base.exists():
continue
for folder in sorted(base.iterdir()):
if not folder.is_dir():
continue
meta_file = folder / 'meta.json'
if meta_file.exists():
try:
meta = json.loads(meta_file.read_text(encoding='utf-8'))
meta["is_default"] = is_default
templates.append(meta)
except:
templates.append({
"id": folder.name,
"name": folder.name,
"is_default": is_default
})
return templates
def delete_template(self, tpl_id: str) -> dict:
"""템플릿 삭제 (사용자 템플릿만)"""
tpl_path = self.TEMPLATES_USER / tpl_id
if not tpl_path.exists():
return {"error": f"템플릿을 찾을 수 없습니다: {tpl_id}"}
# 이 템플릿을 참조하는 DocType이 있는지 확인
referencing = self._find_referencing_doc_types(tpl_id)
if referencing:
names = ', '.join(r['name'] for r in referencing[:3])
return {
"error": f"이 템플릿을 사용 중인 문서 유형이 있습니다: {names}",
"referencing_types": referencing
}
shutil.rmtree(tpl_path)
return {"success": True, "deleted": tpl_id}
def change_template(self, type_id: str, new_tpl_id: str) -> dict:
"""
문서 유형의 템플릿 교체
Args:
type_id: 문서 유형 ID
new_tpl_id: 새 템플릿 ID
"""
config_path = self.DOC_TYPES_USER / type_id / 'config.json'
if not config_path.exists():
return {"error": f"문서 유형을 찾을 수 없습니다: {type_id}"}
# 새 템플릿 존재 확인
new_tpl = self.load_template(new_tpl_id)
if "error" in new_tpl:
return new_tpl
# config 업데이트
config = json.loads(config_path.read_text(encoding='utf-8'))
old_tpl_id = config.get("template_id", "")
config["template_id"] = new_tpl_id
config["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding='utf-8'
)
return {
"success": True,
"type_id": type_id,
"old_template_id": old_tpl_id,
"new_template_id": new_tpl_id
}
def get_template_for_doctype(self, type_id: str) -> dict:
"""문서 유형에 연결된 템플릿 로드"""
config_path = self.DOC_TYPES_USER / type_id / 'config.json'
if not config_path.exists():
# default에서도 탐색
config_path = self.TEMPLATES_DEFAULT.parent / 'doc_types' / type_id / 'config.json'
if not config_path.exists():
return {"error": f"문서 유형을 찾을 수 없습니다: {type_id}"}
config = json.loads(config_path.read_text(encoding='utf-8'))
tpl_id = config.get("template_id")
if not tpl_id:
# ★ 하위 호환: template_id가 없으면 같은 폴더의 template.html 사용
legacy_path = config_path.parent / 'template.html'
if legacy_path.exists():
return {
"html": legacy_path.read_text(encoding='utf-8'),
"style": {},
"meta": {"id": type_id, "name": "레거시 템플릿"},
"template_id": None,
"legacy": True
}
return {"error": "연결된 템플릿이 없습니다"}
return self.load_template(tpl_id)
# ================================================================
# 내부 유틸
# ================================================================
def _find_referencing_doc_types(self, tpl_id: str) -> list:
"""특정 템플릿을 참조하는 DocType 목록"""
result = []
if not self.DOC_TYPES_USER.exists():
return result
for folder in self.DOC_TYPES_USER.iterdir():
config_file = folder / 'config.json'
if config_file.exists():
try:
config = json.loads(config_file.read_text(encoding='utf-8'))
if config.get("template_id") == tpl_id:
result.append({
"id": config.get("id", folder.name),
"name": config.get("name", folder.name)
})
except:
pass
return result
def _generate_basic_html(self, template_info: dict, parsed: dict,
semantic_map: dict = None) -> str:
"""tools 추출 결과 + style_generator → template.html 생성"""
# ① CSS 생성 (style_generator)
from . import style_generator
css = style_generator.generate_css(template_info, semantic_map)
# ② 헤더 HTML
header_html = self._build_header_html(template_info.get("header"))
# ③ 푸터 HTML
footer_html = self._build_footer_html(template_info.get("footer"))
# ④ 본문 HTML (섹션 + 표)
body_html = self._build_body_html(template_info, parsed, semantic_map)
# ⑤ 조립
html = f"""
Template
{header_html}
{body_html}
{footer_html}
"""
return html
# ── 보조 메서드들 ──
def _build_header_html(self, header_info: dict | None) -> str:
"""header tools 추출값 → HTML + placeholder"""
if not header_info or not header_info.get("exists"):
return ""
html = ''
return html
def _build_footer_html(self, footer_info: dict | None) -> str:
"""footer tools 추출값 → HTML + placeholder"""
if not footer_info or not footer_info.get("exists"):
return ""
html = ''
return html
def _build_body_html(self, template_info: dict, parsed: dict,
semantic_map: dict = None) -> str:
"""본문 영역 HTML 생성.
★ v5.2: content_order가 있으면 원본 순서 그대로 조립.
content_order 없으면 기존 섹션+표 방식 (하위 호환).
"""
content_order = template_info.get("content_order")
if content_order and self._has_paragraph_content(content_order):
return self._build_body_from_content_order(
template_info, content_order, semantic_map
)
else:
return self._build_body_legacy(
template_info, parsed, semantic_map
)
# ── content_order 기반 본문 생성 (v5.2+) ──
def _has_paragraph_content(self, content_order: list) -> bool:
"""content_order에 문단이 있는지 (표만 있으면 legacy 사용)"""
return any(
c['type'] == 'paragraph' for c in content_order
)
def _build_body_from_content_order(self, template_info: dict,
content_order: list,
semantic_map: dict = None) -> str:
"""content_order 기반 — 원본 문서 순서 그대로 HTML 조립.
콘텐츠 유형별 처리:
paragraph → {{CONTENT_n}}
table → data-table placeholder (title_table 제외)
image → {{IMAGE_n}}
empty → 생략 (연속 빈 문단 의미 없음)
"""
import re
tables = template_info.get("tables", [])
# semantic_map에서 title/body 인덱스
title_table_idx = None
body_table_indices = []
if semantic_map:
title_table_idx = semantic_map.get("title_table")
body_table_indices = semantic_map.get("body_tables", [])
else:
body_table_indices = [t["index"] for t in tables]
# ★ v5.4: content_order 순서대로 표 매칭
# content_order.table_idx는 section 본문에서 만난 순서이지만,
# tables 리스트 순서와 다를 수 있으므로 순차 커서 방식으로 매칭
# header/footer/title 제외한 순수 body_table만 추출
exclude_indices = set()
if semantic_map:
# header_table, footer_table 제외
for idx_key, role_info in semantic_map.get("table_roles", {}).items():
role = role_info.get("role", "")
if role in ("header_table", "footer_table"):
try:
exclude_indices.add(int(idx_key))
except (ValueError, TypeError):
pass
# title_table도 제외 (별도 처리됨)
if title_table_idx is not None:
exclude_indices.add(title_table_idx)
body_visible_tables = [
t for t in tables
if t["index"] not in exclude_indices
]
# 순차 매칭을 위한 커서
body_tbl_cursor = 0
body_parts = []
# ── 제목 블록 (title_table이 있으면) ──
if title_table_idx is not None:
title_tbl = next(
(t for t in tables if t["index"] == title_table_idx), None
)
if title_tbl:
body_parts.append(
self._build_title_block_html(title_tbl)
)
# ── content_order 순회 ──
para_num = 0 # 문단 placeholder 번호
tbl_num = 0 # 데이터 표 번호 (1-based)
img_num = 0 # 이미지 placeholder 번호
in_section = False
section_num = 0
# 섹션 제목 패턴
sec_patterns = [
re.compile(r'^\d+\.\s+\S'),
re.compile(r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.\s*\S'),
re.compile(r'^제\s*\d+\s*[장절항]\s*\S'),
]
def _is_section_title(text: str) -> bool:
return any(p.match(text) for p in sec_patterns)
for item in content_order:
itype = item['type']
# ── 빈 문단: 생략 ──
if itype == 'empty':
continue
# ── 표: title_table은 이미 처리, body_table만 ──
# ★ v5.4: 순차 커서 방식으로 매칭 (table_idx 의존 제거)
if itype == 'table':
# body_visible_tables에서 다음 표 가져오기
if body_tbl_cursor >= len(body_visible_tables):
continue # 더 이상 표가 없음
tbl_data = body_visible_tables[body_tbl_cursor]
body_tbl_cursor += 1
# body 데이터 표가 아니면 건너뛰기
if tbl_data["index"] not in body_table_indices:
continue
tbl_num += 1
col_cnt = item.get('colCnt', '3')
try:
col_cnt = int(col_cnt)
except (ValueError, TypeError):
col_cnt = 3
# semantic_map에서 col_headers 가져오기
_roles = semantic_map.get("table_roles", {}) if semantic_map else {}
tbl_role = _roles.get(tbl_data["index"],
_roles.get(str(tbl_data["index"]), {}))
col_headers = tbl_role.get("col_headers", [])
actual_col_cnt = len(col_headers) if col_headers else col_cnt
rows = tbl_data.get("rows", [])
header_row_data = rows[0] if rows else None
col_pcts = tbl_data.get("colWidths_pct", [])
body_parts.append(
self._build_table_placeholder(
tbl_num, actual_col_cnt, col_pcts,
header_row=header_row_data
)
)
continue
# ── 이미지 ──
if itype == 'image':
img_num += 1
ppr = item.get('paraPrIDRef', '0')
caption = item.get('text', '')
ref = item.get('binaryItemIDRef', '')
img_html = f'\n'
img_html += f' {{{{IMAGE_{img_num}}}}}\n'
if caption:
img_html += f'
{{{{IMAGE_{img_num}_CAPTION}}}}
\n'
img_html += '
'
body_parts.append(img_html)
continue
# ── 문단 ──
if itype == 'paragraph':
text = item.get('text', '')
ppr = item.get('paraPrIDRef', '0')
cpr = item.get('charPrIDRef', '0')
# 섹션 제목 감지
if _is_section_title(text):
# 이전 섹션 닫기
if in_section:
body_parts.append('\n')
section_num += 1
in_section = True
body_parts.append(
f'\n'
f'
'
f'{{{{SECTION_{section_num}_TITLE}}}}
'
)
continue
# 일반 문단
para_num += 1
# runs가 여러 개면 다중 span
runs = item.get('runs', [])
if len(runs) > 1:
spans = []
for r_idx, run in enumerate(runs):
r_cpr = run.get('charPrIDRef', cpr)
spans.append(
f'
'
f'{{{{PARA_{para_num}_RUN_{r_idx+1}}}}}'
)
inner = ''.join(spans)
else:
inner = (
f'
'
f'{{{{PARA_{para_num}}}}}'
)
body_parts.append(
f'
{inner}
'
)
# 마지막 섹션 닫기
if in_section:
body_parts.append('
\n')
return "\n\n".join(body_parts)
def _build_title_block_html(self, title_tbl: dict) -> str:
"""제목표 → title-block HTML (기존 로직 분리)"""
rows = title_tbl.get("rows", [])
col_pcts = title_tbl.get("colWidths_pct", [])
html = '\n
\n'
if col_pcts:
html += '\n'
for pct in col_pcts:
html += f' \n'
html += '\n'
for r_idx, row in enumerate(rows):
html += '\n'
for c_idx, cell in enumerate(row):
attrs = ""
bf_ref = cell.get("borderFillIDRef")
if bf_ref:
attrs += f' class="bf-{bf_ref}"'
cs = cell.get("colSpan", 1)
if cs > 1:
attrs += f' colspan="{cs}"'
rs = cell.get("rowSpan", 1)
if rs > 1:
attrs += f' rowspan="{rs}"'
cell_text = cell.get("text", "").strip()
if cell_text:
ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}"
html += f' | {{{{{ph_name}}}}} | \n'
else:
html += f' | \n'
html += '
\n'
html += '
\n
\n'
return html
# ── 기존 섹션+표 방식 (하위 호환) ──
def _build_body_legacy(self, template_info: dict, parsed: dict,
semantic_map: dict = None) -> str:
"""content_order 없을 때 — 기존 v5.1 방식 유지"""
body_parts = []
tables = template_info.get("tables", [])
# ── semantic_map이 있으면 활용 ──
if semantic_map:
body_table_indices = semantic_map.get("body_tables", [])
title_idx = semantic_map.get("title_table")
else:
# semantic_map 없으면 전체 표 사용 (하위 호환)
body_table_indices = [t["index"] for t in tables]
title_idx = None
# ── 제목 블록 ──
if title_idx is not None:
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
if title_tbl:
body_parts.append(self._build_title_block_html(title_tbl))
# ── 본문 데이터 표만 필터링 ──
body_tables = [t for t in tables if t["index"] in body_table_indices]
# ── 섹션 감지 ──
section_titles = self._detect_section_titles(parsed)
if not section_titles and not body_tables:
# 구조 정보 부족 → 기본 1섹션
body_parts.append(
'\n'
'
{{SECTION_1_TITLE}}
\n'
'
{{SECTION_1_CONTENT}}
\n'
'
'
)
else:
sec_count = max(len(section_titles), 1)
tbl_idx = 0
for s in range(sec_count):
s_num = s + 1
body_parts.append(
f'\n'
f'
{{{{SECTION_{s_num}_TITLE}}}}
\n'
f'
{{{{SECTION_{s_num}_CONTENT}}}}
\n'
)
# 이 섹션에 표 배분
if tbl_idx < len(body_tables):
t = body_tables[tbl_idx]
col_cnt = t.get("colCnt", 3)
# semantic_map에서 실제 col_headers 가져오기
_roles = semantic_map.get("table_roles", {}) if semantic_map else {}
tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {}))
col_headers = tbl_role.get("col_headers", [])
actual_col_cnt = len(col_headers) if col_headers else col_cnt
# 헤더행 셀 데이터 (bf_id 포함)
rows = t.get("rows", [])
header_row_data = rows[0] if rows else None
body_parts.append(
self._build_table_placeholder(
tbl_idx + 1, actual_col_cnt,
t.get("colWidths_pct", []),
header_row=header_row_data # ★ 헤더행 전달
)
)
tbl_idx += 1
body_parts.append('
\n')
# 남은 표
while tbl_idx < len(body_tables):
t = body_tables[tbl_idx]
col_cnt = t.get("colCnt", 3)
_roles = semantic_map.get("table_roles", {}) if semantic_map else {}
tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {}))
col_headers = tbl_role.get("col_headers", [])
actual_col_cnt = len(col_headers) if col_headers else col_cnt
rows = t.get("rows", [])
header_row_data = rows[0] if rows else None
body_parts.append(
self._build_table_placeholder(
tbl_idx + 1, actual_col_cnt,
t.get("colWidths_pct", []),
header_row=header_row_data
)
)
tbl_idx += 1
return "\n".join(body_parts)
def _build_table_placeholder(self, tbl_num: int, col_cnt: int,
col_pcts: list = None,
header_row: list = None) -> str:
"""표 1개의 placeholder HTML 생성
Args:
tbl_num: 표 번호 (1-based)
col_cnt: 열 수
col_pcts: 열 너비 % 리스트
header_row: 헤더행 셀 리스트 [{bf_id, colSpan, ...}, ...]
"""
# colgroup
colgroup = ""
num_cols = len(col_pcts) if col_pcts else col_cnt
if num_cols > 0:
colgroup = "\n"
if col_pcts and len(col_pcts) == num_cols:
for pct in col_pcts:
colgroup += f' \n'
else:
for _ in range(num_cols):
colgroup += " \n"
colgroup += "\n"
# 헤더 행 — ★ bf_id가 있으면 class 적용
header_cells = []
if header_row:
for c, cell in enumerate(header_row):
bf_id = cell.get("borderFillIDRef")
cs = cell.get("colSpan", 1)
attrs = ""
if bf_id:
attrs += f' class="bf-{bf_id}"'
if cs > 1:
attrs += f' colspan="{cs}"'
header_cells.append(
f' {{{{TABLE_{tbl_num}_H_C{c+1}}}}} | '
)
else:
# fallback: bf 없는 경우
for c in range(col_cnt):
header_cells.append(
f' {{{{TABLE_{tbl_num}_H_C{c+1}}}}} | '
)
header_row_html = "\n".join(header_cells)
return (
f'\n'
f'{colgroup}'
f'\n'
f' \n{header_row_html}\n
\n'
f'\n'
f'\n'
f' {{{{TABLE_{tbl_num}_BODY}}}}\n'
f'\n'
f'
'
)
def _detect_section_titles(self, parsed: dict) -> list:
"""parsed 텍스트에서 섹션 제목 패턴 탐색"""
import re
titles = []
# parsed에서 텍스트 추출
paragraphs = parsed.get("paragraphs", [])
if not paragraphs:
# raw_xml에서 태그 텍스트 추출 시도
section_xml = ""
raw_xml = parsed.get("raw_xml", {})
for key, val in raw_xml.items():
if "section" in key.lower():
section_xml = val if isinstance(val, str) else ""
break
if not section_xml:
section_xml = parsed.get("section_xml", "")
if section_xml:
t_matches = re.findall(r'([^<]+)', section_xml)
paragraphs = [t.strip() for t in t_matches if t.strip()]
# 섹션 제목 패턴
patterns = [
r'^(\d+)\.\s+\S', # "1. 제목"
r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.\s*\S', # "Ⅰ. 제목"
r'^제\s*\d+\s*[장절항]\s*\S', # "제1장 제목"
]
for text in paragraphs:
if isinstance(text, dict):
text = text.get("text", "")
text = str(text).strip()
if not text:
continue
for pat in patterns:
if re.match(pat, text):
titles.append(text)
break
return titles
def _extract_colors(self, template_info: dict) -> dict:
"""template_info에서 색상 정보 추출"""
colors = {"background": [], "border": [], "text": []}
bf = template_info.get("border_fills", {})
for fill_id, fill_data in bf.items():
# ★ background 키 사용 (bg → background)
bg = fill_data.get("background", fill_data.get("bg", ""))
if bg and bg.lower() not in ("", "none", "transparent") \
and bg not in colors["background"]:
colors["background"].append(bg)
# ★ css dict에서 border 색상 추출
css_dict = fill_data.get("css", {})
for prop, val in css_dict.items():
if "border" in prop and val and val != "none":
# "0.12mm solid #999999" → "#999999"
parts = val.split()
if len(parts) >= 3:
c = parts[-1]
if c.startswith("#") and c not in colors["border"]:
colors["border"].append(c)
# fallback: 직접 side 키 (top/bottom/left/right)
for side_key in ("top", "bottom", "left", "right"):
side = fill_data.get(side_key, {})
if isinstance(side, dict):
c = side.get("color", "")
if c and c not in colors["border"]:
colors["border"].append(c)
return colors
def _summarize_features(self, template_info: dict,
semantic_map: dict = None) -> list:
"""template_info에서 특징 요약"""
features = []
header = template_info.get("header", {})
footer = template_info.get("footer", {})
tables = template_info.get("tables", [])
# 폰트 (fonts 구조: {"HANGUL": [{"face": "맑은 고딕"}], ...})
fonts = template_info.get("fonts", {})
hangul = fonts.get("HANGUL", [])
if hangul and isinstance(hangul, list) and len(hangul) > 0:
features.append(f"폰트: {hangul[0].get('face', '?')}")
# 머릿말 (header.table.colCnt)
if header.get("exists"):
col_cnt = header.get("table", {}).get("colCnt", "?")
features.append(f"머릿말: {col_cnt}열")
# 꼬릿말 (footer.table.colCnt)
if footer.get("exists"):
col_cnt = footer.get("table", {}).get("colCnt", "?")
features.append(f"꼬릿말: {col_cnt}열")
# 표 — semantic_map이 있으면 데이터 표만
if semantic_map and semantic_map.get("body_tables"):
for idx in semantic_map["body_tables"]:
t = next((tb for tb in tables if tb["index"] == idx), None)
if t:
features.append(
f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}"
)
elif tables:
t = tables[0]
features.append(f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}")
return features