Files
_Geulbeot/03. Code/geulbeot_10th/converters/html_to_hwp.py

1115 lines
46 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
from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx
# 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()
# ═══════════════════════════════════════════════════════════════
# 표 너비 파싱 유틸리티 (🆕 추가)
# ═══════════════════════════════════════════════════════════════
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)
self.cfg = Config()
self.sp = StyleParser()
self.base_path = ""
self.is_first_h1 = True
self.image_count = 0
self.table_widths = [] # 🆕 표 열 너비 정보 저장용
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):
"""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
txt = cell.get_text(strip=True)
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
# 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(col_widths) < max_cols:
col_widths.append(None)
rc = len(rows_data)
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
txt = row[ci] if ci < len(row) else ""
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")
# ═══ 6. 표 편집 종료 ═══
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
self.table_widths = [] # 🆕 표 열 너비 초기화
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 변환 (하이브리드 방식)
워크플로우:
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))
# ═══ 1단계: HTML 분석 ═══
with open(html_path, 'r', encoding='utf-8') as f:
html_content = f.read()
analyzer = StyleAnalyzer()
elements = analyzer.analyze(html_content)
print(f" 🔧 HTML 전처리 중...")
print(f" 📄 분석 완료: {len(elements)}개 요소")
for role, count in analyzer.get_role_summary().items():
print(f" {role}: {count}")
# ═══ 2단계: 기존 convert() 로직으로 HWP 생성 ═══
# (표/이미지/머리말/꼬리말 모두 정상 작동)
self.convert(html_path, output_path)
# ═══ 3단계: .hwpx로 다시 저장 ═══
hwpx_path = output_path.replace('.hwp', '.hwpx')
if not hwpx_path.endswith('.hwpx'):
hwpx_path = output_path + 'x'
# HWP 다시 열어서 HWPX로 저장
self.hwp.Open(output_path)
self.hwp.SaveAs(hwpx_path, "HWPX")
self.hwp.Clear(1) # 문서 닫기
print(f"\n 📦 HWPX 변환: {hwpx_path}")
# ═══ 4단계: HWPX 후처리 - 스킵 (convert에서 이미 완성) ═══
print(f" ⏭️ 스타일 후처리 스킵 (convert 결과 유지)")
# 🆕 ═══ 4-1단계: 표 열 너비 수정 ═══
if self.table_widths:
try:
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()
# ═══ 5단계: 최종 출력 ═══
# HWPX를 기본 출력으로 사용 (또는 HWP로 재변환)
final_output = hwpx_path
print(f"\n✅ 최종 저장: {final_output}")
return final_output
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()