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