v5:HWPX 스타일 주입 + 표 열너비 변환_20260127

This commit is contained in:
2026-02-20 11:42:07 +09:00
parent 17e639ed40
commit 71740ce912
6 changed files with 1260 additions and 228 deletions

View File

@@ -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 스타일 주입 + 표 열 너비 정밀 변환** |
---

22
app.py
View File

@@ -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

View File

@@ -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("완료!")

View File

@@ -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>/<col>에서 너비 추출
colgroup = table_elem.find('colgroup')
if colgroup:
for col in colgroup.find_all('col'):
width = _parse_width(col.get('width') or col.get('style', ''))
col_widths.append(width)
# 행 데이터 수집
for ri, tr in enumerate(table_elem.find_all('tr')):
row, ci = [], 0
for cell in tr.find_all(['td','th']):
while (ri,ci) in occupied: row.append(""); ci+=1
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:
"""역할에 따른 스타일 설정 반환"""

View File

@@ -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'<hh:style id="([1-9]|[12]\d|30)"[^/]*/>\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'<hh:charPr id="(\d+)"', content)]
self.next_char_id = max(char_ids) + 1 if char_ids else 20
para_ids = [int(m) for m in re.findall(r'<hh:paraPr id="(\d+)"', content)]
self.next_para_id = max(para_ids) + 1 if para_ids else 20
# 스타일은 1부터 시작! (Ctrl+2 = id=1, Ctrl+3 = id=2, ...)
self.next_style_id = 1
def inject(self, hwpx_path: str, role_positions: Dict[str, List[tuple]]) -> 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, '</hh:charProperties>', '\n'.join(char_props) + '\n'
)
# paraProperties에 추가
content = self._insert_before_tag(
content, '</hh:paraProperties>', '\n'.join(para_props) + '\n'
)
# styles에 추가
content = self._insert_before_tag(
content, '</hh:styles>', '\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'<hh:charPr id="{id}" height="{style.font_size}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:underline type="NONE" shape="SOLID" color="#000000"/><hh:strikeout shape="NONE" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/></hh:charPr>'
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'<hh:heading type="OUTLINE" idRef="0" level="{style.outline_level}"/>'
else:
heading = '<hh:heading type="NONE" idRef="0" level="0"/>'
return f'<hh:paraPr id="{id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="{style.align}" vertical="BASELINE"/>{heading}<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/><hh:margin><hc:intent value="{style.indent_first}" unit="HWPUNIT"/><hc:left value="{style.indent_left}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{style.space_before}" unit="HWPUNIT"/><hc:next value="{style.space_after}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="{style.line_spacing}" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>'
def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str:
"""style XML 생성"""
safe_name = name.replace('<', '&lt;').replace('>', '&gt;')
return f'<hh:style id="{id}" type="PARA" name="{safe_name}" engName="" paraPrIDRef="{para_id}" charPrIDRef="{char_id}" nextStyleIDRef="{id}" langID="1042" lockForm="0"/>'
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('<hh:charPr ')
content = re.sub(
r'<hh:charProperties itemCnt="(\d+)"',
f'<hh:charProperties itemCnt="{char_count}"',
content
)
# paraProperties itemCnt
para_count = content.count('<hh:paraPr ')
content = re.sub(
r'<hh:paraProperties itemCnt="(\d+)"',
f'<hh:paraProperties itemCnt="{para_count}"',
content
)
# styles itemCnt
style_count = content.count('<hh:style ')
content = re.sub(
r'<hh:styles itemCnt="(\d+)"',
f'<hh:styles itemCnt="{style_count}"',
content
)
# 🆕 numberings itemCnt
numbering_count = content.count('<hh:numbering ')
content = re.sub(
r'<hh:numberings itemCnt="(\d+)"',
f'<hh:numberings itemCnt="{numbering_count}"',
content
)
return content
def _replace_default_numbering(self, content: str) -> 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'(<hh:numbering id="1"[^>]*>)(.*?)(</hh:numbering>)', 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'>([^<]*)</hh:paraHead>', f'>{pattern}</hh:paraHead>', tag)
return tag
numbering_content = re.sub(
rf'<hh:paraHead[^>]*level="{level}"[^>]*>.*?</hh:paraHead>',
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'<hp:sz width="(\d+)"', tbl)
table_width = int(sz_match.group(1)) if sz_match else 47624
# 열 개수 추출
col_match = re.search(r'colCnt="(\d+)"', tbl)
col_cnt = int(col_match.group(1)) if col_match else 4
# 열 너비 계산 (첫 열은 30%, 나머지 균등)
first_col_width = int(table_width * 0.25)
other_col_width = (table_width - first_col_width) // (col_cnt - 1) if col_cnt > 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'<hp:cellSz width="{width}" height="{new_height}"/>'
tbl = re.sub(
r'<hp:cellSz width="(\d+)" height="(\d+)"/>',
adjust_cell_sz,
tbl
)
return tbl
return re.sub(r'<hp:tbl[^>]*>.*?</hp:tbl>', 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'<hp:header[^>]*>.*?</hp:header>', save_header_footer, content, flags=re.DOTALL)
content = re.sub(r'<hp:footer[^>]*>.*?</hp:footer>', save_header_footer, content, flags=re.DOTALL)
# 모든 <hp:p> 태그와 내부 텍스트 추출
para_pattern = r'(<hp:p [^>]*>)(.*?)(</hp:p>)'
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('<hp:p ', f'<hp:p styleIDRef="{matched_style_id}" ')
# 2. hp:p 태그의 paraPrIDRef도 변경! (스타일의 paraPrIDRef와 일치!)
new_open = re.sub(r'paraPrIDRef="[^"]*"', f'paraPrIDRef="{matched_para_id}"', new_open)
# 3. inner에서 hp:run의 charPrIDRef도 변경! (스타일의 charPrIDRef와 일치!)
new_inner = re.sub(r'(<hp:run[^>]*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 변경"""
# <hp:p ...> 태그들 찾기
pattern = r'<hp:p\s[^>]*>'
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('<hp:p ', f'<hp:p styleIDRef="{style_id}" ')
return content[:match.start()] + new_tag + content[match.end():]
def _remove_manual_numbering(self, inner: str, role: str) -> 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]
# <hp:t> 태그 내 텍스트에서 번호 제거
def remove_number(match):
text = match.group(1)
# 첫 번째 <hp:t> 내용에서만 번호 제거
new_text = re.sub(pattern, '', text, count=1)
return f'<hp:t>{new_text}</hp:t>'
# 첫 번째 hp:t 태그만 처리
new_inner = re.sub(r'<hp:t>([^<]*)</hp:t>', 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 모듈 로드 완료")

View File

@@ -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
# 모든 표(<hp:tbl>...</hp:tbl>) 찾기
tbl_pattern = re.compile(r'(<hp:tbl\b[^>]*>)(.*?)(</hp:tbl>)', 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'(<hp:sz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{total_width}{m.group(3)}',
tbl_content,
count=1
)
# 각 셀의 cellSz width 수정
# 방법: colAddr별로 너비 매핑
def replace_cell_width(tc_match):
tc_content = tc_match.group(0)
# colAddr 추출
col_addr_match = re.search(r'<hp:cellAddr\s+colAddr="(\d+)"', tc_content)
if not col_addr_match:
return tc_content
col_idx = int(col_addr_match.group(1))
if col_idx >= len(col_widths_hwpml):
return tc_content
new_width = col_widths_hwpml[col_idx]
# cellSz width 교체
tc_content = re.sub(
r'(<hp:cellSz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{new_width}{m.group(3)}',
tc_content
)
return tc_content
# 각 <hp:tc>...</hp:tc> 블록 처리
tbl_content = re.sub(
r'<hp:tc\b[^>]*>.*?</hp:tc>',
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)