v5:HWPX 스타일 주입 + 표 열너비 변환_20260127
This commit is contained in:
@@ -16,6 +16,7 @@ import os, re
|
||||
# 스타일 그루핑 시스템 추가
|
||||
from converters.style_analyzer import StyleAnalyzer, StyledElement
|
||||
from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME
|
||||
from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx
|
||||
|
||||
|
||||
# PIL 선택적 import (이미지 크기 확인용)
|
||||
@@ -99,6 +100,79 @@ def strip_numbering(text: str, role: str) -> str:
|
||||
|
||||
return text.strip()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 표 너비 파싱 유틸리티 (🆕 추가)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_width(width_str):
|
||||
"""너비 문자열 파싱 → mm 값 반환"""
|
||||
if not width_str:
|
||||
return None
|
||||
|
||||
width_str = str(width_str).strip().lower()
|
||||
|
||||
# style 속성에서 width 추출
|
||||
style_match = re.search(r'width\s*:\s*([^;]+)', width_str)
|
||||
if style_match:
|
||||
width_str = style_match.group(1).strip()
|
||||
|
||||
# px → mm (96 DPI 기준)
|
||||
px_match = re.search(r'([\d.]+)\s*px', width_str)
|
||||
if px_match:
|
||||
return float(px_match.group(1)) * 25.4 / 96
|
||||
|
||||
# mm 그대로
|
||||
mm_match = re.search(r'([\d.]+)\s*mm', width_str)
|
||||
if mm_match:
|
||||
return float(mm_match.group(1))
|
||||
|
||||
# % → 본문폭(170mm) 기준 계산
|
||||
pct_match = re.search(r'([\d.]+)\s*%', width_str)
|
||||
if pct_match:
|
||||
return float(pct_match.group(1)) * 170 / 100
|
||||
|
||||
# 숫자만 있으면 px로 간주
|
||||
num_match = re.search(r'^([\d.]+)$', width_str)
|
||||
if num_match:
|
||||
return float(num_match.group(1)) * 25.4 / 96
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_align(cell):
|
||||
"""셀의 정렬 속성 파싱"""
|
||||
align = cell.get('align', '').lower()
|
||||
if align in ['left', 'center', 'right']:
|
||||
return align
|
||||
|
||||
style = cell.get('style', '')
|
||||
align_match = re.search(r'text-align\s*:\s*(\w+)', style)
|
||||
if align_match:
|
||||
return align_match.group(1).lower()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_bg_color(cell):
|
||||
"""셀의 배경색 파싱"""
|
||||
bgcolor = cell.get('bgcolor', '')
|
||||
if bgcolor:
|
||||
return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}'
|
||||
|
||||
style = cell.get('style', '')
|
||||
bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style)
|
||||
if bg_match:
|
||||
color = bg_match.group(1).strip()
|
||||
if color.startswith('#'):
|
||||
return color
|
||||
rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color)
|
||||
if rgb_match:
|
||||
r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3))
|
||||
return f'#{r:02X}{g:02X}{b:02X}'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class HtmlToHwpConverter:
|
||||
def __init__(self, visible=True):
|
||||
self.hwp = Hwp(visible=visible)
|
||||
@@ -107,6 +181,7 @@ class HtmlToHwpConverter:
|
||||
self.base_path = ""
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
self.table_widths = [] # 🆕 표 열 너비 정보 저장용
|
||||
self.style_map = {} # 역할 → 스타일 이름 매핑
|
||||
self.sty_path = None # .sty 파일 경로
|
||||
|
||||
@@ -436,43 +511,168 @@ class HtmlToHwpConverter:
|
||||
self.hwp.BreakPara()
|
||||
|
||||
def _insert_table(self, table_elem):
|
||||
rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0
|
||||
"""HTML 테이블 → HWP 표 변환 (내용 기반 열 너비 계산 + HWPX 후처리용 저장)"""
|
||||
|
||||
# ═══ 1. 테이블 구조 분석 ═══
|
||||
rows_data = []
|
||||
cell_styles = {}
|
||||
occupied = {}
|
||||
max_cols = 0
|
||||
col_widths = [] # 열 너비 (mm) - HTML에서 지정된 값
|
||||
|
||||
# <colgroup>/<col>에서 너비 추출
|
||||
colgroup = table_elem.find('colgroup')
|
||||
if colgroup:
|
||||
for col in colgroup.find_all('col'):
|
||||
width = _parse_width(col.get('width') or col.get('style', ''))
|
||||
col_widths.append(width)
|
||||
|
||||
# 행 데이터 수집
|
||||
for ri, tr in enumerate(table_elem.find_all('tr')):
|
||||
row, ci = [], 0
|
||||
for cell in tr.find_all(['td','th']):
|
||||
while (ri,ci) in occupied: row.append(""); ci+=1
|
||||
row = []
|
||||
ci = 0
|
||||
|
||||
for cell in tr.find_all(['td', 'th']):
|
||||
# 병합된 셀 건너뛰기
|
||||
while (ri, ci) in occupied:
|
||||
row.append("")
|
||||
ci += 1
|
||||
|
||||
txt = cell.get_text(strip=True)
|
||||
cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1))
|
||||
cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0}
|
||||
cs = int(cell.get('colspan', 1))
|
||||
rs = int(cell.get('rowspan', 1))
|
||||
|
||||
# 셀 스타일 저장
|
||||
cell_styles[(ri, ci)] = {
|
||||
'is_header': cell.name == 'th' or ri == 0,
|
||||
'align': _parse_align(cell),
|
||||
'bg_color': _parse_bg_color(cell)
|
||||
}
|
||||
|
||||
# 첫 행에서 열 너비 추출 (colgroup 없을 때)
|
||||
if ri == 0:
|
||||
width = _parse_width(cell.get('width') or cell.get('style', ''))
|
||||
for _ in range(cs):
|
||||
if len(col_widths) <= ci + _:
|
||||
col_widths.append(width if _ == 0 else None)
|
||||
|
||||
row.append(txt)
|
||||
|
||||
# 병합 영역 표시
|
||||
for dr in range(rs):
|
||||
for dc in range(cs):
|
||||
if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True
|
||||
for _ in range(cs-1): row.append("")
|
||||
if dr > 0 or dc > 0:
|
||||
occupied[(ri + dr, ci + dc)] = True
|
||||
|
||||
# colspan 빈 셀 추가
|
||||
for _ in range(cs - 1):
|
||||
row.append("")
|
||||
ci += cs
|
||||
|
||||
rows_data.append(row)
|
||||
max_cols = max(max_cols, len(row))
|
||||
|
||||
# 행/열 수 맞추기
|
||||
for row in rows_data:
|
||||
while len(row) < max_cols: row.append("")
|
||||
while len(row) < max_cols:
|
||||
row.append("")
|
||||
while len(col_widths) < max_cols:
|
||||
col_widths.append(None)
|
||||
|
||||
rc = len(rows_data)
|
||||
if rc == 0 or max_cols == 0: return
|
||||
if rc == 0 or max_cols == 0:
|
||||
return
|
||||
|
||||
print(f" 표: {rc}행 × {max_cols}열")
|
||||
|
||||
# ═══ 2. 열 너비 계산 (내용 길이 기반) ═══
|
||||
body_width_mm = 170 # A4 본문 폭 (210mm - 좌우 여백 40mm)
|
||||
|
||||
# 지정된 너비가 있는 열 확인
|
||||
specified_width = sum(w for w in col_widths if w is not None)
|
||||
unspecified_indices = [i for i, w in enumerate(col_widths) if w is None]
|
||||
|
||||
if unspecified_indices:
|
||||
# 각 열의 최대 텍스트 길이 계산 (한글=2, 영문/숫자=1)
|
||||
col_text_lengths = [0] * max_cols
|
||||
for row in rows_data:
|
||||
for ci, cell_text in enumerate(row):
|
||||
if ci < max_cols:
|
||||
# 한글은 2배 너비로 계산
|
||||
length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text))
|
||||
col_text_lengths[ci] = max(col_text_lengths[ci], length)
|
||||
|
||||
# 최소 너비 보장 (8자 이상)
|
||||
col_text_lengths = [max(length, 8) for length in col_text_lengths]
|
||||
|
||||
# 미지정 열들의 총 텍스트 길이
|
||||
unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices)
|
||||
|
||||
# 남은 너비를 텍스트 길이 비율로 분배
|
||||
remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices))
|
||||
|
||||
for i in unspecified_indices:
|
||||
if unspecified_total_length > 0:
|
||||
ratio = col_text_lengths[i] / unspecified_total_length
|
||||
col_widths[i] = remaining_width * ratio
|
||||
else:
|
||||
col_widths[i] = remaining_width / len(unspecified_indices)
|
||||
|
||||
print(f" 텍스트 길이: {col_text_lengths}")
|
||||
|
||||
# 본문 폭 초과 시 비례 축소
|
||||
total = sum(col_widths)
|
||||
if total > body_width_mm:
|
||||
ratio = body_width_mm / total
|
||||
col_widths = [w * ratio for w in col_widths]
|
||||
|
||||
col_widths_mm = [round(w, 1) for w in col_widths]
|
||||
print(f" 열 너비(mm): {col_widths_mm}")
|
||||
|
||||
# ═══ 3. HWPX 후처리용 열 너비 저장 ═══
|
||||
self.table_widths.append(col_widths_mm)
|
||||
print(f" 📊 표 #{len(self.table_widths)} 저장 완료")
|
||||
|
||||
# ═══ 4. HWP 표 생성 (기본 방식) ═══
|
||||
self._set_para('left', 130, before=5, after=0)
|
||||
self.hwp.create_table(rc, max_cols, treat_as_char=True)
|
||||
|
||||
# ═══ 5. 셀 내용 입력 ═══
|
||||
for ri, row in enumerate(rows_data):
|
||||
for ci in range(max_cols):
|
||||
if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue
|
||||
# 병합된 셀 건너뛰기
|
||||
if (ri, ci) in occupied:
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
continue
|
||||
|
||||
txt = row[ci] if ci < len(row) else ""
|
||||
hdr = cell_styles.get((ri,ci),{}).get('is_header', False)
|
||||
if hdr: self._set_cell_bg('#E8F5E9')
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
|
||||
style = cell_styles.get((ri, ci), {})
|
||||
hdr = style.get('is_header', False)
|
||||
|
||||
# 배경색
|
||||
if hdr:
|
||||
self._set_cell_bg('#E8F5E9')
|
||||
elif style.get('bg_color'):
|
||||
self._set_cell_bg(style['bg_color'])
|
||||
|
||||
# 정렬
|
||||
align = style.get('align', 'center' if hdr else 'left')
|
||||
if align == 'center':
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
|
||||
elif align == 'right':
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignRight")
|
||||
else:
|
||||
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
|
||||
|
||||
# 폰트
|
||||
self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333')
|
||||
self.hwp.insert_text(str(txt))
|
||||
if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# 다음 셀로 이동 (마지막 셀 제외)
|
||||
if not (ri == rc - 1 and ci == max_cols - 1):
|
||||
self.hwp.HAction.Run("MoveRight")
|
||||
|
||||
# ═══ 6. 표 편집 종료 ═══
|
||||
self.hwp.HAction.Run("Cancel")
|
||||
self.hwp.HAction.Run("CloseEx")
|
||||
self.hwp.HAction.Run("MoveDocEnd")
|
||||
@@ -734,7 +934,8 @@ class HtmlToHwpConverter:
|
||||
self.base_path = os.path.dirname(os.path.abspath(html_path))
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
self.table_widths = [] # 🆕 표 열 너비 초기화
|
||||
|
||||
print(f"\n입력: {html_path}")
|
||||
print(f"출력: {output_path}\n")
|
||||
|
||||
@@ -787,166 +988,75 @@ class HtmlToHwpConverter:
|
||||
|
||||
def convert_with_styles(self, html_path, output_path, sty_path=None):
|
||||
"""
|
||||
스타일 그루핑이 적용된 HWP 변환
|
||||
스타일 그루핑이 적용된 HWP 변환 (하이브리드 방식)
|
||||
|
||||
✅ 수정: 기존 convert() 로직 + 스타일 적용
|
||||
워크플로우:
|
||||
1. HTML 분석 (역할 분류)
|
||||
2. 기존 convert() 로직으로 HWP 생성 (표/이미지 정상 작동)
|
||||
3. .hwpx로 저장
|
||||
4. HWPX 후처리: 커스텀 스타일 주입
|
||||
"""
|
||||
print("="*60)
|
||||
print("HTML → HWP 변환기 v11 (스타일 그루핑)")
|
||||
print("="*60)
|
||||
|
||||
self.base_path = os.path.dirname(os.path.abspath(html_path))
|
||||
self.is_first_h1 = True
|
||||
self.image_count = 0
|
||||
|
||||
# 1. HTML 파일 읽기
|
||||
# ═══ 1단계: HTML 분석 ═══
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# 2. 스타일 분석
|
||||
from converters.style_analyzer import StyleAnalyzer
|
||||
from converters.hwp_style_mapping import HwpStyGenerator
|
||||
|
||||
analyzer = StyleAnalyzer()
|
||||
elements = analyzer.analyze(html_content)
|
||||
html_styles = analyzer.extract_css_styles(html_content)
|
||||
|
||||
print(f"\n📊 분석 결과: {len(elements)}개 요소")
|
||||
print(f" 🔧 HTML 전처리 중...")
|
||||
print(f" 📄 분석 완료: {len(elements)}개 요소")
|
||||
for role, count in analyzer.get_role_summary().items():
|
||||
print(f" {role}: {count}")
|
||||
|
||||
# 3. 스타일 매핑 생성
|
||||
sty_gen = HwpStyGenerator()
|
||||
sty_gen.update_from_html(html_styles)
|
||||
self.style_map = sty_gen.apply_to_hwp(self.hwp) # Dict[str, HwpStyle]
|
||||
self.sty_gen = sty_gen # 나중에 사용
|
||||
# ═══ 2단계: 기존 convert() 로직으로 HWP 생성 ═══
|
||||
# (표/이미지/머리말/꼬리말 모두 정상 작동)
|
||||
self.convert(html_path, output_path)
|
||||
|
||||
# 4. ★ 기존 convert() 로직 그대로 사용 ★
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
# ═══ 3단계: .hwpx로 다시 저장 ═══
|
||||
hwpx_path = output_path.replace('.hwp', '.hwpx')
|
||||
if not hwpx_path.endswith('.hwpx'):
|
||||
hwpx_path = output_path + 'x'
|
||||
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
full_title = title_tag.get_text(strip=True)
|
||||
footer_title = full_title.split(':')[0].strip()
|
||||
else:
|
||||
footer_title = ""
|
||||
# HWP 다시 열어서 HWPX로 저장
|
||||
self.hwp.Open(output_path)
|
||||
self.hwp.SaveAs(hwpx_path, "HWPX")
|
||||
self.hwp.Clear(1) # 문서 닫기
|
||||
|
||||
self.hwp.FileNew()
|
||||
self._setup_page()
|
||||
self._create_footer(footer_title)
|
||||
print(f"\n 📦 HWPX 변환: {hwpx_path}")
|
||||
|
||||
raw = soup.find(id='raw-container')
|
||||
if raw:
|
||||
cover = raw.find(id='box-cover')
|
||||
if cover:
|
||||
print(" → 표지")
|
||||
for ch in cover.children:
|
||||
self._process(ch)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
# ═══ 4단계: HWPX 후처리 - 커스텀 스타일 주입 ═══
|
||||
try:
|
||||
from converters.hwpx_style_injector import inject_styles_to_hwpx
|
||||
inject_styles_to_hwpx(hwpx_path, elements)
|
||||
print(f" ✅ 스타일 주입 완료")
|
||||
|
||||
toc = raw.find(id='box-toc')
|
||||
if toc:
|
||||
print(" → 목차")
|
||||
self.is_first_h1 = True
|
||||
self._underline_box("목 차", 20, '#008000')
|
||||
self.hwp.BreakPara()
|
||||
self.hwp.BreakPara()
|
||||
self._insert_list(toc.find('ul') or toc)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
|
||||
summary = raw.find(id='box-summary')
|
||||
if summary:
|
||||
print(" → 요약")
|
||||
self.is_first_h1 = True
|
||||
self._process(summary)
|
||||
self.hwp.HAction.Run("BreakPage")
|
||||
|
||||
content = raw.find(id='box-content')
|
||||
if content:
|
||||
print(" → 본문")
|
||||
self.is_first_h1 = True
|
||||
self._process(content)
|
||||
else:
|
||||
self._process(soup.find('body') or soup)
|
||||
except Exception as e:
|
||||
print(f" [경고] 스타일 주입 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 5. 저장
|
||||
self.hwp.SaveAs(output_path)
|
||||
print(f"\n✅ 저장: {output_path}")
|
||||
print(f" 이미지: {self.image_count}개 처리")
|
||||
|
||||
|
||||
def _insert_styled_element(self, elem: 'StyledElement'):
|
||||
"""스타일이 지정된 요소 삽입 (수정됨)"""
|
||||
role = elem.role
|
||||
text = elem.text
|
||||
|
||||
# ═══ 특수 요소 처리 ═══
|
||||
|
||||
# 그림
|
||||
if role == 'FIGURE':
|
||||
src = elem.attributes.get('src', '')
|
||||
if src:
|
||||
self._insert_image(src)
|
||||
return
|
||||
|
||||
# 표
|
||||
if role == 'TABLE':
|
||||
self._insert_table_from_element(elem)
|
||||
return
|
||||
|
||||
# 표 셀/캡션은 TABLE에서 처리
|
||||
if role in ['TH', 'TD']:
|
||||
return
|
||||
|
||||
# 빈 텍스트 스킵
|
||||
if not text:
|
||||
return
|
||||
|
||||
# ═══ 텍스트 요소 처리 ═══
|
||||
|
||||
# 번호 제거 (HWP 개요가 자동 생성하면)
|
||||
# clean_text = strip_numbering(text, role) # 필요시 활성화
|
||||
clean_text = text # 일단 원본 유지
|
||||
|
||||
# 1. 스타일 설정 가져오기
|
||||
style_config = self._get_style_config(role)
|
||||
|
||||
# 2. 문단 모양 먼저 적용
|
||||
self._set_para(
|
||||
align=style_config.get('align', 'justify'),
|
||||
lh=style_config.get('line_height', 160),
|
||||
left=style_config.get('indent_left', 0),
|
||||
indent=style_config.get('indent_first', 0),
|
||||
before=style_config.get('space_before', 0),
|
||||
after=style_config.get('space_after', 0)
|
||||
)
|
||||
|
||||
# 3. 글자 모양 적용
|
||||
self._set_font(
|
||||
size=style_config.get('font_size', 11),
|
||||
bold=style_config.get('bold', False),
|
||||
color=style_config.get('color', '#000000')
|
||||
)
|
||||
|
||||
# 4. 텍스트 삽입
|
||||
self.hwp.insert_text(clean_text)
|
||||
|
||||
# 5. 스타일 적용 (F6 목록에서 참조되도록)
|
||||
style_name = self.style_map.get(role)
|
||||
if style_name:
|
||||
# 🆕 ═══ 4-1단계: 표 열 너비 수정 ═══
|
||||
if self.table_widths:
|
||||
try:
|
||||
self.hwp.HAction.Run("MoveLineBegin")
|
||||
self.hwp.HAction.Run("MoveSelLineEnd")
|
||||
self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||
self.hwp.HParameterSet.HStyle.StyleName = style_name
|
||||
self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet)
|
||||
self.hwp.HAction.Run("MoveLineEnd")
|
||||
except:
|
||||
pass # 스타일 없으면 무시
|
||||
from converters.hwpx_table_injector import inject_table_widths
|
||||
inject_table_widths(hwpx_path, self.table_widths)
|
||||
except Exception as e:
|
||||
print(f" [경고] 표 열 너비 수정 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 6. 줄바꿈
|
||||
self.hwp.BreakPara()
|
||||
|
||||
# ═══ 5단계: 최종 출력 ═══
|
||||
# HWPX를 기본 출력으로 사용 (또는 HWP로 재변환)
|
||||
final_output = hwpx_path
|
||||
|
||||
print(f"\n✅ 최종 저장: {final_output}")
|
||||
return final_output
|
||||
|
||||
def _get_style_config(self, role: str) -> dict:
|
||||
"""역할에 따른 스타일 설정 반환"""
|
||||
|
||||
Reference in New Issue
Block a user