Files
test/converters/style_analyzer.py
2026-02-20 11:34:02 +09:00

935 lines
33 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.
"""
HTML 스타일 분석기 v3.0
HTML 요소를 분석하여 역할(Role)을 자동 분류
✅ v3.0 변경사항:
- 글벗 HTML 구조 완벽 지원 (.sheet, .body-content)
- 머리말/꼬리말/페이지번호 제거
- 강력한 중복 콘텐츠 필터링
- 제목 계층 구조 정확한 인식
"""
import re
from bs4 import BeautifulSoup, Tag, NavigableString
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple, Set
from enum import Enum
class DocumentSection(Enum):
"""문서 섹션 유형"""
COVER = "cover" # 표지
TOC = "toc" # 목차
CONTENT = "content" # 본문
@dataclass
class StyledElement:
"""스타일이 지정된 요소"""
role: str # 역할 (H1, BODY, TH 등)
text: str # 텍스트 내용
tag: str # 원본 HTML 태그
html: str # 원본 HTML
section: str # 섹션 (cover, toc, content)
attributes: Dict # 추가 속성 (이미지 src 등)
def __repr__(self):
preview = self.text[:30] + "..." if len(self.text) > 30 else self.text
return f"<{self.role}> {preview}"
class StyleAnalyzer:
"""HTML 문서를 분석하여 역할 분류"""
# 번호 패턴 정의
PATTERNS = {
# 장 번호: "제1장", "제2장"
"chapter": re.compile(r'^제\s*\d+\s*장'),
# 1단계 제목: "1 ", "2 " (숫자+공백, 점 없음)
"h1_num": re.compile(r'^(\d+)\s+[가-힣]'),
# 대항목: "1.", "2."
"h2_num": re.compile(r'^(\d+)\.\s'),
# 중항목: "1.1 ", "1.2 "
"h3_num": re.compile(r'^(\d+)\.(\d+)\s'),
# 소항목: "1.1.1"
"h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'),
# 세부: "1)", "2)"
"h5_paren": re.compile(r'^(\d+)\)\s*'),
# 세세부: "(1)", "(2)"
"h6_paren": re.compile(r'^\((\d+)\)\s*'),
# 가나다: "가.", "나."
"h4_korean": re.compile(r'^[가-하]\.\s'),
# 가나다 괄호: "가)", "나)"
"h5_korean": re.compile(r'^[가-하]\)\s'),
# 원문자: "①", "②"
"h6_circle": re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]'),
# 목록: "•", "-", "○"
"list_bullet": re.compile(r'^[•\-○]\s'),
# 페이지 번호 패턴: "- 1 -", "- 12 -"
"page_number": re.compile(r'^-\s*\d+\s*-$'),
# 꼬리말 패턴: "문서제목- 1 -"
"footer_pattern": re.compile(r'.+[-]\s*\d+\s*[-]$'),
}
# 제거할 텍스트 패턴들
REMOVE_PATTERNS = [
re.compile(r'^-\s*\d+\s*-$'), # "- 1 -"
re.compile(r'[-]\s*\d+\s*[-]\s*$'), # "문서제목- 1 -"
re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (이미지 크기)
re.compile(r'^\[이미지 없음:.*\]$'), # "[이미지 없음: xxx]"
re.compile(r'^\[그림\s*\d+-\d+\]$'), # "[그림 1-1]"
]
def __init__(self):
self.elements: List[StyledElement] = []
self.current_section = DocumentSection.CONTENT
self.seen_texts: Set[str] = set() # 중복 방지용
self.document_title = "" # 문서 제목 (꼬리말 제거용)
def analyze(self, html: str) -> List[StyledElement]:
"""HTML 문서 분석하여 역할 분류된 요소 리스트 반환"""
soup = BeautifulSoup(html, 'html.parser')
self.elements = []
self.seen_texts = set()
# 1. 전처리: 불필요한 요소 제거
self._preprocess(soup)
# 2. 문서 제목 추출 (꼬리말 패턴 감지용)
self._extract_document_title(soup)
# 3. 섹션 감지 및 순회
self._detect_and_process_sections(soup)
# 4. 후처리: 중복 및 불필요 요소 제거
self._postprocess()
return self.elements
def _preprocess(self, soup: BeautifulSoup):
"""HTML 전처리 - 불필요한 요소 제거"""
print(" 🔧 HTML 전처리 중...")
# 1. 스크립트/스타일 태그 제거
removed_count = 0
for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']):
tag.decompose()
removed_count += 1
if removed_count > 0:
print(f" - script/style 등 {removed_count}개 제거")
# 2. 머리말/꼬리말 영역 제거 (글벗 HTML 구조)
header_footer_count = 0
for selector in ['.page-header', '.page-footer', '.header', '.footer',
'[class*="header"]', '[class*="footer"]',
'.running-header', '.running-footer']:
for elem in soup.select(selector):
# 실제 콘텐츠 헤더가 아닌 페이지 헤더만 제거
text = elem.get_text(strip=True)
if self._is_header_footer_text(text):
elem.decompose()
header_footer_count += 1
if header_footer_count > 0:
print(f" - 머리말/꼬리말 {header_footer_count}개 제거")
# 3. 숨겨진 요소 제거
hidden_count = 0
for elem in soup.select('[style*="display:none"], [style*="display: none"]'):
elem.decompose()
hidden_count += 1
for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'):
elem.decompose()
hidden_count += 1
# 4. #raw-container 외부의 .sheet 제거 (글벗 구조)
raw_container = soup.find(id='raw-container')
if raw_container:
print(" - 글벗 구조 감지: #raw-container 우선 사용")
# raw-container 외부의 모든 .sheet 제거
for sheet in soup.select('.sheet'):
if not self._is_descendant_of(sheet, raw_container):
sheet.decompose()
def _extract_document_title(self, soup: BeautifulSoup):
"""문서 제목 추출 (꼬리말 패턴 감지용)"""
# 표지에서 제목 찾기
cover = soup.find(id='box-cover') or soup.find(class_='box-cover')
if cover:
h1 = cover.find('h1')
if h1:
self.document_title = h1.get_text(strip=True)
print(f" - 문서 제목 감지: {self.document_title[:30]}...")
def _is_header_footer_text(self, text: str) -> bool:
"""머리말/꼬리말 텍스트인지 판단"""
if not text:
return False
# 페이지 번호 패턴
if self.PATTERNS['page_number'].match(text):
return True
# "문서제목- 1 -" 패턴
if self.PATTERNS['footer_pattern'].match(text):
return True
# 문서 제목 + 페이지번호 조합
if self.document_title and self.document_title in text:
if re.search(r'[-]\s*\d+\s*[-]', text):
return True
return False
def _should_skip_text(self, text: str) -> bool:
"""건너뛸 텍스트인지 판단"""
if not text:
return True
# 제거 패턴 체크
for pattern in self.REMOVE_PATTERNS:
if pattern.match(text):
return True
# 머리말/꼬리말 체크
if self._is_header_footer_text(text):
return True
# 문서 제목만 있는 줄 (꼬리말에서 온 것)
if self.document_title and text.strip() == self.document_title:
# 이미 표지에서 처리했으면 스킵
if any(e.role == 'COVER_TITLE' and self.document_title in e.text
for e in self.elements):
return True
return False
def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool:
"""element가 ancestor의 자손인지 확인"""
parent = element.parent
while parent:
if parent == ancestor:
return True
parent = parent.parent
return False
def _detect_and_process_sections(self, soup: BeautifulSoup):
"""섹션 감지 및 처리"""
# 글벗 구조 (#raw-container) 우선 처리
raw = soup.find(id='raw-container')
if raw:
self._process_geulbeot_structure(raw)
return
# .sheet 구조 처리 (렌더링된 페이지)
sheets = soup.select('.sheet')
if sheets:
self._process_sheet_structure(sheets)
return
# 일반 HTML 구조 처리
self._process_generic_html(soup)
def _process_geulbeot_structure(self, raw: Tag):
"""글벗 HTML #raw-container 구조 처리"""
print(" 📄 글벗 #raw-container 구조 처리 중...")
# 표지
cover = raw.find(id='box-cover')
if cover:
print(" - 표지 섹션")
self.current_section = DocumentSection.COVER
self._process_cover(cover)
# 목차
toc = raw.find(id='box-toc')
if toc:
print(" - 목차 섹션")
self.current_section = DocumentSection.TOC
self._process_toc(toc)
# 요약
summary = raw.find(id='box-summary')
if summary:
print(" - 요약 섹션")
self.current_section = DocumentSection.CONTENT
self._process_content_element(summary)
# 본문
content = raw.find(id='box-content')
if content:
print(" - 본문 섹션")
self.current_section = DocumentSection.CONTENT
self._process_content_element(content)
def _process_sheet_structure(self, sheets: List[Tag]):
"""글벗 .sheet 페이지 구조 처리"""
print(f" 📄 .sheet 페이지 구조 처리 중... ({len(sheets)}페이지)")
for i, sheet in enumerate(sheets):
# 페이지 내 body-content만 추출
body_content = sheet.select_one('.body-content')
if body_content:
self._process_content_element(body_content)
else:
# body-content가 없으면 머리말/꼬리말 제외하고 처리
for child in sheet.children:
if isinstance(child, Tag):
classes = child.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 머리말/꼬리말 스킵
if any(x in class_str.lower() for x in ['header', 'footer']):
continue
self._process_content_element(child)
def _process_generic_html(self, soup: BeautifulSoup):
"""일반 HTML 구조 처리"""
print(" 📄 일반 HTML 구조 처리 중...")
# 표지
cover = soup.find(class_=re.compile(r'cover|title-page|box-cover'))
if cover:
self.current_section = DocumentSection.COVER
self._process_cover(cover)
# 목차
toc = soup.find(class_=re.compile(r'toc|table-of-contents'))
if toc:
self.current_section = DocumentSection.TOC
self._process_toc(toc)
# 본문
self.current_section = DocumentSection.CONTENT
main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup
for child in main_content.children:
if isinstance(child, Tag):
self._process_content_element(child)
def _process_cover(self, cover: Tag):
"""표지 처리"""
# H1 = 제목
h1 = cover.find('h1')
if h1:
text = h1.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_TITLE",
text=text,
tag="h1",
html=str(h1)[:200],
section="cover",
attributes={}
))
# H2 = 부제목
h2 = cover.find('h2')
if h2:
text = h2.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_SUBTITLE",
text=text,
tag="h2",
html=str(h2)[:200],
section="cover",
attributes={}
))
# P = 정보
for p in cover.find_all('p'):
text = p.get_text(strip=True)
if text and not self._is_duplicate(text):
self.elements.append(StyledElement(
role="COVER_INFO",
text=text,
tag="p",
html=str(p)[:200],
section="cover",
attributes={}
))
def _process_toc(self, toc: Tag):
"""목차 처리"""
# UL/OL 기반 목차
for li in toc.find_all('li'):
text = li.get_text(strip=True)
if not text or self._is_duplicate(text):
continue
classes = li.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 레벨 판단 (구체적 → 일반 순서!)
if 'lvl-1' in class_str or 'toc-lvl-1' in class_str:
role = "TOC_H1"
elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str:
role = "TOC_H2"
elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str:
role = "TOC_H3"
elif self.PATTERNS['h4_num'].match(text): # 1.1.1 먼저!
role = "TOC_H3"
elif self.PATTERNS['h3_num'].match(text): # 1.1 그다음
role = "TOC_H2"
elif self.PATTERNS['h2_num'].match(text): # 1. 그다음
role = "TOC_H1"
else:
role = "TOC_H1"
self.elements.append(StyledElement(
role=role,
text=text,
tag="li",
html=str(li)[:200],
section="toc",
attributes={}
))
def _process_content_element(self, element: Tag):
"""본문 요소 재귀 처리"""
if not isinstance(element, Tag):
return
tag_name = element.name.lower() if element.name else ""
classes = element.get('class', [])
class_str = ' '.join(classes) if classes else ''
# 머리말/꼬리말 클래스 스킵
if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']):
return
# 테이블 특수 처리
if tag_name == 'table':
self._process_table(element)
return
# 그림 특수 처리
if tag_name in ['figure', 'img']:
self._process_figure(element)
return
# 텍스트 추출
text = self._get_direct_text(element)
if text:
# 건너뛸 텍스트 체크
if self._should_skip_text(text):
pass # 자식은 계속 처리
elif not self._is_duplicate(text):
role = self._classify_role(element, tag_name, classes, text)
if role:
self.elements.append(StyledElement(
role=role,
text=text,
tag=tag_name,
html=str(element)[:200],
section=self.current_section.value,
attributes=dict(element.attrs) if element.attrs else {}
))
# 자식 요소 재귀 처리 (컨테이너 태그)
if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body',
'ul', 'ol', 'dl', 'blockquote']:
for child in element.children:
if isinstance(child, Tag):
self._process_content_element(child)
def _get_direct_text(self, element: Tag) -> str:
"""요소의 직접 텍스트만 추출 (자식 컨테이너 제외)"""
# 제목 태그는 전체 텍스트
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']:
return element.get_text(strip=True)
# 컨테이너 태그는 직접 텍스트만
texts = []
for child in element.children:
if isinstance(child, NavigableString):
t = str(child).strip()
if t:
texts.append(t)
return ' '.join(texts)
def _is_duplicate(self, text: str) -> bool:
"""중복 텍스트인지 확인"""
if not text:
return True
# 정규화
normalized = re.sub(r'\s+', ' ', text.strip())
# 짧은 텍스트는 중복 허용 (번호 등)
if len(normalized) < 10:
return False
# 첫 50자로 체크
key = normalized[:50]
if key in self.seen_texts:
return True
self.seen_texts.add(key)
return False
def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]:
"""요소의 역할 분류
⚠️ 중요: 패턴 매칭은 반드시 구체적인 것 → 일반적인 것 순서로!
1.1.1 → 1.1 → 1. → 1
(1) → 1)
가) → 가.
"""
class_str = ' '.join(classes) if classes else ''
# ============ 제목 태그 (HTML 태그 우선) ============
if tag == 'h1':
return "H1"
if tag == 'h2':
return "H2"
if tag == 'h3':
return "H3"
if tag == 'h4':
return "H4"
if tag == 'h5':
return "H5"
if tag == 'h6':
return "H6"
# ============ 본문 (p, div 등) - 번호 패턴으로 분류 ============
if tag in ['p', 'div', 'span']:
# ------ 숫자.숫자 패턴 (구체적 → 일반 순서!) ------
# "1.1.1" 패턴 (가장 구체적 - 먼저 체크!)
if self.PATTERNS['h4_num'].match(text):
if len(text) < 100:
return "H3"
return "BODY"
# "1.1 " 패턴
if self.PATTERNS['h3_num'].match(text):
if len(text) < 100:
return "H2"
return "BODY"
# "1." 패턴
if self.PATTERNS['h2_num'].match(text):
if len(text) < 100:
return "H1"
return "BODY"
# "1 가나다..." 패턴 (숫자+공백+한글)
if self.PATTERNS['h1_num'].match(text):
return "H1"
# ------ 괄호 패턴 (구체적 → 일반 순서!) ------
# "(1)" 패턴 (괄호로 감싼 게 더 구체적 - 먼저 체크!)
if self.PATTERNS['h6_paren'].match(text):
if element.find('strong') or len(text) < 80:
return "H5"
return "BODY"
# "1)" 패턴
if self.PATTERNS['h5_paren'].match(text):
if element.find('strong') or len(text) < 80:
return "H4"
return "BODY"
# ------ 한글 패턴 (구체적 → 일반 순서!) ------
# "가)" 패턴 (괄호가 더 구체적 - 먼저 체크!)
if self.PATTERNS['h5_korean'].match(text):
return "H5"
# "가." 패턴
if self.PATTERNS['h4_korean'].match(text):
return "H4"
# ------ 특수 기호 패턴 ------
# "①②③" 패턴
if self.PATTERNS['h6_circle'].match(text):
return "H6"
# ------ 기타 ------
# 강조 박스
if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']):
return "HIGHLIGHT_BOX"
# 일반 본문
return "BODY"
# ============ 목록 ============
if tag == 'li':
return "LIST_ITEM"
# ============ 정의 목록 ============
if tag == 'dt':
return "H5"
if tag == 'dd':
return "BODY"
return "BODY"
def _process_table(self, table: Tag):
"""테이블 처리 - 구조 데이터 포함"""
# 캡션
caption = table.find('caption')
caption_text = ""
if caption:
caption_text = caption.get_text(strip=True)
if caption_text and not self._is_duplicate(caption_text):
self.elements.append(StyledElement(
role="TABLE_CAPTION",
text=caption_text,
tag="caption",
html=str(caption)[:100],
section=self.current_section.value,
attributes={}
))
# 🆕 표 구조 데이터 수집
table_data = {'rows': [], 'caption': caption_text}
for tr in table.find_all('tr'):
row = []
for cell in tr.find_all(['th', 'td']):
cell_info = {
'text': cell.get_text(strip=True),
'is_header': cell.name == 'th',
'colspan': int(cell.get('colspan', 1)),
'rowspan': int(cell.get('rowspan', 1)),
'bg_color': self._extract_bg_color(cell),
}
row.append(cell_info)
if row:
table_data['rows'].append(row)
# 🆕 TABLE 요소로 추가 (개별 TH/TD 대신)
if table_data['rows']:
self.elements.append(StyledElement(
role="TABLE",
text=f"[표: {len(table_data['rows'])}행]",
tag="table",
html=str(table)[:200],
section=self.current_section.value,
attributes={'table_data': table_data}
))
def _extract_bg_color(self, element: Tag) -> str:
"""요소에서 배경색 추출"""
style = element.get('style', '')
# background-color 추출
match = re.search(r'background-color:\s*([^;]+)', style)
if match:
return self._normalize_color(match.group(1))
# bgcolor 속성
bgcolor = element.get('bgcolor', '')
if bgcolor:
return self._normalize_color(bgcolor)
return ''
def _process_figure(self, element: Tag):
"""그림 처리"""
img = element.find('img') if element.name == 'figure' else element
if img and img.name == 'img':
src = img.get('src', '')
alt = img.get('alt', '')
if src: # src가 있을 때만 추가
self.elements.append(StyledElement(
role="FIGURE",
text=alt or "이미지",
tag="img",
html=str(img)[:100],
section=self.current_section.value,
attributes={"src": src, "alt": alt}
))
# 캡션
if element.name == 'figure':
figcaption = element.find('figcaption')
if figcaption:
text = figcaption.get_text(strip=True)
if text and not self._should_skip_text(text):
self.elements.append(StyledElement(
role="FIGURE_CAPTION",
text=text,
tag="figcaption",
html=str(figcaption)[:100],
section=self.current_section.value,
attributes={}
))
def _postprocess(self):
"""후처리: 불필요 요소 제거"""
print(f" 🧹 후처리 중... (처리 전: {len(self.elements)}개)")
filtered = []
for elem in self.elements:
# 빈 텍스트 제거
if not elem.text or not elem.text.strip():
continue
# 머리말/꼬리말 텍스트 제거
if self._is_header_footer_text(elem.text):
continue
# 제거 패턴 체크
skip = False
for pattern in self.REMOVE_PATTERNS:
if pattern.match(elem.text.strip()):
skip = True
break
if not skip:
filtered.append(elem)
self.elements = filtered
print(f" - 처리 후: {len(self.elements)}")
def get_role_summary(self) -> Dict[str, int]:
"""역할별 요소 수 요약"""
summary = {}
for elem in self.elements:
summary[elem.role] = summary.get(elem.role, 0) + 1
return dict(sorted(summary.items()))
def extract_css_styles(self, html: str) -> Dict[str, Dict]:
"""
HTML에서 역할별 CSS 스타일 추출
Returns: {역할: {font_size, color, bold, ...}}
"""
soup = BeautifulSoup(html, 'html.parser')
role_styles = {}
# <style> 태그에서 CSS 파싱
style_tag = soup.find('style')
if style_tag:
css_text = style_tag.string or ''
role_styles.update(self._parse_css_rules(css_text))
# 인라인 스타일에서 추출 (요소별)
for elem in self.elements:
if elem.role not in role_styles:
role_styles[elem.role] = self._extract_inline_style(elem.html)
return role_styles
def _parse_css_rules(self, css_text: str) -> Dict[str, Dict]:
"""CSS 텍스트에서 규칙 파싱"""
import re
rules = {}
# h1, h2, .section-title 등의 패턴
pattern = r'([^{]+)\{([^}]+)\}'
for match in re.finditer(pattern, css_text):
selector = match.group(1).strip()
properties = match.group(2)
style = {}
for prop in properties.split(';'):
if ':' in prop:
key, value = prop.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'font-size':
style['font_size'] = self._parse_font_size(value)
elif key == 'color':
style['color'] = self._normalize_color(value)
elif key == 'font-weight':
style['bold'] = value in ['bold', '700', '800', '900']
elif key == 'text-align':
style['align'] = value
# 셀렉터 → 역할 매핑
role = self._selector_to_role(selector)
if role:
rules[role] = style
return rules
def _selector_to_role(self, selector: str) -> str:
"""CSS 셀렉터 → 역할 매핑"""
selector = selector.lower().strip()
mapping = {
'h1': 'H1', 'h2': 'H2', 'h3': 'H3', 'h4': 'H4',
'.cover-title': 'COVER_TITLE',
'.section-title': 'H1',
'th': 'TH', 'td': 'TD',
'p': 'BODY',
}
for key, role in mapping.items():
if key in selector:
return role
return None
def _parse_font_size(self, value: str) -> float:
"""폰트 크기 파싱 (pt 단위로 변환)"""
import re
match = re.search(r'([\d.]+)(pt|px|em|rem)?', value)
if match:
size = float(match.group(1))
unit = match.group(2) or 'pt'
if unit == 'px':
size = size * 0.75 # px → pt
elif unit in ['em', 'rem']:
size = size * 11 # 기본 11pt 기준
return size
return 11.0
def _normalize_color(self, value: str) -> str:
"""색상값 정규화 (#RRGGBB)"""
import re
value = value.strip().lower()
# 이미 #rrggbb 형식
if re.match(r'^#[0-9a-f]{6}$', value):
return value.upper()
# #rgb → #rrggbb
if re.match(r'^#[0-9a-f]{3}$', value):
return f'#{value[1]*2}{value[2]*2}{value[3]*2}'.upper()
# rgb(r, g, b)
match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', value)
if match:
r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
return f'#{r:02X}{g:02X}{b:02X}'
# 색상 이름
color_names = {
'black': '#000000', 'white': '#FFFFFF',
'red': '#FF0000', 'green': '#008000', 'blue': '#0000FF',
'navy': '#1A365D',
}
return color_names.get(value, '#000000')
def _extract_inline_style(self, html: str) -> Dict:
"""HTML 요소에서 인라인 스타일 추출"""
style = {}
# style 속성 찾기
match = re.search(r'style\s*=\s*["\']([^"\']+)["\']', html)
if match:
style_str = match.group(1)
for prop in style_str.split(';'):
if ':' in prop:
key, value = prop.split(':', 1)
key = key.strip().lower()
value = value.strip()
if key == 'font-size':
style['font_size'] = self._parse_font_size(value)
elif key == 'color':
style['color'] = self._normalize_color(value)
elif key == 'font-weight':
style['bold'] = value in ['bold', '700', '800', '900']
elif key == 'text-align':
style['align'] = value
elif key == 'background-color':
style['bg_color'] = self._normalize_color(value)
return style
def _extract_bg_color(self, element) -> str:
"""요소에서 배경색 추출"""
if not hasattr(element, 'get'):
return ''
style = element.get('style', '')
# background-color 추출
match = re.search(r'background-color:\s*([^;]+)', style)
if match:
return self._normalize_color(match.group(1))
# bgcolor 속성
bgcolor = element.get('bgcolor', '')
if bgcolor:
return self._normalize_color(bgcolor)
return ''
def export_for_hwp(self) -> List[Dict]:
"""HWP 변환용 데이터 내보내기"""
return [
{
"role": e.role,
"text": e.text,
"tag": e.tag,
"section": e.section,
"attributes": e.attributes
}
for e in self.elements
]
if __name__ == "__main__":
# 테스트
test_html = """
<html>
<head>
<script>var x = 1;</script>
<style>.test { color: red; }</style>
</head>
<body>
<div class="sheet">
<div class="page-header">건설·토목 측량 DX 실무지침</div>
<div class="body-content">
<h1>1 DX 개요와 기본 개념</h1>
<h2>1.1 측량 DX 프레임</h2>
<h3>1.1.1 측량 DX 발전 단계</h3>
<p>1) <strong>Digitization 정의</strong></p>
<p>본문 내용입니다. 이것은 충분히 긴 텍스트로 본문으로 인식되어야 합니다.</p>
<p>(1) 단계별 정의 및 진화</p>
<p>측량 기술의 발전은 장비의 변화와 성과물의 차원에 따라 구분된다.</p>
</div>
<div class="page-footer">건설·토목 측량 DX 실무지침- 1 -</div>
</div>
<div class="sheet">
<div class="page-header">건설·토목 측량 DX 실무지침</div>
<div class="body-content">
<p>① 첫 번째 항목</p>
<table>
<caption>표 1. 데이터 비교</caption>
<tr><th>구분</th><th>내용</th></tr>
<tr><td>항목1</td><td>설명1</td></tr>
</table>
</div>
<div class="page-footer">건설·토목 측량 DX 실무지침- 2 -</div>
</div>
</body>
</html>
"""
analyzer = StyleAnalyzer()
elements = analyzer.analyze(test_html)
print("\n" + "="*60)
print("분석 결과")
print("="*60)
for elem in elements:
print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}")
print("\n" + "="*60)
print("역할 요약")
print("="*60)
for role, count in analyzer.get_role_summary().items():
print(f" {role}: {count}")