From 71740ce912b0e2357e7c4ce89764b1c29be98634 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 20 Feb 2026 11:42:07 +0900 Subject: [PATCH] =?UTF-8?q?v5:HWPX=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20+=20=ED=91=9C=20=EC=97=B4=EB=84=88?= =?UTF-8?q?=EB=B9=84=20=EB=B3=80=ED=99=98=5F20260127?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 93 ++-- app.py | 22 +- converters/dkdl.py | 37 -- converters/html_to_hwp.py | 412 ++++++++++------ converters/hwpx_style_injector.py | 750 ++++++++++++++++++++++++++++++ converters/hwpx_table_injector.py | 174 +++++++ 6 files changed, 1260 insertions(+), 228 deletions(-) delete mode 100644 converters/dkdl.py create mode 100644 converters/hwpx_style_injector.py create mode 100644 converters/hwpx_table_injector.py diff --git a/README.md b/README.md index 6634aca..e401f77 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -# 글벗 (Geulbeot) v4.0 +# 글벗 (Geulbeot) v5.0 -**AI 기반 문서 자동화 시스템 — GPD 총괄기획실** +**HWPX 스타일 주입 + 표 열 너비 정밀 변환** 다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤 선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다. 생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다. +v5에서는 HWP 변환 품질을 고도화했습니다. 기존 pyhwpx 기본 변환에 HWPX 후처리를 추가하여, +커스텀 스타일 주입과 표 열 너비 정밀 조정이 가능해졌습니다. + --- ## 🏗 아키텍처 (Architecture) @@ -47,7 +50,7 @@ RAG 파이프라인 (9단계) ─── 공통 처리 - 자료 입력 → 9단계 RAG 파이프라인 (파일 변환 → 추출 → 도메인 분석 → 청킹 → 임베딩 → 코퍼스 → 인덱싱 → 콘텐츠 생성 → HTML 조립) - 문서 유형별 생성: 기획서 (Claude 3단계), 보고서 (Gemini 2단계) - AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`) - - HWP 변환: HTML 스타일 분석 → 역할 매핑 → HWPX 생성 + - HWP 변환: 하이브리드 방식 — pyhwpx 기본 생성 → HWPX 스타일 주입 → 표 열 너비 수정 - PDF 변환: WeasyPrint 기반 ### 2. Frontend (순수 JavaScript) @@ -61,14 +64,19 @@ RAG 파이프라인 (9단계) ─── 공통 처리 - **RAG 파이프라인**: 9단계 — 파일 형식 통일 → 텍스트·이미지 추출 → 도메인 분석 → 의미 단위 청킹 → RAG 임베딩 → 코퍼스 구축 → FAISS 인덱싱 → 콘텐츠 생성 → HTML 조립 - **분량 자동 판단**: 5,000자 기준 — 긴 문서는 전체 파이프라인, 짧은 문서는 축약 파이프라인 -- **HWP 변환**: pyhwpx 기반 + v4에서 추가된 스타일 분석기·HWPX 생성기·매핑 모듈 +- **HWP 변환 (v5 하이브리드 방식)**: + 1. HTML 분석 → StyleAnalyzer로 역할 분류 + 2. pyhwpx 기본 변환 (표·이미지·머리말·꼬리말 정상 처리) + 3. HWP → HWPX 변환 + 4. HWPX 후처리 — header.xml에 커스텀 스타일 정의 주입, section*.xml에 역할별 styleIDRef 매핑 + 5. HWPX 후처리 — 표 열 너비 정밀 수정 (px/mm/% → HWPML 단위 변환) ### 4. 주요 시나리오 (Core Scenarios) 1. **기획서 생성**: 텍스트 또는 파일을 입력하면, RAG 분석 후 Claude API가 구조 추출 → 페이지 배치 계획 → 글벗 표준 HTML 기획서를 생성. 1~N페이지 옵션 지원 2. **보고서 생성**: 폴더 경로의 자료들을 RAG 파이프라인으로 분석하고, Gemini API가 섹션별 콘텐츠 초안 → 표지·목차·간지·별첨이 포함된 다페이지 HTML 보고서를 생성 3. **AI 편집**: 생성된 문서를 웹 편집기에서 확인 후, "이 부분을 표로 바꿔줘" 같은 피드백으로 전체 또는 선택 부분을 AI가 수정 -4. **HWP 내보내기**: 글벗 HTML을 스타일 분석기가 요소별 역할을 분류하고, HWP 스타일로 매핑하여 서식이 유지된 HWP 파일로 변환 +4. **HWP 내보내기 (v5 개선)**: 기존 pyhwpx 변환 후 HWPX를 열어 커스텀 스타일(제목 계층·본문·표 등)을 주입하고, 표 열 너비를 원본 HTML과 일치시켜 서식 정확도를 높임 ### 프로세스 플로우 @@ -111,6 +119,7 @@ flowchart TD classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999 + classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 A(["📋 RAG 분석 결과"]):::startEnd B{"문서 유형 선택"}:::decision @@ -128,7 +137,7 @@ flowchart TD K{"출력 형식"}:::decision L["HTML / PDF"]:::exportStyle - M["HWP 변환\n스타일 분석→매핑→생성"]:::exportStyle + M["HWP 변환 (v5 하이브리드)\npyhwpx→스타일주입→표주입"]:::newModule N["PPT 변환\n예정"]:::planned O(["✅ 최종 산출물"]):::startEnd @@ -146,26 +155,47 @@ flowchart TD K -->|"PPT"| N -.-> O ``` +#### HWP 변환 (v5 하이브리드 방식) + +```mermaid +flowchart TD + classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d + classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 + classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c + classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px + + A(["📄 글벗 HTML"]):::startEnd + B["① StyleAnalyzer\nHTML 요소 역할 분류"]:::process + C["② pyhwpx 기본 변환\n표·이미지·머리말 처리"]:::process + D["③ HWP → HWPX 변환"]:::process + E["④ 스타일 주입\nhwpx_style_injector\nheader.xml + section.xml"]:::newModule + F["⑤ 표 열 너비 수정\nhwpx_table_injector\npx/mm/% → HWPML"]:::newModule + G([".hwpx 파일"]):::exportStyle + + A --> B --> C --> D --> E --> F --> G +``` + --- -## 🔄 v3 → v4 변경사항 +## 🔄 v4 → v5 변경사항 -| 영역 | v3 | v4 | +| 영역 | v4 | v5 | |------|------|------| -| app.py | 모든 로직 포함 (579줄) | 라우팅 전담 (291줄) | -| 비즈니스 로직 | app.py 내부 | handlers/ 패키지로 분리 (briefing + report + common) | -| 프롬프트 | prompts/ 공용 1곳 | handlers/*/prompts/ 모듈별 분리 | -| HWP 변환 | pyhwpx 직접 변환만 | + 스타일 분석기·HWPX 생성기·매핑 모듈 추가 | -| 환경설정 | 없음 | .env + api_config.py (python-dotenv) | +| HWP 변환 방식 | pyhwpx 기본 변환만 | 하이브리드: pyhwpx → HWPX 후처리 | +| 스타일 주입 | style_analyzer로 분석만 | + **hwpx_style_injector** — header.xml 스타일 정의, section.xml 매핑 | +| 표 열 너비 | HTML 원본과 불일치 | + **hwpx_table_injector** — px/mm/% → HWPML 정밀 변환 | +| 표 너비 파싱 | 없음 | html_to_hwp.py에 `_parse_width()` 유틸 추가 | +| HWP 출력 형식 | .hwp만 | .hwpx 출력 지원 (mimetype 추가) | +| 테스트 코드 | dkdl.py 잔존 | 삭제 (정리) | --- ## 🗺 상태 및 로드맵 (Status & Roadmap) -- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현 · 현재 버전) +- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현) - **Phase 2**: 문서 생성 — 기획서·보고서 AI 생성 + 글벗 표준 HTML 양식 (🔧 기본 구현) - **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현) -- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석기, HWPX 생성기, 역할→HWP 매핑 (🔧 기본 구현) +- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현 · 현재 버전) - **Phase 5**: 문서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) - **Phase 6**: HWPX 템플릿 관리 — 파싱·시맨틱 매핑·스타일 추출·표 매칭·콘텐츠 주입 (예정) - **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, AI 편집 개선, 도메인 선택기 (예정) @@ -187,8 +217,8 @@ flowchart TD ```bash # 저장소 클론 및 설정 -git clone http://[Gitea주소]/kei/geulbeot-v4.git -cd geulbeot-v4 +git clone http://[Gitea주소]/kei/geulbeot-v5.git +cd geulbeot-v5 # 가상환경 python -m venv venv @@ -222,27 +252,25 @@ python app.py ## 📂 프로젝트 구조 ``` -geulbeot_4th/ +geulbeot_5th/ ├── app.py # Flask 웹 서버 — API 라우팅 ├── api_config.py # .env 환경변수 로더 │ ├── handlers/ # 비즈니스 로직 │ ├── common.py # Claude API 호출, JSON/HTML 추출 -│ ├── briefing/ # 기획서 처리 -│ │ ├── processor.py # 구조추출 → 배치계획 → HTML 생성 -│ │ └── prompts/ # 각 단계별 AI 프롬프트 -│ └── report/ # 보고서 처리 -│ ├── processor.py # RAG 파이프라인 연동 + AI 편집 -│ └── prompts/ +│ ├── briefing/ # 기획서 처리 (구조추출 → 배치 → HTML) +│ └── report/ # 보고서 처리 (RAG 파이프라인 연동) │ ├── converters/ # 변환 엔진 │ ├── pipeline/ # 9단계 RAG 파이프라인 │ │ ├── router.py # 분량 판단 (5,000자 기준) │ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→코퍼스→인덱싱→콘텐츠→HTML -│ ├── style_analyzer.py # HTML 요소 역할 분류 (v4 신규) -│ ├── hwpx_generator.py # HWPX 파일 직접 생성 (v4 신규) -│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑 (v4 신규) -│ ├── html_to_hwp.py # 보고서 → HWP 변환 +│ ├── style_analyzer.py # HTML 요소 역할 분류 +│ ├── hwpx_generator.py # HWPX 파일 직접 생성 +│ ├── hwp_style_mapping.py # 역할 → HWP 스타일 매핑 +│ ├── hwpx_style_injector.py # ★ v5 신규 — HWPX 커스텀 스타일 주입 +│ ├── hwpx_table_injector.py # ★ v5 신규 — HWPX 표 열 너비 정밀 수정 +│ ├── html_to_hwp.py # 보고서 → HWP 변환 (하이브리드 워크플로우) │ └── html_to_hwp_briefing.py # 기획서 → HWP 변환 │ ├── static/ @@ -279,7 +307,7 @@ geulbeot_4th/ - API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완) - HWP 변환: Windows + pyhwpx + 한글 프로그램 필수 - 문서 유형: 기획서·보고서만 구현, 발표자료·사용자 등록 유형 미구현 -- 레거시 잔존: prompts/ 디렉토리, dkdl.py 테스트 코드 +- 레거시 잔존: prompts/ 디렉토리 --- @@ -287,9 +315,9 @@ geulbeot_4th/ | 영역 | 줄 수 | |------|-------| -| Python 전체 | 9,780 | +| Python 전체 | 10,782 (+1,002) | | 프론트엔드 (JS + CSS + HTML) | 3,859 | -| **합계** | **~13,600** | +| **합계** | **~14,600** | --- @@ -300,7 +328,8 @@ geulbeot_4th/ | v1 | Flask + Claude API 기획서 생성기 | | v2 | 웹 편집기 추가 | | v3 | 9단계 RAG 파이프라인 + HWP 변환 | -| **v4** | **코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기** | +| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 | +| **v5** | **HWPX 스타일 주입 + 표 열 너비 정밀 변환** | --- diff --git a/app.py b/app.py index e2de47f..9afcbb3 100644 --- a/app.py +++ b/app.py @@ -229,16 +229,22 @@ def export_hwp(): # 스타일 그루핑 사용 여부 if use_style_grouping: - converter.convert_with_styles(html_path, hwp_path) + final_path = converter.convert_with_styles(html_path, hwp_path) + # HWPX 파일 전송 + return send_file( + final_path, + as_attachment=True, + download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwpx', + mimetype='application/vnd.hancom.hwpx' + ) else: converter.convert(html_path, hwp_path) - - return send_file( - hwp_path, - as_attachment=True, - download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp', - mimetype='application/x-hwp' - ) + return send_file( + hwp_path, + as_attachment=True, + download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp', + mimetype='application/x-hwp' + ) except ImportError as e: return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500 diff --git a/converters/dkdl.py b/converters/dkdl.py deleted file mode 100644 index 1ba3302..0000000 --- a/converters/dkdl.py +++ /dev/null @@ -1,37 +0,0 @@ -from pyhwpx import Hwp - -hwp = Hwp() -hwp.FileNew() - -# HTML 헤딩 레벨 → 한글 기본 스타일 매핑 -heading_style_map = { - 'h1': 1, # 개요 1 - 'h2': 2, # 개요 2 - 'h3': 3, # 개요 3 - 'h4': 4, # 개요 4 - 'h5': 5, # 개요 5 - 'h6': 6, # 개요 6 -} - -def apply_heading_style(text, tag): - """HTML 태그에 맞는 스타일 적용""" - hwp.insert_text(text) - hwp.HAction.Run("MoveLineBegin") - hwp.HAction.Run("MoveSelLineEnd") - - # 해당 태그의 스타일 번호로 적용 - style_num = heading_style_map.get(tag, 0) - if style_num: - hwp.HAction.Run(f"StyleShortcut{style_num}") - - hwp.HAction.Run("MoveLineEnd") - hwp.BreakPara() - -# 테스트 -apply_heading_style("1장 서론", 'h1') -apply_heading_style("1.1 연구의 배경", 'h2') -apply_heading_style("1.1.1 세부 내용", 'h3') -apply_heading_style("본문 텍스트", 'p') # 일반 텍스트 - -hwp.SaveAs(r"D:\test_output.hwp") -print("완료!") \ No newline at end of file diff --git a/converters/html_to_hwp.py b/converters/html_to_hwp.py index 73af99a..d0a9afa 100644 --- a/converters/html_to_hwp.py +++ b/converters/html_to_hwp.py @@ -16,6 +16,7 @@ import os, re # 스타일 그루핑 시스템 추가 from converters.style_analyzer import StyleAnalyzer, StyledElement from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx # PIL 선택적 import (이미지 크기 확인용) @@ -99,6 +100,79 @@ def strip_numbering(text: str, role: str) -> str: return text.strip() +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틸리티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 문자열 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI 기준) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 그대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 본문폭(170mm) 기준 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있으면 px로 간주 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배경색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + class HtmlToHwpConverter: def __init__(self, visible=True): self.hwp = Hwp(visible=visible) @@ -107,6 +181,7 @@ class HtmlToHwpConverter: self.base_path = "" self.is_first_h1 = True self.image_count = 0 + self.table_widths = [] # 🆕 표 열 너비 정보 저장용 self.style_map = {} # 역할 → 스타일 이름 매핑 self.sty_path = None # .sty 파일 경로 @@ -436,43 +511,168 @@ class HtmlToHwpConverter: self.hwp.BreakPara() def _insert_table(self, table_elem): - rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0 + """HTML 테이블 → HWP 표 변환 (내용 기반 열 너비 계산 + HWPX 후처리용 저장)""" + + # ═══ 1. 테이블 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # 열 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데이터 수집 for ri, tr in enumerate(table_elem.find_all('tr')): - row, ci = [], 0 - for cell in tr.find_all(['td','th']): - while (ri,ci) in occupied: row.append(""); ci+=1 + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 건너뛰기 + while (ri, ci) in occupied: + row.append("") + ci += 1 + txt = cell.get_text(strip=True) - cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1)) - cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0} + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 스타일 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 열 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + row.append(txt) + + # 병합 영역 표시 for dr in range(rs): for dc in range(cs): - if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True - for _ in range(cs-1): row.append("") + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") ci += cs + rows_data.append(row) max_cols = max(max_cols, len(row)) + + # 행/열 수 맞추기 for row in rows_data: - while len(row) < max_cols: row.append("") + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) rc = len(rows_data) - if rc == 0 or max_cols == 0: return + if rc == 0 or max_cols == 0: + return + print(f" 표: {rc}행 × {max_cols}열") + # ═══ 2. 열 너비 계산 (내용 길이 기반) ═══ + body_width_mm = 170 # A4 본문 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 열 확인 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 열의 최대 텍스트 길이 계산 (한글=2, 영문/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한글은 2배 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 보장 (8자 이상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 미지정 열들의 총 텍스트 길이 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 남은 너비를 텍스트 길이 비율로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍스트 길이: {col_text_lengths}") + + # 본문 폭 초과 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" 열 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처리용 열 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (기본 방식) ═══ self._set_para('left', 130, before=5, after=0) self.hwp.create_table(rc, max_cols, treat_as_char=True) + # ═══ 5. 셀 내용 입력 ═══ for ri, row in enumerate(rows_data): for ci in range(max_cols): - if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue + # 병합된 셀 건너뛰기 + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + txt = row[ci] if ci < len(row) else "" - hdr = cell_styles.get((ri,ci),{}).get('is_header', False) - if hdr: self._set_cell_bg('#E8F5E9') - self.hwp.HAction.Run("ParagraphShapeAlignCenter") + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배경색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰트 self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') self.hwp.insert_text(str(txt)) - if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight") + + # 다음 셀로 이동 (마지막 셀 제외) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + # ═══ 6. 표 편집 종료 ═══ self.hwp.HAction.Run("Cancel") self.hwp.HAction.Run("CloseEx") self.hwp.HAction.Run("MoveDocEnd") @@ -734,7 +934,8 @@ class HtmlToHwpConverter: self.base_path = os.path.dirname(os.path.abspath(html_path)) self.is_first_h1 = True self.image_count = 0 - + self.table_widths = [] # 🆕 표 열 너비 초기화 + print(f"\n입력: {html_path}") print(f"출력: {output_path}\n") @@ -787,166 +988,75 @@ class HtmlToHwpConverter: def convert_with_styles(self, html_path, output_path, sty_path=None): """ - 스타일 그루핑이 적용된 HWP 변환 + 스타일 그루핑이 적용된 HWP 변환 (하이브리드 방식) - ✅ 수정: 기존 convert() 로직 + 스타일 적용 + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. 기존 convert() 로직으로 HWP 생성 (표/이미지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처리: 커스텀 스타일 주입 """ print("="*60) print("HTML → HWP 변환기 v11 (스타일 그루핑)") print("="*60) self.base_path = os.path.dirname(os.path.abspath(html_path)) - self.is_first_h1 = True - self.image_count = 0 - # 1. HTML 파일 읽기 + # ═══ 1단계: HTML 분석 ═══ with open(html_path, 'r', encoding='utf-8') as f: html_content = f.read() - # 2. 스타일 분석 - from converters.style_analyzer import StyleAnalyzer - from converters.hwp_style_mapping import HwpStyGenerator - analyzer = StyleAnalyzer() elements = analyzer.analyze(html_content) - html_styles = analyzer.extract_css_styles(html_content) - print(f"\n📊 분석 결과: {len(elements)}개 요소") + print(f" 🔧 HTML 전처리 중...") + print(f" 📄 분석 완료: {len(elements)}개 요소") for role, count in analyzer.get_role_summary().items(): print(f" {role}: {count}") - # 3. 스타일 매핑 생성 - sty_gen = HwpStyGenerator() - sty_gen.update_from_html(html_styles) - self.style_map = sty_gen.apply_to_hwp(self.hwp) # Dict[str, HwpStyle] - self.sty_gen = sty_gen # 나중에 사용 + # ═══ 2단계: 기존 convert() 로직으로 HWP 생성 ═══ + # (표/이미지/머리말/꼬리말 모두 정상 작동) + self.convert(html_path, output_path) - # 4. ★ 기존 convert() 로직 그대로 사용 ★ - soup = BeautifulSoup(html_content, 'html.parser') + # ═══ 3단계: .hwpx로 다시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' - title_tag = soup.find('title') - if title_tag: - full_title = title_tag.get_text(strip=True) - footer_title = full_title.split(':')[0].strip() - else: - footer_title = "" + # HWP 다시 열어서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 문서 닫기 - self.hwp.FileNew() - self._setup_page() - self._create_footer(footer_title) + print(f"\n 📦 HWPX 변환: {hwpx_path}") - raw = soup.find(id='raw-container') - if raw: - cover = raw.find(id='box-cover') - if cover: - print(" → 표지") - for ch in cover.children: - self._process(ch) - self.hwp.HAction.Run("BreakPage") + # ═══ 4단계: HWPX 후처리 - 커스텀 스타일 주입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 스타일 주입 완료") - toc = raw.find(id='box-toc') - if toc: - print(" → 목차") - self.is_first_h1 = True - self._underline_box("목 차", 20, '#008000') - self.hwp.BreakPara() - self.hwp.BreakPara() - self._insert_list(toc.find('ul') or toc) - self.hwp.HAction.Run("BreakPage") - - summary = raw.find(id='box-summary') - if summary: - print(" → 요약") - self.is_first_h1 = True - self._process(summary) - self.hwp.HAction.Run("BreakPage") - - content = raw.find(id='box-content') - if content: - print(" → 본문") - self.is_first_h1 = True - self._process(content) - else: - self._process(soup.find('body') or soup) + except Exception as e: + print(f" [경고] 스타일 주입 실패: {e}") + import traceback + traceback.print_exc() - # 5. 저장 - self.hwp.SaveAs(output_path) - print(f"\n✅ 저장: {output_path}") - print(f" 이미지: {self.image_count}개 처리") - - - def _insert_styled_element(self, elem: 'StyledElement'): - """스타일이 지정된 요소 삽입 (수정됨)""" - role = elem.role - text = elem.text - - # ═══ 특수 요소 처리 ═══ - - # 그림 - if role == 'FIGURE': - src = elem.attributes.get('src', '') - if src: - self._insert_image(src) - return - - # 표 - if role == 'TABLE': - self._insert_table_from_element(elem) - return - - # 표 셀/캡션은 TABLE에서 처리 - if role in ['TH', 'TD']: - return - - # 빈 텍스트 스킵 - if not text: - return - - # ═══ 텍스트 요소 처리 ═══ - - # 번호 제거 (HWP 개요가 자동 생성하면) - # clean_text = strip_numbering(text, role) # 필요시 활성화 - clean_text = text # 일단 원본 유지 - - # 1. 스타일 설정 가져오기 - style_config = self._get_style_config(role) - - # 2. 문단 모양 먼저 적용 - self._set_para( - align=style_config.get('align', 'justify'), - lh=style_config.get('line_height', 160), - left=style_config.get('indent_left', 0), - indent=style_config.get('indent_first', 0), - before=style_config.get('space_before', 0), - after=style_config.get('space_after', 0) - ) - - # 3. 글자 모양 적용 - self._set_font( - size=style_config.get('font_size', 11), - bold=style_config.get('bold', False), - color=style_config.get('color', '#000000') - ) - - # 4. 텍스트 삽입 - self.hwp.insert_text(clean_text) - - # 5. 스타일 적용 (F6 목록에서 참조되도록) - style_name = self.style_map.get(role) - if style_name: + # 🆕 ═══ 4-1단계: 표 열 너비 수정 ═══ + if self.table_widths: try: - self.hwp.HAction.Run("MoveLineBegin") - self.hwp.HAction.Run("MoveSelLineEnd") - self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) - self.hwp.HParameterSet.HStyle.StyleName = style_name - self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) - self.hwp.HAction.Run("MoveLineEnd") - except: - pass # 스타일 없으면 무시 + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [경고] 표 열 너비 수정 실패: {e}") + import traceback + traceback.print_exc() - # 6. 줄바꿈 - self.hwp.BreakPara() - + # ═══ 5단계: 최종 출력 ═══ + # HWPX를 기본 출력으로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output def _get_style_config(self, role: str) -> dict: """역할에 따른 스타일 설정 반환""" diff --git a/converters/hwpx_style_injector.py b/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/converters/hwpx_style_injector.py @@ -0,0 +1,750 @@ +""" +HWPX 스타일 주입기 +pyhwpx로 생성된 HWPX 파일에 커스텀 스타일을 후처리로 주입 + +워크플로우: +1. HWPX 압축 해제 +2. header.xml에 커스텀 스타일 정의 추가 +3. section*.xml에서 역할별 styleIDRef 매핑 +4. 다시 압축 +""" + +import os +import re +import zipfile +import shutil +import tempfile +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass + + +@dataclass +class StyleDefinition: + """스타일 정의""" + id: int + name: str + font_size: int # hwpunit (pt * 100) + font_bold: bool + font_color: str # #RRGGBB + align: str # LEFT, CENTER, RIGHT, JUSTIFY + line_spacing: int # percent (160 = 160%) + indent_left: int # hwpunit + indent_first: int # hwpunit + space_before: int # hwpunit + space_after: int # hwpunit + outline_level: int = -1 # 🆕 개요 수준 (-1=없음, 0=1수준, 1=2수준, ...) + + +# 역할 → 스타일 정의 매핑 +ROLE_STYLES: Dict[str, StyleDefinition] = { + # 🆕 개요 문단 (자동 번호 매기기!) + 'H1': StyleDefinition( + id=101, name='제1장 제목', font_size=2200, font_bold=True, + font_color='#006400', align='CENTER', line_spacing=200, + indent_left=0, indent_first=0, space_before=400, space_after=200, + outline_level=0 # 🆕 제^1장 + ), + 'H2': StyleDefinition( + id=102, name='1.1 제목', font_size=1500, font_bold=True, + font_color='#03581d', align='LEFT', line_spacing=200, + indent_left=0, indent_first=0, space_before=300, space_after=100, + outline_level=1 # 🆕 ^1.^2 + ), + 'H3': StyleDefinition( + id=103, name='1.1.1 제목', font_size=1400, font_bold=True, + font_color='#228B22', align='LEFT', line_spacing=200, + indent_left=500, indent_first=0, space_before=200, space_after=100, + outline_level=2 # 🆕 ^1.^2.^3 + ), + 'H4': StyleDefinition( + id=104, name='가. 제목', font_size=1300, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=200, + indent_left=1000, indent_first=0, space_before=150, space_after=50, + outline_level=3 # 🆕 ^4. + ), + 'H5': StyleDefinition( + id=105, name='1) 제목', font_size=1200, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=200, + indent_left=1500, indent_first=0, space_before=100, space_after=50, + outline_level=4 # 🆕 ^5) + ), + 'H6': StyleDefinition( + id=106, name='가) 제목', font_size=1150, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=200, + indent_left=2000, indent_first=0, space_before=100, space_after=50, + outline_level=5 # 🆕 ^6) + ), + 'H7': StyleDefinition( + id=115, name='① 제목', font_size=1100, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=200, + indent_left=2300, indent_first=0, space_before=100, space_after=50, + outline_level=6 # 🆕 ^7 (원문자) + ), + # 본문 스타일 (개요 아님) + 'BODY': StyleDefinition( + id=107, name='○본문', font_size=1100, font_bold=False, + font_color='#000000', align='JUSTIFY', line_spacing=200, + indent_left=1500, indent_first=0, space_before=0, space_after=0 + ), + 'LIST_ITEM': StyleDefinition( + id=108, name='●본문', font_size=1050, font_bold=False, + font_color='#000000', align='JUSTIFY', line_spacing=200, + indent_left=2500, indent_first=0, space_before=0, space_after=0 + ), + 'TABLE_CAPTION': StyleDefinition( + id=109, name='<표 제목>', font_size=1100, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=130, + indent_left=0, indent_first=0, space_before=200, space_after=100 + ), + 'FIGURE_CAPTION': StyleDefinition( + id=110, name='<그림 제목>', font_size=1100, font_bold=True, + font_color='#000000', align='CENTER', line_spacing=130, + indent_left=0, indent_first=0, space_before=100, space_after=200 + ), + 'COVER_TITLE': StyleDefinition( + id=111, name='표지제목', font_size=2800, font_bold=True, + font_color='#1a365d', align='CENTER', line_spacing=150, + indent_left=0, indent_first=0, space_before=0, space_after=200 + ), + 'COVER_SUBTITLE': StyleDefinition( + id=112, name='표지부제', font_size=1800, font_bold=False, + font_color='#2d3748', align='CENTER', line_spacing=150, + indent_left=0, indent_first=0, space_before=0, space_after=100 + ), + 'TOC_1': StyleDefinition( + id=113, name='목차1수준', font_size=1200, font_bold=True, + font_color='#000000', align='LEFT', line_spacing=180, + indent_left=0, indent_first=0, space_before=100, space_after=50 + ), + 'TOC_2': StyleDefinition( + id=114, name='목차2수준', font_size=1100, font_bold=False, + font_color='#000000', align='LEFT', line_spacing=180, + indent_left=500, indent_first=0, space_before=0, space_after=0 + ), +} + +# ⚠️ 개요 자동 번호 기능 활성화! +# idRef="0"은 numbering id=1을 참조하므로, 해당 패턴을 교체하면 동작함 + + +class HwpxStyleInjector: + """HWPX 스타일 주입기""" + + def __init__(self): + self.temp_dir: Optional[Path] = None + self.role_to_style_id: Dict[str, int] = {} + self.role_to_para_id: Dict[str, int] = {} # 🆕 + self.role_to_char_id: Dict[str, int] = {} # 🆕 + self.next_char_id = 0 + self.next_para_id = 0 + self.next_style_id = 0 + + def _find_max_ids(self): + """기존 스타일 교체: 바탕글(id=0)만 유지, 나머지는 우리 스타일로 교체""" + header_path = self.temp_dir / "Contents" / "header.xml" + if not header_path.exists(): + self.next_char_id = 1 + self.next_para_id = 1 + self.next_style_id = 1 + return + + content = header_path.read_text(encoding='utf-8') + + # 🆕 기존 "본문", "개요 1~10" 등 스타일 제거 (id=1~22) + # 바탕글(id=0)만 유지! + + # style id=1~30 제거 (바탕글 제외) + content = re.sub(r'\s*', '', content) + + # itemCnt는 나중에 _update_item_counts에서 자동 업데이트됨 + + # 파일 저장 + header_path.write_text(content, encoding='utf-8') + print(f" [INFO] 기존 스타일(본문, 개요1~10 등) 제거 완료") + + # charPr, paraPr은 기존 것 다음부터 (참조 깨지지 않도록) + char_ids = [int(m) for m in re.findall(r' str: + """ + HWPX 파일에 커스텀 스타일 주입 + + Args: + hwpx_path: 원본 HWPX 파일 경로 + role_positions: 역할별 위치 정보 {role: [(section_idx, para_idx), ...]} + + Returns: + 수정된 HWPX 파일 경로 + """ + print(f"\n🎨 HWPX 스타일 주입 시작...") + print(f" 입력: {hwpx_path}") + + # 1. 임시 디렉토리에 압축 해제 + self.temp_dir = Path(tempfile.mkdtemp(prefix='hwpx_inject_')) + print(f" 임시 폴더: {self.temp_dir}") + + try: + with zipfile.ZipFile(hwpx_path, 'r') as zf: + zf.extractall(self.temp_dir) + + # 압축 해제 직후 section 파일 크기 확인 + print(f" [DEBUG] After unzip:") + for sec in ['section0.xml', 'section1.xml', 'section2.xml']: + sec_path = self.temp_dir / "Contents" / sec + if sec_path.exists(): + print(f" [DEBUG] {sec} size: {sec_path.stat().st_size} bytes") + + # 🆕 기존 최대 ID 찾기 (연속 ID 할당을 위해) + self._find_max_ids() + print(f" [DEBUG] Starting IDs: char={self.next_char_id}, para={self.next_para_id}, style={self.next_style_id}") + + # 2. header.xml에 스타일 정의 추가 + used_roles = set(role_positions.keys()) + self._inject_header_styles(used_roles) + + # 3. section*.xml에 styleIDRef 매핑 + self._inject_section_styles(role_positions) + + # 4. 다시 압축 + output_path = hwpx_path # 원본 덮어쓰기 + self._repack_hwpx(output_path) + + print(f" ✅ 스타일 주입 완료: {output_path}") + return output_path + + finally: + # 임시 폴더 정리 + if self.temp_dir and self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + + def _inject_header_styles(self, used_roles: set): + """header.xml에 스타일 정의 추가 (모든 ROLE_STYLES 주입)""" + header_path = self.temp_dir / "Contents" / "header.xml" + if not header_path.exists(): + print(" [경고] header.xml 없음") + return + + content = header_path.read_text(encoding='utf-8') + + # 🆕 모든 ROLE_STYLES 주입 (used_roles 무시) + char_props = [] + para_props = [] + styles = [] + + for role, style_def in ROLE_STYLES.items(): + char_id = self.next_char_id + para_id = self.next_para_id + style_id = self.next_style_id + + self.role_to_style_id[role] = style_id + self.role_to_para_id[role] = para_id # 🆕 + self.role_to_char_id[role] = char_id # 🆕 + + # charPr 생성 + char_props.append(self._make_char_pr(char_id, style_def)) + + # paraPr 생성 + para_props.append(self._make_para_pr(para_id, style_def)) + + # style 생성 + styles.append(self._make_style(style_id, style_def.name, para_id, char_id)) + + self.next_char_id += 1 + self.next_para_id += 1 + self.next_style_id += 1 + + if not styles: + print(" [정보] 주입할 스타일 없음") + return + + # charProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(styles) + '\n' + ) + + # 🆕 numbering id=1 패턴 교체 (idRef="0"이 참조하는 기본 번호 모양) + # 이렇게 하면 개요 자동 번호가 "제1장, 1.1, 1.1.1..." 형식으로 동작! + content = self._replace_default_numbering(content) + + # itemCnt 업데이트 + content = self._update_item_counts(content) + + header_path.write_text(content, encoding='utf-8') + print(f" → header.xml 수정 완료 ({len(styles)}개 스타일 추가)") + + def _make_char_pr(self, id: int, style: StyleDefinition) -> str: + """charPr XML 생성 (한 줄로!)""" + color = style.font_color.lstrip('#') + font_id = "1" if style.font_bold else "0" + + return f'' + + def _make_para_pr(self, id: int, style: StyleDefinition) -> str: + """paraPr XML 생성 (한 줄로!)""" + # 개요 문단이면 type="OUTLINE", 아니면 type="NONE" + # idRef="0"은 numbering id=1 (기본 번호 모양)을 참조 + if style.outline_level >= 0: + heading = f'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + def _insert_before_tag(self, content: str, tag: str, insert_text: str) -> str: + """특정 태그 앞에 텍스트 삽입""" + return content.replace(tag, insert_text + tag) + + def _update_item_counts(self, content: str) -> str: + """itemCnt 속성 업데이트""" + # charProperties itemCnt + char_count = content.count(' str: + """numbering id=1의 패턴을 우리 패턴으로 교체""" + # 우리가 원하는 개요 번호 패턴 + new_patterns = [ + {'level': '1', 'format': 'DIGIT', 'pattern': '제^1장'}, + {'level': '2', 'format': 'DIGIT', 'pattern': '^1.^2'}, + {'level': '3', 'format': 'DIGIT', 'pattern': '^1.^2.^3'}, + {'level': '4', 'format': 'HANGUL_SYLLABLE', 'pattern': '^4.'}, + {'level': '5', 'format': 'DIGIT', 'pattern': '^5)'}, + {'level': '6', 'format': 'HANGUL_SYLLABLE', 'pattern': '^6)'}, + {'level': '7', 'format': 'CIRCLED_DIGIT', 'pattern': '^7'}, + ] + + # numbering id="1" 찾기 + match = re.search(r'(]*>)(.*?)()', content, re.DOTALL) + if not match: + print(" [경고] numbering id=1 없음, 교체 건너뜀") + return content + + numbering_content = match.group(2) + + for np in new_patterns: + level = np['level'] + fmt = np['format'] + pattern = np['pattern'] + + # 해당 level의 paraHead 찾아서 교체 + def replace_parahead(m): + tag = m.group(0) + # numFormat 변경 + tag = re.sub(r'numFormat="[^"]*"', f'numFormat="{fmt}"', tag) + # 패턴(텍스트 내용) 변경 + tag = re.sub(r'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + replace_parahead, + numbering_content + ) + + new_content = match.group(1) + numbering_content + match.group(3) + print(" [INFO] numbering id=1 패턴 교체 완료 (제^1장, ^1.^2, ^1.^2.^3...)") + return content.replace(match.group(0), new_content) + + def _adjust_tables(self, content: str) -> str: + """표 셀 크기 자동 조정 + + 1. 행 높이: 최소 800 hwpunit (내용 잘림 방지) + 2. 열 너비: 표 전체 너비를 열 개수로 균등 분배 (또는 첫 열 좁게) + """ + + def adjust_table(match): + tbl = match.group(0) + + # 표 전체 너비 추출 + sz_match = re.search(r' 1 else table_width + + # 행 높이 최소값 설정 + min_height = 800 # 약 8mm + + # 셀 크기 조정 + col_idx = [0] # closure용 + + def adjust_cell_sz(cell_match): + width = int(cell_match.group(1)) + height = int(cell_match.group(2)) + + # 높이 조정 + new_height = max(height, min_height) + + return f'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', adjust_table, content, flags=re.DOTALL) + + def _inject_section_styles(self, role_positions: Dict[str, List[tuple]]): + """section*.xml에 styleIDRef 매핑 (텍스트 매칭 방식)""" + contents_dir = self.temp_dir / "Contents" + + # 🔍 디버그: role_to_style_id 확인 + print(f" [DEBUG] role_to_style_id: {self.role_to_style_id}") + + # section 파일들 찾기 + section_files = sorted(contents_dir.glob("section*.xml")) + print(f" [DEBUG] section files: {[f.name for f in section_files]}") + + total_modified = 0 + + for section_file in section_files: + print(f" [DEBUG] Processing: {section_file.name}") + original_content = section_file.read_text(encoding='utf-8') + print(f" [DEBUG] File size: {len(original_content)} bytes") + + content = original_content # 작업용 복사본 + + # 🆕 머리말/꼬리말 영역 보존 (placeholder로 교체) + header_footer_map = {} + placeholder_idx = 0 + + def save_header_footer(match): + nonlocal placeholder_idx + key = f"__HF_PLACEHOLDER_{placeholder_idx}__" + header_footer_map[key] = match.group(0) + placeholder_idx += 1 + return key + + # 머리말/꼬리말 임시 교체 + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 모든 태그와 내부 텍스트 추출 + para_pattern = r'(]*>)(.*?)()' + + section_modified = 0 + + def replace_style(match): + nonlocal total_modified, section_modified + open_tag = match.group(1) + inner = match.group(2) + close_tag = match.group(3) + + # 텍스트 추출 (태그 제거) + text = re.sub(r'<[^>]+>', '', inner).strip() + if not text: + return match.group(0) + + # 텍스트 앞부분으로 역할 판단 + text_start = text[:50] # 처음 50자로 판단 + + matched_role = None + matched_style_id = None + matched_para_id = None + matched_char_id = None + + # 제목 패턴 매칭 (앞에 특수문자 허용) + # Unicode: ■\u25a0 ▸\u25b8 ◆\u25c6 ▶\u25b6 ●\u25cf ○\u25cb ▪\u25aa ►\u25ba ☞\u261e ★\u2605 ※\u203b ·\u00b7 + prefix = r'^[\u25a0\u25b8\u25c6\u25b6\u25cf\u25cb\u25aa\u25ba\u261e\u2605\u203b\u00b7\s]*' + + # 🆕 FIGURE_CAPTION: "[그림 1-1]", "[그림 1-2]" 등 (가장 먼저 체크!) + # 그림 = \uadf8\ub9bc + if re.match(r'^\[\uadf8\ub9bc\s*[\d-]+\]', text_start): + matched_role = 'FIGURE_CAPTION' + # 🆕 TABLE_CAPTION: "<표 1-1>", "[표 1-1]" 등 + # 표 = \ud45c + elif re.match(r'^[<\[]\ud45c\s*[\d-]+[>\]]', text_start): + matched_role = 'TABLE_CAPTION' + # H1: "제1장", "1 개요" 등 + elif re.match(prefix + r'\uc81c?\s*\d+\uc7a5?\s', text_start) or re.match(prefix + r'[1-9]\s+[\uac00-\ud7a3]', text_start): + matched_role = 'H1' + # H3: "1.1.1 " (H2보다 먼저 체크!) + elif re.match(prefix + r'\d+\.\d+\.\d+\s', text_start): + matched_role = 'H3' + # H2: "1.1 " + elif re.match(prefix + r'\d+\.\d+\s', text_start): + matched_role = 'H2' + # H4: "가. " + elif re.match(prefix + r'[\uac00-\ud7a3]\.\s', text_start): + matched_role = 'H4' + # H5: "1) " + elif re.match(prefix + r'\d+\)\s', text_start): + matched_role = 'H5' + # H6: "(1) " 또는 "가) " + elif re.match(prefix + r'\(\d+\)\s', text_start): + matched_role = 'H6' + elif re.match(prefix + r'[\uac00-\ud7a3]\)\s', text_start): + matched_role = 'H6' + # LIST_ITEM: "○ ", "● ", "• " 등 + elif re.match(r'^[\u25cb\u25cf\u25e6\u2022\u2023\u25b8]\s', text_start): + matched_role = 'LIST_ITEM' + elif re.match(r'^[-\u2013\u2014]\s', text_start): + matched_role = 'LIST_ITEM' + + # 매칭된 역할이 있고 스타일 ID가 있으면 적용 + if matched_role and matched_role in self.role_to_style_id: + matched_style_id = self.role_to_style_id[matched_role] + matched_para_id = self.role_to_para_id[matched_role] + matched_char_id = self.role_to_char_id[matched_role] + elif 'BODY' in self.role_to_style_id and len(text) > 20: + # 긴 텍스트는 본문으로 간주 + matched_role = 'BODY' + matched_style_id = self.role_to_style_id['BODY'] + matched_para_id = self.role_to_para_id['BODY'] + matched_char_id = self.role_to_char_id['BODY'] + + if matched_style_id: + # 1. hp:p 태그의 styleIDRef 변경 + if 'styleIDRef="' in open_tag: + new_open = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{matched_style_id}"', open_tag) + else: + new_open = open_tag.replace(']*charPrIDRef=")[^"]*(")', f'\\g<1>{matched_char_id}\\2', inner) + + # 🆕 4. 개요 문단이면 수동 번호 제거 (자동 번호가 붙으니까!) + if matched_role in ROLE_STYLES and ROLE_STYLES[matched_role].outline_level >= 0: + new_inner = self._remove_manual_numbering(new_inner, matched_role) + + total_modified += 1 + section_modified += 1 + return new_open + new_inner + close_tag + + return match.group(0) + + new_content = re.sub(para_pattern, replace_style, content, flags=re.DOTALL) + + # 🆕 표 크기 자동 조정 + new_content = self._adjust_tables(new_content) + + # 🆕 outlineShapeIDRef를 1로 변경 (우리가 교체한 numbering id=1 사용) + new_content = re.sub( + r'outlineShapeIDRef="[^"]*"', + 'outlineShapeIDRef="1"', + new_content + ) + + + # 🆕 머리말/꼬리말 복원 + for key, original in header_footer_map.items(): + new_content = new_content.replace(key, original) + + print(f" [DEBUG] {section_file.name}: {section_modified} paras modified, content changed: {new_content != original_content}") + + if new_content != original_content: + section_file.write_text(new_content, encoding='utf-8') + print(f" -> {section_file.name} saved") + + print(f" -> Total {total_modified} paragraphs styled") + + def _update_para_style(self, content: str, para_idx: int, style_id: int) -> str: + """특정 인덱스의 문단 styleIDRef 변경""" + # 태그들 찾기 + pattern = r']*>' + matches = list(re.finditer(pattern, content)) + + if para_idx >= len(matches): + return content + + match = matches[para_idx] + old_tag = match.group(0) + + # styleIDRef 속성 변경 또는 추가 + if 'styleIDRef=' in old_tag: + new_tag = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{style_id}"', old_tag) + else: + # 속성 추가 + new_tag = old_tag.replace(' str: + """🆕 개요 문단에서 수동 번호 제거 (자동 번호가 붙으니까!) + + HTML에서 "제1장 DX 개요" → "DX 개요" (자동으로 "제1장" 붙음) + HTML에서 "1.1 측량 DX" → "측량 DX" (자동으로 "1.1" 붙음) + """ + # 역할별 번호 패턴 + patterns = { + 'H1': r'^(제\s*\d+\s*장\s*)', # "제1장 " → 제거 + 'H2': r'^(\d+\.\d+\s+)', # "1.1 " → 제거 + 'H3': r'^(\d+\.\d+\.\d+\s+)', # "1.1.1 " → 제거 + 'H4': r'^([가-힣]\.\s+)', # "가. " → 제거 + 'H5': r'^(\d+\)\s+)', # "1) " → 제거 + 'H6': r'^([가-힣]\)\s+|\(\d+\)\s+)', # "가) " 또는 "(1) " → 제거 + 'H7': r'^([①②③④⑤⑥⑦⑧⑨⑩]+\s*)', # "① " → 제거 + } + + if role not in patterns: + return inner + + pattern = patterns[role] + + # 태그 내 텍스트에서 번호 제거 + def remove_number(match): + text = match.group(1) + # 첫 번째 내용에서만 번호 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번째 hp:t 태그만 처리 + new_inner = re.sub(r'([^<]*)', remove_number, inner, count=1) + + return new_inner + + def _repack_hwpx(self, output_path: str): + """HWPX 재압축""" + print(f" [DEBUG] Repacking to: {output_path}") + print(f" [DEBUG] Source dir: {self.temp_dir}") + + # 압축 전 section 파일 크기 확인 + for sec in ['section0.xml', 'section1.xml', 'section2.xml']: + sec_path = self.temp_dir / "Contents" / sec + if sec_path.exists(): + print(f" [DEBUG] {sec} size before zip: {sec_path.stat().st_size} bytes") + + # 🆕 임시 파일에 먼저 저장 (원본 파일 잠금 문제 회피) + temp_output = output_path + ".tmp" + + with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf: + # mimetype은 압축 없이 첫 번째로 + mimetype_path = self.temp_dir / "mimetype" + if mimetype_path.exists(): + zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED) + + # 나머지 파일들 + file_count = 0 + for root, dirs, files in os.walk(self.temp_dir): + for file in files: + if file == "mimetype": + continue + file_path = Path(root) / file + arcname = file_path.relative_to(self.temp_dir) + zf.write(file_path, arcname) + file_count += 1 + + print(f" [DEBUG] Total files zipped: {file_count}") + + # 🆕 원본 삭제 후 임시 파일을 원본 이름으로 변경 + import time + for attempt in range(3): + try: + if os.path.exists(output_path): + os.remove(output_path) + os.rename(temp_output, output_path) + break + except PermissionError: + print(f" [DEBUG] 파일 잠금 대기 중... ({attempt + 1}/3)") + time.sleep(0.5) + else: + # 3번 시도 실패 시 임시 파일 이름으로 유지 + print(f" [경고] 원본 덮어쓰기 실패, 임시 파일 사용: {temp_output}") + output_path = temp_output + + # 압축 후 결과 확인 + print(f" [DEBUG] Output file size: {Path(output_path).stat().st_size} bytes") + + +def inject_styles_to_hwpx(hwpx_path: str, elements: list) -> str: + """ + 편의 함수: StyledElement 리스트로부터 역할 위치 추출 후 스타일 주입 + + Args: + hwpx_path: HWPX 파일 경로 + elements: StyleAnalyzer의 StyledElement 리스트 + + Returns: + 수정된 HWPX 파일 경로 + """ + # 역할별 위치 수집 + # 참고: 현재는 section 0, para 순서대로 가정 + role_positions: Dict[str, List[tuple]] = {} + + for idx, elem in enumerate(elements): + role = elem.role + if role not in role_positions: + role_positions[role] = [] + # (section_idx, para_idx) - 현재는 section 0 가정 + role_positions[role].append((0, idx)) + + injector = HwpxStyleInjector() + return injector.inject(hwpx_path, role_positions) + + +# 테스트 +if __name__ == "__main__": + # 테스트용 + test_positions = { + 'H1': [(0, 0), (0, 5)], + 'H2': [(0, 1), (0, 6)], + 'BODY': [(0, 2), (0, 3), (0, 4)], + } + + # injector = HwpxStyleInjector() + # injector.inject("test.hwpx", test_positions) + print("HwpxStyleInjector 모듈 로드 완료") \ No newline at end of file diff --git a/converters/hwpx_table_injector.py b/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/converters/hwpx_table_injector.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +""" +HWPX 표 열 너비 수정기 v2 +표 생성 후 HWPX 파일을 직접 수정하여 열 너비 적용 +""" + +import zipfile +import re +from pathlib import Path +import tempfile +import shutil + +# mm → HWPML 단위 변환 (1mm ≈ 283.46 HWPML units) +MM_TO_HWPML = 7200 / 25.4 # ≈ 283.46 + + +def inject_table_widths(hwpx_path: str, table_widths_list: list): + """ + HWPX 파일의 표 열 너비를 수정 + + Args: + hwpx_path: HWPX 파일 경로 + table_widths_list: [[w1, w2, w3], [w1, w2], ...] 형태 (mm 단위) + """ + if not table_widths_list: + print(" [INFO] 수정할 표 없음") + return + + print(f"📐 HWPX 표 열 너비 수정 시작... ({len(table_widths_list)}개 표)") + + # HWPX 압축 해제 + temp_dir = Path(tempfile.mkdtemp(prefix="hwpx_table_")) + + with zipfile.ZipFile(hwpx_path, 'r') as zf: + zf.extractall(temp_dir) + + # section*.xml 파일들에서 표 찾기 + contents_dir = temp_dir / "Contents" + + table_idx = 0 + total_modified = 0 + + for section_file in sorted(contents_dir.glob("section*.xml")): + with open(section_file, 'r', encoding='utf-8') as f: + content = f.read() + + original_content = content + + # 모든 표(...) 찾기 + tbl_pattern = re.compile(r'(]*>)(.*?)()', re.DOTALL) + + def process_table(match): + nonlocal table_idx, total_modified + + if table_idx >= len(table_widths_list): + return match.group(0) + + tbl_open = match.group(1) + tbl_content = match.group(2) + tbl_close = match.group(3) + + col_widths_mm = table_widths_list[table_idx] + col_widths_hwpml = [int(w * MM_TO_HWPML) for w in col_widths_mm] + + # 표 전체 너비 수정 (hp:sz width="...") + total_width = int(sum(col_widths_mm) * MM_TO_HWPML) + tbl_content = re.sub( + r'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교체 + tc_content = re.sub( + r'(... 블록 처리 + tbl_content = re.sub( + r']*>.*?', + replace_cell_width, + tbl_content, + flags=re.DOTALL + ) + + print(f" ✅ 표 #{table_idx + 1}: {col_widths_mm} mm → HWPML 적용") + table_idx += 1 + total_modified += 1 + + return tbl_open + tbl_content + tbl_close + + # 표 처리 + new_content = tbl_pattern.sub(process_table, content) + + # 변경사항 있으면 저장 + if new_content != original_content: + with open(section_file, 'w', encoding='utf-8') as f: + f.write(new_content) + print(f" → {section_file.name} 저장됨") + + # 다시 압축 + repack_hwpx(temp_dir, hwpx_path) + + # 임시 폴더 삭제 + shutil.rmtree(temp_dir) + + print(f" ✅ 총 {total_modified}개 표 열 너비 수정 완료") + + +def repack_hwpx(source_dir: Path, output_path: str): + """HWPX 파일 다시 압축""" + import os + import time + + temp_output = output_path + ".tmp" + + with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf: + # mimetype은 압축 없이 첫 번째로 + mimetype_path = source_dir / "mimetype" + if mimetype_path.exists(): + zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED) + + # 나머지 파일들 + for root, dirs, files in os.walk(source_dir): + for file in files: + if file == "mimetype": + continue + file_path = Path(root) / file + arcname = file_path.relative_to(source_dir) + zf.write(file_path, arcname) + + # 원본 교체 + for attempt in range(3): + try: + if os.path.exists(output_path): + os.remove(output_path) + os.rename(temp_output, output_path) + break + except PermissionError: + time.sleep(0.5) + + +# 테스트용 +if __name__ == "__main__": + test_widths = [ + [18.2, 38.9, 42.8, 70.1], + [19.9, 79.6, 70.5], + [28.7, 81.4, 59.9], + [19.2, 61.4, 89.5], + ] + + hwpx_path = r"C:\Users\User\AppData\Local\Temp\geulbeot_output.hwpx" + inject_table_widths(hwpx_path, test_widths) \ No newline at end of file