# -*- 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 = '
\n' if header_info.get("type") == "table" and header_info.get("table"): tbl = header_info["table"] rows = tbl.get("rows", []) col_pcts = tbl.get("colWidths_pct", []) # ★ 추가: colWidths_pct 없으면 셀 width_hu에서 계산 if not col_pcts and rows: widths = [c.get("width_hu", 0) for c in rows[0]] total = sum(widths) if total > 0: col_pcts = [round(w / total * 100) for w in widths] html += '\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): lines = cell.get("lines", []) cell_text = cell.get("text", "").strip() # ★ 추가 ph_name = f"HEADER_R{r_idx+1}_C{c_idx+1}" # ★ 수정: 텍스트 없는 셀은 비움 if not cell_text and not lines: content = "" elif len(lines) > 1: # 멀티라인 셀 → 각 라인별 placeholder line_phs = [] for l_idx in range(len(lines)): line_phs.append(f"{{{{{ph_name}_LINE_{l_idx+1}}}}}") content = "
".join(line_phs) else: content = f"{{{{{ph_name}}}}}" # colSpan/rowSpan attrs = "" bf_ref = cell.get("borderFillIDRef") if bf_ref: attrs += f' class="bf-{bf_ref}"' if cell.get("colSpan", 1) > 1: attrs += f' colspan="{cell["colSpan"]}"' if cell.get("rowSpan", 1) > 1: attrs += f' rowspan="{cell["rowSpan"]}"' html += f' {content}\n' html += '\n' html += '
\n' else: # 텍스트형 헤더 texts = header_info.get("texts", []) for i in range(max(len(texts), 1)): html += f'
{{{{{f"HEADER_TEXT_{i+1}"}}}}}
\n' 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.3: content_order table_idx → tables 리스트 매핑 # content_order.table_idx = section body에서 만난 표 순번 (0-based) # tables 리스트 = HWPX 전체 표 (header/footer 포함) # → header/footer 제외한 "본문 가시 표" 리스트로 매핑해야 정확함 header_footer_indices = set() if semantic_map: 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: header_footer_indices.add(int(idx_key)) except (ValueError, TypeError): pass body_visible_tables = [ t for t in tables if t["index"] not in header_footer_indices ] 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만 ── # table_idx = content_order.py가 부여한 등장순서 0-based # ★ v5.3: body_visible_tables로 매핑 (header/footer 표 제외) if itype == 'table': t_idx = item.get('table_idx', 0) # body_visible_tables에서 해당 인덱스의 표 가져오기 if t_idx < len(body_visible_tables): tbl_data = body_visible_tables[t_idx] if tbl_data["index"] == title_table_idx: continue # title_table 건너뛰기 if tbl_data["index"] not in body_table_indices: continue # body 데이터 표가 아니면 건너뛰기 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 {} if t_idx < len(body_visible_tables): tbl_data = body_visible_tables[t_idx] 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", []) else: actual_col_cnt = col_cnt header_row_data = None col_pcts = [] 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