v5:HWPX 스타일 주입 + 표 열너비 변환_20260127
This commit is contained in:
93
README.md
93
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 스타일 주입 + 표 열 너비 정밀 변환** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
22
app.py
22
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
|
||||
|
||||
@@ -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("완료!")
|
||||
@@ -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:
|
||||
"""역할에 따른 스타일 설정 반환"""
|
||||
|
||||
750
converters/hwpx_style_injector.py
Normal file
750
converters/hwpx_style_injector.py
Normal 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('<', '<').replace('>', '>')
|
||||
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 모듈 로드 완료")
|
||||
174
converters/hwpx_table_injector.py
Normal file
174
converters/hwpx_table_injector.py
Normal 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)
|
||||
Reference in New Issue
Block a user