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

1013 lines
42 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 -*-
"""
HTML → HWP 변환기 v11
✅ 이미지: sizeoption=0 (원본 크기) 또는 width/height 지정
✅ 페이지번호: ctrl 코드 방식으로 수정
✅ 나머지는 v10 유지
pip install pyhwpx beautifulsoup4 pillow
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup, NavigableString
import os, re
# 스타일 그루핑 시스템 추가
from converters.style_analyzer import StyleAnalyzer, StyledElement
from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME
# PIL 선택적 import (이미지 크기 확인용)
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
print("[알림] PIL 없음 - 이미지 원본 크기로 삽입")
class Config:
MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15
HEADER_LEN, FOOTER_LEN = 10, 10
MAX_IMAGE_WIDTH = 150 # mm (최대 이미지 너비)
ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가
class StyleParser:
def __init__(self):
self.style_map = {} # 스타일 매핑 (역할 → HwpStyle)
self.sty_gen = None # 스타일 생성기
self.class_styles = {
'h1': {'font-size': '20pt', 'color': '#008000'},
'h2': {'font-size': '16pt', 'color': '#03581d'},
'h3': {'font-size': '13pt', 'color': '#228B22'},
'p': {'font-size': '11pt', 'color': '#333333'},
'li': {'font-size': '11pt', 'color': '#333333'},
'th': {'font-size': '9pt', 'color': '#006400'},
'td': {'font-size': '9.5pt', 'color': '#333333'},
'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'},
'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'},
'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'},
}
def get_element_style(self, elem):
style = {}
tag = elem.name if hasattr(elem, 'name') else None
if tag and tag in self.class_styles: style.update(self.class_styles[tag])
for cls in elem.get('class', []) if hasattr(elem, 'get') else []:
if cls in self.class_styles: style.update(self.class_styles[cls])
return style
def parse_size(self, s):
m = re.search(r'([\d.]+)', str(s)) if s else None
return float(m.group(1)) if m else 11
def parse_color(self, c):
if not c: return '#000000'
c = str(c).strip().lower()
if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper()
m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c)
return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000'
def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900']
# ═══════════════════════════════════════════════════════════════
# 번호 제거 유틸리티
# ═══════════════════════════════════════════════════════════════
NUMBERING_PATTERNS = {
'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → ""
'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → ""
'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → ""
'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → ""
'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → ""
'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → ""
'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → ""
'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → ""
}
def strip_numbering(text: str, role: str) -> str:
"""
역할에 따라 텍스트 앞의 번호/기호 제거
HWP 개요 기능이 번호를 자동 생성하므로 중복 방지
"""
if not text:
return text
pattern = NUMBERING_PATTERNS.get(role)
if pattern:
return pattern.sub('', text).strip()
return text.strip()
class HtmlToHwpConverter:
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.cfg = Config()
self.sp = StyleParser()
self.base_path = ""
self.is_first_h1 = True
self.image_count = 0
self.style_map = {} # 역할 → 스타일 이름 매핑
self.sty_path = None # .sty 파일 경로
def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm)
def _pt(self, pt): return self.hwp.PointToHwpUnit(pt)
def _rgb(self, c):
c = c.lstrip('#')
return self.hwp.RGBColor(int(c[0:2],16), int(c[2:4],16), int(c[4:6],16)) if len(c)>=6 else self.hwp.RGBColor(0,0,0)
def _setup_page(self):
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
s = self.hwp.HParameterSet.HSecDef
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
self.hwp.HAction.Execute("PageSetup", s.HSet)
except: pass
def _create_header(self, right_text=""):
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
try:
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
if right_text:
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말: {e}")
# ═══════════════════════════════════════════════════════════════
# 꼬리말 - 페이지 번호 (수정)
# ═══════════════════════════════════════════════════════════════
def _create_footer(self, left_text=""):
print(f" → 꼬리말: {left_text}")
# 1. 꼬리말 열기
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
# 2. 좌측 정렬 + 제목 8pt
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
self._set_font(8, False, '#666666')
self.hwp.insert_text(left_text)
# 3. 꼬리말 닫기
self.hwp.HAction.Run("CloseEx")
# 4. 쪽번호 (우측 하단)
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
def _new_section_with_header(self, header_text):
"""새 구역 생성 후 머리말 설정"""
print(f" → 새 구역 머리말: {header_text}")
try:
self.hwp.HAction.Run("BreakSection")
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
self.hwp.insert_text(header_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 구역 머리말: {e}")
# 스타일 적용 관련 (🆕 NEW)
def _load_style_template(self, sty_path: str):
"""
.sty 스타일 템플릿 로드
HWP에서 스타일 불러오기 기능 사용
"""
if not os.path.exists(sty_path):
print(f" [경고] 스타일 파일 없음: {sty_path}")
return False
try:
# HWP 스타일 불러오기
self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet)
self.hwp.HParameterSet.HStyleTemplate.filename = sty_path
self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet)
print(f" ✅ 스타일 템플릿 로드: {sty_path}")
return True
except Exception as e:
print(f" [경고] 스타일 로드 실패: {e}")
return False
def _apply_style_by_name(self, style_name: str):
"""
현재 문단에 스타일 이름으로 적용
텍스트 삽입 후 호출
"""
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 Exception as e:
print(f" [경고] 스타일 적용 실패 '{style_name}': {e}")
def _build_dynamic_style_map(self, elements: list):
"""HTML 분석 결과 기반 동적 스타일 매핑 생성 (숫자)"""
roles = set(elem.role for elem in elements)
# 제목 역할 정렬 (H1, H2, H3...)
title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()],
key=lambda x: int(x[1:]))
# 기타 역할
other_roles = [r for r in roles if r not in title_roles]
# 순차 할당 (개요 1~10)
self.style_map = {}
style_num = 1
for role in title_roles:
if style_num <= 10:
self.style_map[role] = style_num
style_num += 1
for role in other_roles:
if style_num <= 10:
self.style_map[role] = style_num
style_num += 1
print(f" 📝 동적 스타일 매핑: {self.style_map}")
return self.style_map
def _set_font(self, size=11, bold=False, color='#000000'):
self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color))
def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0):
acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter',
'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'}
if align in acts: self.hwp.HAction.Run(acts[align])
try:
self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet)
p = self.hwp.HParameterSet.HParaShape
p.LineSpaceType, p.LineSpacing = 0, lh
p.LeftMargin = self._mm(left)
p.IndentMargin = self._mm(indent)
p.SpaceBeforePara = self._pt(before)
p.SpaceAfterPara = self._pt(after)
p.BreakNonLatinWord = 0
self.hwp.HAction.Execute("ParagraphShape", p.HSet)
except: pass
def _set_cell_bg(self, color):
try:
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
p = self.hwp.HParameterSet.HCellBorderFill
p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
p.FillAttr.WinBrushHatchColor = self._rgb('#000000')
p.FillAttr.WinBrushFaceColor = self._rgb(color)
p.FillAttr.WindowsBrush = 1
self.hwp.HAction.Execute("CellBorderFill", p.HSet)
except: pass
def _underline_box(self, text, size=14, color='#008000'):
try:
self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet)
t = self.hwp.HParameterSet.HTableCreation
t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0
t.WidthValue, t.HeightValue = self._mm(168), self._mm(10)
self.hwp.HAction.Execute("TableCreate", t.HSet)
self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
self.hwp.HParameterSet.HInsertText.Text = text
self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet)
self.hwp.HAction.Run("TableCellBlock")
self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
self.hwp.HParameterSet.HCharShape.Height = self._pt(size)
self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color)
self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet)
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
c = self.hwp.HParameterSet.HCellBorderFill
c.BorderTypeTop = self.hwp.HwpLineType("None")
c.BorderTypeRight = self.hwp.HwpLineType("None")
c.BorderTypeLeft = self.hwp.HwpLineType("None")
self.hwp.HAction.Execute("CellBorder", c.HSet)
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
c = self.hwp.HParameterSet.HCellBorderFill
c.BorderColorBottom = self._rgb(color)
c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm")
self.hwp.HAction.Execute("CellBorder", c.HSet)
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
except:
self._set_font(size, True, color)
self.hwp.insert_text(text)
self.hwp.BreakPara()
def _update_header(self, new_title):
"""머리말 텍스트 업데이트"""
try:
# 기존 머리말 편집 모드로 진입
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 편집 모드
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
# 기존 내용 삭제
self.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
# 새 내용 삽입
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#333333')
self.hwp.insert_text(new_title)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말 업데이트: {e}")
def _insert_heading(self, elem):
lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1
txt = elem.get_text(strip=True)
st = self.sp.get_element_style(elem)
sz = self.sp.parse_size(st.get('font-size','14pt'))
cl = self.sp.parse_color(st.get('color','#008000'))
if lv == 1:
if self.is_first_h1:
self._create_header(txt)
self.is_first_h1 = False
else:
self._new_section_with_header(txt)
self._set_para('left', 130, before=0, after=0)
self._underline_box(txt, sz, cl)
self.hwp.BreakPara()
self._set_para('left', 130, before=0, after=15)
self.hwp.BreakPara()
elif lv == 2:
self._set_para('left', 150, before=20, after=8)
self._set_font(sz, True, cl)
self.hwp.insert_text("" + txt)
self.hwp.BreakPara()
elif lv == 3:
self._set_para('left', 140, left=3, before=12, after=5)
self._set_font(sz, True, cl)
self.hwp.insert_text("" + txt)
self.hwp.BreakPara()
def _insert_paragraph(self, elem):
txt = elem.get_text(strip=True)
if not txt: return
st = self.sp.get_element_style(elem)
sz = self.sp.parse_size(st.get('font-size','11pt'))
cl = self.sp.parse_color(st.get('color','#333333'))
self._set_para('justify', 170, left=0, indent=3, before=0, after=3)
if elem.find(['b','strong']):
for ch in elem.children:
if isinstance(ch, NavigableString):
if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch))
elif ch.name in ['b','strong']:
if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text())
else:
self._set_font(sz, self.sp.is_bold(st), cl)
self.hwp.insert_text(txt)
self.hwp.BreakPara()
def _insert_list(self, elem):
lt = elem.name
for i, li in enumerate(elem.find_all('li', recursive=False)):
st = self.sp.get_element_style(li)
cls = li.get('class', [])
txt = li.get_text(strip=True)
is_toc = any('toc-' in c for c in cls)
if 'toc-lvl-1' in cls: left, bef = 0, 8
elif 'toc-lvl-2' in cls: left, bef = 7, 3
elif 'toc-lvl-3' in cls: left, bef = 14, 1
else: left, bef = 4, 2
pf = f"{i+1}. " if lt == 'ol' else ""
sz = self.sp.parse_size(st.get('font-size','11pt'))
cl = self.sp.parse_color(st.get('color','#333333'))
bd = self.sp.is_bold(st)
if is_toc:
self._set_para('left', 170, left=left, indent=0, before=bef, after=1)
self._set_font(sz, bd, cl)
self.hwp.insert_text(pf + txt)
self.hwp.BreakPara()
else:
self._set_para('justify', 170, left=left, indent=0, before=bef, after=1)
self._set_font(sz, bd, cl)
self.hwp.insert_text(pf)
self.hwp.HAction.Run("ParagraphShapeIndentAtCaret")
self.hwp.insert_text(txt)
self.hwp.BreakPara()
def _insert_table(self, table_elem):
rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0
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
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}
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("")
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("")
rc = len(rows_data)
if rc == 0 or max_cols == 0: return
print(f" 표: {rc}× {max_cols}")
self._set_para('left', 130, before=5, after=0)
self.hwp.create_table(rc, max_cols, treat_as_char=True)
for ri, row in enumerate(rows_data):
for ci in range(max_cols):
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")
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")
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
self._set_para('left', 130, before=5, after=5)
self.hwp.BreakPara()
# ═══════════════════════════════════════════════════════════════
# 이미지 삽입 - sizeoption 수정 ★
# ═══════════════════════════════════════════════════════════════
def _insert_image(self, src, caption=""):
self.image_count += 1
if not src:
return
# 🆕 assets 폴더에서 먼저 찾기
filename = os.path.basename(src)
full_path = os.path.join(self.cfg.ASSETS_PATH, filename)
# assets에 없으면 기존 방식으로 fallback
if not os.path.exists(full_path):
if not os.path.isabs(src):
full_path = os.path.normpath(os.path.join(self.base_path, src))
else:
full_path = src
print(f" 📷 이미지 #{self.image_count}: {filename}")
if not os.path.exists(full_path):
print(f" ❌ 파일 없음: {full_path}")
self._set_font(9, False, '#999999')
self._set_para('center', 130)
self.hwp.insert_text(f"[이미지 없음: {os.path.basename(src)}]")
self.hwp.BreakPara()
return
try:
self._set_para('center', 130, before=5, after=3)
# ★ sizeoption=0: 원본 크기
# ★ sizeoption=2: 지정 크기 (width, height 필요)
# ★ 둘 다 안되면 sizeoption 없이 시도
inserted = False
# 방법 1: sizeoption=0 (원본 크기)
try:
self.hwp.insert_picture(full_path, sizeoption=0)
inserted = True
print(f" ✅ 삽입 성공 (원본 크기)")
except Exception as e1:
pass
# 방법 2: width/height 지정
if not inserted and HAS_PIL:
try:
with Image.open(full_path) as img:
w_px, h_px = img.size
# px → mm 변환 (96 DPI 기준)
w_mm = w_px * 25.4 / 96
h_mm = h_px * 25.4 / 96
# 최대 너비 제한
if w_mm > self.cfg.MAX_IMAGE_WIDTH:
ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm
w_mm = self.cfg.MAX_IMAGE_WIDTH
h_mm = h_mm * ratio
self.hwp.insert_picture(full_path, sizeoption=1,
width=self._mm(w_mm), height=self._mm(h_mm))
inserted = True
print(f" ✅ 삽입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)")
except Exception as e2:
pass
# 방법 3: 기본값
if not inserted:
try:
self.hwp.insert_picture(full_path)
inserted = True
print(f" ✅ 삽입 성공 (기본)")
except Exception as e3:
print(f" ❌ 삽입 실패: {e3}")
self._set_font(9, False, '#FF0000')
self.hwp.insert_text(f"[이미지 오류: {os.path.basename(src)}]")
self.hwp.BreakPara()
if caption and inserted:
self._set_font(9.5, True, '#666666')
self._set_para('center', 130, before=0, after=5)
self.hwp.insert_text(caption)
self.hwp.BreakPara()
except Exception as e:
print(f" ❌ 오류: {e}")
def _insert_table_from_element(self, elem: 'StyledElement'):
"""StyledElement에서 표 삽입 (수정됨)"""
table_data = elem.attributes.get('table_data', {})
if not table_data:
return
rows = table_data.get('rows', [])
if not rows:
return
num_rows = len(rows)
num_cols = max(len(row) for row in rows) if rows else 1
print(f" → 표 삽입: {num_rows}× {num_cols}")
try:
# 1. 표 앞에 문단 설정
self._set_para('left', 130, before=5, after=0)
# 2. 표 생성 (pyhwpx 내장 메서드 사용)
self.hwp.create_table(num_rows, num_cols, treat_as_char=True)
# 3. 셀별 데이터 입력
for row_idx, row in enumerate(rows):
for col_idx, cell in enumerate(row):
# 셀 건너뛰기 (병합된 셀)
if col_idx >= len(row):
self.hwp.HAction.Run("TableRightCell")
continue
cell_text = cell.get('text', '')
is_header = cell.get('is_header', False)
# 헤더 셀 스타일
if is_header:
self._set_cell_bg('#E8F5E9')
self.hwp.HAction.Run("ParagraphShapeAlignCenter")
self._set_font(9, True, '#006400')
else:
self._set_font(9.5, False, '#333333')
# 텍스트 입력
self.hwp.insert_text(cell_text)
# 다음 셀로 (마지막 셀 제외)
if not (row_idx == num_rows - 1 and col_idx == num_cols - 1):
self.hwp.HAction.Run("TableRightCell")
# 4. ★ 표 빠져나오기 (핵심!)
self.hwp.HAction.Run("Cancel") # 선택 해제
self.hwp.HAction.Run("CloseEx") # 표 편집 종료
self.hwp.HAction.Run("MoveDocEnd") # 문서 끝으로
# 5. 표 뒤 문단
self._set_para('left', 130, before=5, after=5)
self.hwp.BreakPara()
print(f" ✅ 표 삽입 완료")
except Exception as e:
print(f" [오류] 표 삽입 실패: {e}")
# 표 안에 갇혔을 경우 탈출 시도
try:
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
except:
pass
def _move_to_cell(self, row: int, col: int):
"""표에서 특정 셀로 이동"""
# 첫 셀로 이동
self.hwp.HAction.Run("TableColBegin")
self.hwp.HAction.Run("TableRowBegin")
# row만큼 아래로
for _ in range(row):
self.hwp.HAction.Run("TableLowerCell")
# col만큼 오른쪽으로
for _ in range(col):
self.hwp.HAction.Run("TableRightCell")
def _apply_cell_style(self, bold=False, bg_color=None, align='left'):
"""현재 셀 스타일 적용"""
# 글자 굵기
if bold:
self.hwp.HAction.Run("CharShapeBold")
# 정렬
align_actions = {
'left': "ParagraphShapeAlignLeft",
'center': "ParagraphShapeAlignCenter",
'right': "ParagraphShapeAlignRight",
}
if align in align_actions:
self.hwp.HAction.Run(align_actions[align])
# 배경색
if bg_color:
self._apply_cell_bg(bg_color)
def _apply_cell_bg(self, color: str):
"""셀 배경색 적용"""
try:
color = color.lstrip('#')
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 단색
self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b)
self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet)
except Exception as e:
print(f" [경고] 셀 배경색: {e}")
def _insert_highlight_box(self, elem):
txt = elem.get_text(strip=True)
if not txt: return
self._set_para('left', 130, before=5, after=0)
self.hwp.create_table(1, 1, treat_as_char=True)
self._set_cell_bg('#E2ECE2')
self._set_font(11, False, '#333333')
self.hwp.insert_text(txt)
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
self._set_para('left', 130, before=0, after=5)
self.hwp.BreakPara()
def _process(self, elem):
if isinstance(elem, NavigableString): return
tag = elem.name
if not tag or tag in ['script','style','template','noscript','head']: return
if tag == 'figure':
img = elem.find('img')
if img:
figcaption = elem.find('figcaption')
caption = figcaption.get_text(strip=True) if figcaption else ""
self._insert_image(img.get('src', ''), caption)
return
if tag == 'img':
self._insert_image(elem.get('src', ''))
return
if tag in ['h1','h2','h3']: self._insert_heading(elem)
elif tag == 'p': self._insert_paragraph(elem)
elif tag == 'table': self._insert_table(elem)
elif tag in ['ul','ol']: self._insert_list(elem)
elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem)
elif tag in ['div','section','article','main','body','html','span']:
for ch in elem.children: self._process(ch)
def convert(self, html_path, output_path):
print("="*60)
print("HTML → HWP 변환기 v11")
print(" ✓ 이미지: sizeoption 수정")
print(" ✓ 페이지번호: 다중 방법 시도")
print("="*60)
self.base_path = os.path.dirname(os.path.abspath(html_path))
self.is_first_h1 = True
self.image_count = 0
print(f"\n입력: {html_path}")
print(f"출력: {output_path}\n")
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
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 = ""
self.hwp.FileNew()
self._setup_page()
self._create_footer(footer_title)
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")
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)
self.hwp.SaveAs(output_path)
print(f"\n✅ 저장: {output_path}")
print(f" 이미지: {self.image_count}개 처리")
def convert_with_styles(self, html_path, output_path, sty_path=None):
"""
스타일 그루핑이 적용된 HWP 변환
✅ 수정: 기존 convert() 로직 + 스타일 적용
"""
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 파일 읽기
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)}개 요소")
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 # 나중에 사용
# 4. ★ 기존 convert() 로직 그대로 사용 ★
soup = BeautifulSoup(html_content, 'html.parser')
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 = ""
self.hwp.FileNew()
self._setup_page()
self._create_footer(footer_title)
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")
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)
# 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:
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 # 스타일 없으면 무시
# 6. 줄바꿈
self.hwp.BreakPara()
def _get_style_config(self, role: str) -> dict:
"""역할에 따른 스타일 설정 반환"""
STYLE_CONFIGS = {
# 표지
'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10},
'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'},
'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'},
# 목차
'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0},
'TOC_H2': {'font_size': 11, 'indent_left': 5},
'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'},
# 제목 계층
'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8},
'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6},
'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5},
'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4},
'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3},
'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9},
'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12},
# 본문
'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3},
'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5},
'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3},
# 표
'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'},
'TD': {'font_size': 9.5, 'align': 'left'},
'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'},
# 그림
'FIGURE': {'align': 'center'},
'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'},
# 기타
'UNKNOWN': {'font_size': 11, 'align': 'left'},
}
return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN'])
def close(self):
try: self.hwp.Quit()
except: pass
def main():
html_path = r"D:\for python\survey_test\output\generated\report.html"
output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp"
sty_path = r"D:\for python\survey_test\교통영향평가스타일.sty" # 🆕 추가
try:
conv = HtmlToHwpConverter(visible=True)
conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가
input("\nEnter를 누르면 HWP가 닫힙니다...")
conv.close()
except Exception as e:
print(f"\n[에러] {e}")
import traceback; traceback.print_exc()
if __name__ == "__main__":
main()