# -*- coding: utf-8 -*- """ HTML → HWP 변환기 (기획서 전용) ✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함) ✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원 ✅ process-container (단계별 프로세스) 지원 ✅ badge 스타일 텍스트 변환 ✅ Navy 색상 테마 pip install pyhwpx beautifulsoup4 """ from pyhwpx import Hwp from bs4 import BeautifulSoup import os class Config: """페이지 설정""" PAGE_WIDTH = 210 PAGE_HEIGHT = 297 MARGIN_LEFT = 20 MARGIN_RIGHT = 20 MARGIN_TOP = 20 MARGIN_BOTTOM = 15 HEADER_LEN = 10 FOOTER_LEN = 10 CONTENT_WIDTH = 170 class HtmlToHwpConverter: """HTML → HWP 변환기 (기획서 전용)""" def __init__(self, visible=True): self.hwp = Hwp(visible=visible) self.cfg = Config() self.colors = {} self.is_first_h1 = True # ───────────────────────────────────────────────────────── # 초기화 및 유틸리티 # ───────────────────────────────────────────────────────── def _init_colors(self): """색상 팔레트 초기화 (Navy 계열)""" self.colors = { 'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d 'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282 'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce 'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748 'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568 'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0 'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc 'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0 'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f 'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13 'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b 'white': self.hwp.RGBColor(255, 255, 255), 'black': self.hwp.RGBColor(0, 0, 0), } def _mm(self, mm): """밀리미터를 HWP 단위로 변환""" return self.hwp.MiliToHwpUnit(mm) def _pt(self, pt): """포인트를 HWP 단위로 변환""" return self.hwp.PointToHwpUnit(pt) def _rgb(self, hex_color): """HEX 색상을 RGB로 변환""" c = hex_color.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 _font(self, size=10, color='black', bold=False): """폰트 설정 (색상 이름 사용)""" self.hwp.set_font( FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self.colors.get(color, self.colors['black']) ) def _set_font(self, size=11, bold=False, hex_color='#000000'): """폰트 설정 (HEX 색상 사용)""" self.hwp.set_font( FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(hex_color) ) def _align(self, align): """정렬 설정""" actions = { 'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight', 'justify': 'ParagraphShapeAlignJustify', } if align in actions: self.hwp.HAction.Run(actions[align]) def _para(self, text='', size=10, color='black', bold=False, align='left'): """문단 삽입""" self._align(align) self._font(size, color, bold) if text: self.hwp.insert_text(text) self.hwp.BreakPara() def _exit_table(self): """표 편집 모드 종료""" self.hwp.HAction.Run("Cancel") self.hwp.HAction.Run("CloseEx") self.hwp.HAction.Run("MoveDocEnd") self.hwp.BreakPara() 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) print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm") except Exception as e: print(f"[경고] 페이지 설정 실패: {e}") # ───────────────────────────────────────────────────────── # 머리말 / 꼬리말 (보고서 방식) # ───────────────────────────────────────────────────────── 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, '#4a5568') 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, '#4a5568') 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, '#4a5568') self.hwp.insert_text(header_text) self.hwp.HAction.Run("CloseEx") except Exception as e: print(f" [경고] 구역 머리말: {e}") # ───────────────────────────────────────────────────────── # 셀 배경색 설정 # ───────────────────────────────────────────────────────── def _set_cell_bg(self, color_name): """셀 배경색 설정 (색상 이름)""" self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) pset = self.hwp.HParameterSet.HCellBorderFill pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0) pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white']) pset.FillAttr.WindowsBrush = 1 self.hwp.HAction.Execute("CellBorderFill", pset.HSet) # ───────────────────────────────────────────────────────── # HTML 요소 변환 (기획서 전용) # ───────────────────────────────────────────────────────── def _convert_lead_box(self, elem): """lead-box 변환 (핵심 기조 박스)""" content = elem.find("div") if not content: return text = content.get_text(strip=True) text = ' '.join(text.split()) print(f" → lead-box") self.hwp.create_table(1, 1, treat_as_char=True) self._set_cell_bg('bg-light') self._font(11.5, 'dark-gray', False) self.hwp.insert_text(text) self._exit_table() def _convert_strategy_grid(self, elem): """strategy-grid 변환 (2x2 전략 박스)""" items = elem.find_all(class_="strategy-item") if not items: return print(f" → strategy-grid: {len(items)} items") self.hwp.create_table(2, 2, treat_as_char=True) for i, item in enumerate(items[:4]): if i > 0: self.hwp.HAction.Run("MoveRight") self._set_cell_bg('bg-light') title = item.find(class_="strategy-title") if title: self._font(10, 'primary-navy', True) self.hwp.insert_text(title.get_text(strip=True)) self.hwp.BreakPara() p = item.find("p") if p: self._font(9.5, 'dark-gray', False) self.hwp.insert_text(p.get_text(strip=True)) self._exit_table() def _convert_process_container(self, elem): """process-container 변환 (단계별 프로세스)""" steps = elem.find_all(class_="process-step") if not steps: return print(f" → process-container: {len(steps)} steps") rows = len(steps) self.hwp.create_table(rows, 2, treat_as_char=True) for i, step in enumerate(steps): if i > 0: self.hwp.HAction.Run("MoveRight") # 번호 셀 num = step.find(class_="step-num") self._set_cell_bg('primary-navy') self._font(10, 'white', True) self._align('center') if num: self.hwp.insert_text(num.get_text(strip=True)) self.hwp.HAction.Run("MoveRight") # 내용 셀 content = step.find(class_="step-content") self._set_cell_bg('bg-light') self._font(10.5, 'dark-gray', False) self._align('left') if content: self.hwp.insert_text(content.get_text(strip=True)) self._exit_table() def _convert_data_table(self, table): """data-table 변환 (badge 포함)""" data = [] thead = table.find("thead") if thead: ths = thead.find_all("th") data.append([th.get_text(strip=True) for th in ths]) tbody = table.find("tbody") if tbody: for tr in tbody.find_all("tr"): row = [] for td in tr.find_all("td"): badge = td.find(class_="badge") if badge: badge_class = ' '.join(badge.get('class', [])) badge_text = badge.get_text(strip=True) if 'badge-safe' in badge_class: row.append(f"[✓ {badge_text}]") elif 'badge-caution' in badge_class: row.append(f"[△ {badge_text}]") elif 'badge-risk' in badge_class: row.append(f"[✗ {badge_text}]") else: row.append(f"[{badge_text}]") else: row.append(td.get_text(strip=True)) data.append(row) if not data: return rows = len(data) cols = len(data[0]) if data else 0 print(f" → data-table: {rows}×{cols}") self.hwp.create_table(rows, cols, treat_as_char=True) for row_idx, row in enumerate(data): for col_idx, cell_text in enumerate(row): is_header = (row_idx == 0) is_first_col = (col_idx == 0 and not is_header) is_safe = '[✓' in str(cell_text) is_caution = '[△' in str(cell_text) is_risk = '[✗' in str(cell_text) if is_header: self._set_cell_bg('primary-navy') self._font(9, 'white', True) elif is_first_col: self._set_cell_bg('bg-light') self._font(9.5, 'primary-navy', True) elif is_safe: self._font(9.5, 'badge-safe', True) elif is_caution: self._font(9.5, 'badge-caution', True) elif is_risk: self._font(9.5, 'badge-risk', True) else: self._font(9.5, 'dark-gray', False) self._align('center') self.hwp.insert_text(str(cell_text)) if not (row_idx == rows - 1 and col_idx == cols - 1): self.hwp.HAction.Run("MoveRight") self._exit_table() def _convert_qa_grid(self, elem): """qa-grid 변환 (Q&A 2단 박스)""" items = elem.find_all(class_="qa-item") if not items: return print(f" → qa-grid: {len(items)} items") self.hwp.create_table(1, 2, treat_as_char=True) for i, item in enumerate(items[:2]): if i > 0: self.hwp.HAction.Run("MoveRight") self._set_cell_bg('bg-light') text = item.get_text(strip=True) strong = item.find("strong") if strong: q_text = strong.get_text(strip=True) a_text = text.replace(q_text, '').strip() self._font(9.5, 'primary-navy', True) self.hwp.insert_text(q_text) self.hwp.BreakPara() self._font(9.5, 'dark-gray', False) self.hwp.insert_text(a_text) else: self._font(9.5, 'dark-gray', False) self.hwp.insert_text(text) self._exit_table() def _convert_bottom_box(self, elem): """bottom-box 변환 (핵심 결론 박스)""" left = elem.find(class_="bottom-left") right = elem.find(class_="bottom-right") if not left or not right: return left_text = ' '.join(left.get_text().split()) right_text = right.get_text(strip=True) print(f" → bottom-box") self.hwp.create_table(1, 2, treat_as_char=True) # 좌측 (Navy 배경) self._set_cell_bg('primary-navy') self._font(10.5, 'white', True) self._align('center') self.hwp.insert_text(left_text) self.hwp.HAction.Run("MoveRight") # 우측 (연한 배경) self._set_cell_bg('bg-light') self._font(10.5, 'primary-navy', True) self._align('center') self.hwp.insert_text(right_text) self._exit_table() def _convert_section(self, section): """section 변환""" title = section.find(class_="section-title") if title: self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True) strategy_grid = section.find(class_="strategy-grid") if strategy_grid: self._convert_strategy_grid(strategy_grid) process = section.find(class_="process-container") if process: self._convert_process_container(process) table = section.find("table", class_="data-table") if table: self._convert_data_table(table) ul = section.find("ul") if ul: for li in ul.find_all("li", recursive=False): keyword = li.find(class_="keyword") if keyword: kw_text = keyword.get_text(strip=True) full = li.get_text(strip=True) rest = full.replace(kw_text, '', 1).strip() self._font(10.5, 'primary-navy', True) self.hwp.insert_text(" • " + kw_text + " ") self._font(10.5, 'dark-gray', False) self.hwp.insert_text(rest) self.hwp.BreakPara() else: self._para(" • " + li.get_text(strip=True), 10.5, 'dark-gray') qa_grid = section.find(class_="qa-grid") if qa_grid: self._convert_qa_grid(qa_grid) self._para() def _convert_sheet(self, sheet, is_first_page=False, footer_title=""): """한 페이지(sheet) 변환""" # 첫 페이지에서만 머리말/꼬리말 설정 if is_first_page: # 머리말: page-header에서 텍스트 추출 header = sheet.find(class_="page-header") if header: left = header.find(class_="header-left") right = header.find(class_="header-right") # 우측 텍스트 사용 (부서명 등) header_text = right.get_text(strip=True) if right else "" if header_text: self._create_header(header_text) # 꼬리말: 제목 + 페이지번호 self._create_footer(footer_title) # 대제목 title = sheet.find(class_="header-title") if title: title_text = title.get_text(strip=True) if '[첨부]' in title_text: self._para(title_text, 15, 'primary-navy', True, 'left') self._font(10, 'secondary-navy', False) self._align('left') self.hwp.insert_text("─" * 60) self.hwp.BreakPara() else: self._para(title_text, 23, 'primary-navy', True, 'center') self._font(10, 'secondary-navy', False) self._align('center') self.hwp.insert_text("━" * 45) self.hwp.BreakPara() self._para() # 리드 박스 lead_box = sheet.find(class_="lead-box") if lead_box: self._convert_lead_box(lead_box) self._para() # 섹션들 for section in sheet.find_all(class_="section"): self._convert_section(section) # 하단 박스 bottom_box = sheet.find(class_="bottom-box") if bottom_box: self._para() self._convert_bottom_box(bottom_box) # ───────────────────────────────────────────────────────── # 메인 변환 함수 # ───────────────────────────────────────────────────────── def convert(self, html_path, output_path): """HTML → HWP 변환 실행""" print("=" * 60) print("HTML → HWP 변환기 (기획서 전용)") print(" ✓ 머리말/꼬리말: 보고서 방식") print(" ✓ Navy 테마, 기획서 요소") print("=" * 60) print(f"\n[입력] {html_path}") 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._init_colors() self._setup_page() # 페이지별 변환 sheets = soup.find_all(class_="sheet") total = len(sheets) print(f"[변환] 총 {total} 페이지\n") for i, sheet in enumerate(sheets, 1): print(f"[{i}/{total}] 페이지 처리 중...") self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title) if i < total: self.hwp.HAction.Run("BreakPage") # 저장 self.hwp.SaveAs(output_path) print(f"\n✅ 저장 완료: {output_path}") def close(self): """HWP 종료""" try: self.hwp.Quit() except: pass def main(): """메인 실행""" html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html" output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp" print("=" * 60) print("HTML → HWP 변환기 (기획서)") print("=" * 60) print() try: converter = HtmlToHwpConverter(visible=True) converter.convert(html_path, output_path) print("\n" + "=" * 60) print("✅ 변환 완료!") print("=" * 60) input("\nEnter를 누르면 HWP가 닫힙니다...") converter.close() except FileNotFoundError: print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}") print("경로를 확인해주세요.") except Exception as e: print(f"\n[에러] {e}") import traceback traceback.print_exc() if __name__ == "__main__": main()