# -*- coding: utf-8 -*- """ Semantic Mapper v1.0 HWPX tools 추출 결과(template_info)에서 각 요소의 "의미"를 판별. 역할: - 표 분류: 헤더표 / 푸터표 / 제목블록 / 데이터표 - 섹션 감지: 본문 텍스트에서 섹션 패턴 탐색 - 스타일 매핑 준비: charPr→HTML태그, borderFill→CSS클래스 (Phase 2에서 구현) 입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결과) 출력: semantic_map dict → semantic_map.json으로 저장 ★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토리 ★ 호출: template_manager.extract_and_save() 내에서 analyze() 직후 """ import re # ================================================================ # 메인 엔트리포인트 # ================================================================ def generate(template_info: dict, parsed: dict) -> dict: """semantic_map 생성 — 모든 판별 로직 조합. Args: template_info: DocTemplateAnalyzer.analyze() 결과 parsed: HWPX 파서 결과 (raw_xml, section_xml, paragraphs 등) Returns: { "version": "1.0", "table_roles": { "0": {"role": "footer_table", ...}, ... }, "body_tables": [3], # 본문에 들어갈 표 index 목록 "title_table": 2, # 제목 블록 index (없으면 None) "sections": [...], # 감지된 섹션 목록 "style_mappings": {...}, # Phase 2용 스타일 매핑 (현재 빈 구조) } """ tables = template_info.get("tables", []) header = template_info.get("header") footer = template_info.get("footer") # ① 표 역할 분류 table_roles = _classify_tables(tables, header, footer) # ② 본문 전용 표 / 제목 블록 추출 body_tables = sorted( idx for idx, info in table_roles.items() if info["role"] == "data_table" ) title_table = next( (idx for idx, info in table_roles.items() if info["role"] == "title_block"), None ) # ③ 섹션 감지 sections = _detect_sections(parsed) # ④ 스타일 매핑 (Phase 2에서 구현, 현재는 빈 구조) style_mappings = _prepare_style_mappings(template_info) return { "version": "1.0", "table_roles": table_roles, "body_tables": body_tables, "title_table": title_table, "sections": sections, "style_mappings": style_mappings, } # ================================================================ # 표 분류 # ================================================================ def _classify_tables(tables: list, header: dict | None, footer: dict | None) -> dict: """각 표의 역할 판별: header_table / footer_table / title_block / data_table 판별 순서: Pass 1 — header/footer 텍스트 매칭 Pass 2 — 제목 블록 패턴 (1행, 좁은+넓은 열 구조) Pass 3 — 나머지 → 데이터 표 """ header_texts = _collect_hf_texts(header) footer_texts = _collect_hf_texts(footer) roles = {} classified = set() # ── Pass 1: header/footer 매칭 ── for tbl in tables: idx = tbl["index"] tbl_texts = _collect_table_texts(tbl) if not tbl_texts: continue # header 매칭 if header_texts: overlap = len(tbl_texts & header_texts) if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: roles[idx] = { "role": "header_table", "match_source": "header", "matched_texts": list(tbl_texts & header_texts), } classified.add(idx) continue # footer 매칭 if footer_texts: overlap = len(tbl_texts & footer_texts) if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: roles[idx] = { "role": "footer_table", "match_source": "footer", "matched_texts": list(tbl_texts & footer_texts), } classified.add(idx) continue # ── Pass 2: 제목 블록 탐지 ── for tbl in tables: idx = tbl["index"] if idx in classified: continue if _is_title_block(tbl): title_text = _extract_longest_text(tbl) roles[idx] = { "role": "title_block", "title_text": title_text, } classified.add(idx) continue # ── Pass 3: 나머지 → 데이터 표 ── for tbl in tables: idx = tbl["index"] if idx in classified: continue col_headers = _detect_table_headers(tbl) roles[idx] = { "role": "data_table", "header_row": 0 if col_headers else None, "col_headers": col_headers, "row_count": tbl.get("rowCnt", 0), "col_count": tbl.get("colCnt", 0), } return roles # ── 표 분류 보조 함수 ── def _collect_hf_texts(hf_info: dict | None) -> set: """header/footer의 table 셀 텍스트 수집""" if not hf_info or not hf_info.get("table"): return set() texts = set() for row in hf_info["table"].get("rows", []): for cell in row: t = cell.get("text", "").strip() if t: texts.add(t) return texts def _collect_table_texts(tbl: dict) -> set: """표의 모든 셀 텍스트 수집""" texts = set() for row in tbl.get("rows", []): for cell in row: t = cell.get("text", "").strip() if t: texts.add(t) return texts def _extract_longest_text(tbl: dict) -> str: """표에서 가장 긴 텍스트 추출 (제목 블록용)""" longest = "" for row in tbl.get("rows", []): for cell in row: t = cell.get("text", "").strip() if len(t) > len(longest): longest = t return longest def _is_title_block(tbl: dict) -> bool: """제목 블록 패턴 판별. 조건 (하나라도 충족): A) 1행 2열, 왼쪽 열 비율 ≤ 10% (불릿아이콘 + 제목) B) 1행 1열, 텍스트 길이 5~100자 (제목 단독) """ if tbl.get("rowCnt", 0) != 1: return False col_cnt = tbl.get("colCnt", 0) col_pcts = tbl.get("colWidths_pct", []) # 패턴 A: 좁은 왼쪽 + 넓은 오른쪽 if col_cnt == 2 and len(col_pcts) >= 2: if col_pcts[0] <= 10: return True # 패턴 B: 단일 셀 제목 if col_cnt == 1: rows = tbl.get("rows", []) if rows and rows[0]: text = rows[0][0].get("text", "") if 5 < len(text) < 100: return True return False def _detect_table_headers(tbl: dict) -> list: """표 첫 행의 컬럼 헤더 텍스트 반환. 헤더 판별: 첫 행의 모든 텍스트가 짧음 (20자 이하) """ rows = tbl.get("rows", []) if not rows or len(rows) < 2: return [] first_row = rows[0] headers = [] for cell in first_row: t = cell.get("text", "").strip() headers.append(t) # 전부 짧은 텍스트이면 헤더행 if headers and all(len(h) <= 20 for h in headers if h): non_empty = [h for h in headers if h] if non_empty: # 최소 1개는 텍스트가 있어야 return headers return [] # ================================================================ # 섹션 감지 # ================================================================ _SECTION_PATTERNS = [ (r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요" (r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ][\.\s]+(.+)', "roman"), # "Ⅰ. 개요" (r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요" (r'^[▶►▸●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요" ] def _detect_sections(parsed: dict) -> list: """parsed 텍스트에서 섹션 제목 패턴 탐색. Returns: [ {"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"}, {"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"}, ... ] """ paragraphs = _extract_paragraphs(parsed) sections = [] sec_idx = 0 for text in paragraphs: text = text.strip() if not text or len(text) > 100: # 너무 긴 텍스트는 제목이 아님 continue for pat, pat_type in _SECTION_PATTERNS: m = re.match(pat, text) if m: # numbered 패턴: 숫자가 100 이상이면 섹션 번호가 아님 (연도 등 제외) if pat_type == "numbered" and int(m.group(1)) > 99: continue sec_idx += 1 sections.append({ "index": sec_idx, "title": text, "pattern_type": pat_type, }) break return sections def _extract_paragraphs(parsed: dict) -> list: """parsed에서 텍스트 단락 추출. 우선순위: 1. parsed["paragraphs"] (파서가 직접 제공) 2. section_xml의 태그에서 추출 """ paragraphs = parsed.get("paragraphs", []) if paragraphs: return [ p.get("text", "") if isinstance(p, dict) else str(p) for p in paragraphs ] # section_xml에서 추출 section_xml = "" raw_xml = parsed.get("raw_xml", {}) for key, val in raw_xml.items(): if "section" in key.lower() and isinstance(val, str): section_xml = val break if not section_xml: section_xml = parsed.get("section_xml", "") if section_xml: return [ t.strip() for t in re.findall(r'([^<]+)', section_xml) if t.strip() ] return [] # ================================================================ # 스타일 매핑 (Phase 2에서 확장) # ================================================================ def _prepare_style_mappings(template_info: dict) -> dict: """스타일 매핑 빈 구조 생성. Phase 2에서 이 구조를 채움: - char_styles → CSS font/color rules - border_fills → CSS border/background rules - para_styles → CSS margin/alignment rules """ mappings = { "char_pr": {}, "border_fill": {}, "para_pr": {}, } # border_fills가 있으면 기본 매핑 생성 border_fills = template_info.get("border_fills", {}) for bf_id, bf_data in border_fills.items(): # ★ 실제 키 구조 대응 (bg→background, sides→css/직접키) bg = bf_data.get("background", bf_data.get("bg", "")) # borders: css dict 또는 직접 키에서 추출 borders = {} css_dict = bf_data.get("css", {}) if css_dict: for prop, val in css_dict.items(): if prop.startswith("border-") and val and val != "none": borders[prop] = val else: # fallback: 직접 side 키 for side in ("top", "bottom", "left", "right"): si = bf_data.get(side, {}) if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE": borders[f"border-{side}"] = ( f"{si.get('width','0.1mm')} " f"{si.get('type','solid').lower()} " f"{si.get('color','#000')}" ) mappings["border_fill"][str(bf_id)] = { "css_class": f"bf-{bf_id}", "bg": bg, "borders": borders, } return mappings