# -*- 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 = 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 후처리 - 커스텀 스타일 주입 ═══ try: from converters.hwpx_style_injector import inject_styles_to_hwpx inject_styles_to_hwpx(hwpx_path, elements) print(f" ✅ 스타일 주입 완료") except Exception as e: print(f" [경고] 스타일 주입 실패: {e}") import traceback traceback.print_exc() # 🆕 ═══ 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()