382 lines
12 KiB
Python
382 lines
12 KiB
Python
# -*- 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의 <hp:t> 태그에서 추출
|
||
"""
|
||
paragraphs = parsed.get("paragraphs", [])
|
||
if paragraphs:
|
||
return [
|
||
p.get("text", "") if isinstance(p, dict) else str(p)
|
||
for p in paragraphs
|
||
]
|
||
|
||
# section_xml에서 <hp:t> 추출
|
||
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'<hp:t>([^<]+)</hp:t>', 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 |