Files
test/handlers/semantic_mapper.py

382 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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