📦 Initialize Geulbeot structure and merge Prompts & test projects
This commit is contained in:
8
03. Code/geulbeot_10th/handlers/__init__.py
Normal file
8
03. Code/geulbeot_10th/handlers/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
handlers 패키지
|
||||
문서 유형별 처리 로직을 분리하여 관리
|
||||
"""
|
||||
|
||||
from .template.doc_template_analyzer import DocTemplateAnalyzer
|
||||
|
||||
5
03. Code/geulbeot_10th/handlers/briefing/__init__.py
Normal file
5
03. Code/geulbeot_10th/handlers/briefing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 모듈
|
||||
"""
|
||||
from .processor import BriefingProcessor
|
||||
279
03. Code/geulbeot_10th/handlers/briefing/processor.py
Normal file
279
03. Code/geulbeot_10th/handlers/briefing/processor.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 로직
|
||||
- 1~2페이지 압축형 보고서
|
||||
- Navy 양식
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import jsonify, session
|
||||
|
||||
from handlers.common import call_claude, extract_json, extract_html, load_prompt, client
|
||||
|
||||
|
||||
class BriefingProcessor:
|
||||
"""기획서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def _get_step1_prompt(self) -> str:
|
||||
"""1단계: 구조 추출 프롬프트"""
|
||||
prompt = self._load_prompt('step1_extract.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
|
||||
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
|
||||
|
||||
def _get_step1_5_prompt(self) -> str:
|
||||
"""1.5단계: 배치 계획 프롬프트"""
|
||||
prompt = self._load_prompt('step1_5_plan.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
|
||||
|
||||
def _get_step2_prompt(self) -> str:
|
||||
"""2단계: HTML 생성 프롬프트"""
|
||||
prompt = self._load_prompt('step2_generate.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
|
||||
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
|
||||
|
||||
def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool:
|
||||
"""페이지당 콘텐츠 양 체크"""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
sheets = soup.find_all('div', class_='sheet')
|
||||
for sheet in sheets:
|
||||
sections = sheet.find_all('div', class_='section')
|
||||
if len(sections) > max_sections_per_page:
|
||||
return True
|
||||
|
||||
all_li = sheet.find_all('li')
|
||||
if len(all_li) > 12:
|
||||
return True
|
||||
|
||||
steps = sheet.find_all('div', class_='process-step')
|
||||
if len(steps) > 6:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""기획서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용을 입력하거나 파일을 업로드해주세요.'}
|
||||
|
||||
page_option = options.get('page_option', '1')
|
||||
department = options.get('department', '총괄기획실')
|
||||
additional_prompt = options.get('instruction', '')
|
||||
|
||||
# ============== 1단계: 구조 추출 ==============
|
||||
step1_prompt = self._get_step1_prompt()
|
||||
step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요.
|
||||
|
||||
## 원본 HTML
|
||||
{content}
|
||||
|
||||
---
|
||||
위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력."""
|
||||
|
||||
step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000)
|
||||
structure_json = extract_json(step1_response)
|
||||
|
||||
if not structure_json:
|
||||
structure_json = {"raw_content": content, "parse_failed": True}
|
||||
|
||||
# ============== 1.5단계: 배치 계획 ==============
|
||||
step1_5_prompt = self._get_step1_5_prompt()
|
||||
step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요.
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 수
|
||||
{page_option}페이지
|
||||
|
||||
---
|
||||
배치 계획 JSON만 출력하세요. 설명 없이 JSON만."""
|
||||
|
||||
step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000)
|
||||
page_plan = extract_json(step1_5_response)
|
||||
|
||||
if not page_plan:
|
||||
page_plan = {"page_plan": {}, "parse_failed": True}
|
||||
|
||||
# ============== 2단계: HTML 생성 ==============
|
||||
page_instructions = {
|
||||
'1': '1페이지로 핵심 내용만 압축하여 작성하세요.',
|
||||
'2': '2페이지로 작성하세요. 1페이지는 본문, 2페이지는 [첨부]입니다.',
|
||||
'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부] 형태로 분할합니다.'
|
||||
}
|
||||
|
||||
step2_prompt = self._get_step2_prompt()
|
||||
step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요.
|
||||
|
||||
## 배치 계획
|
||||
{json.dumps(page_plan, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 옵션
|
||||
{page_instructions.get(page_option, page_instructions['1'])}
|
||||
|
||||
## 부서명
|
||||
{department}
|
||||
|
||||
## 추가 요청사항
|
||||
{additional_prompt if additional_prompt else '없음'}
|
||||
|
||||
---
|
||||
위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요.
|
||||
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력."""
|
||||
|
||||
step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000)
|
||||
html_content = extract_html(step2_response)
|
||||
|
||||
# 후처리 검증
|
||||
if self._content_too_long(html_content):
|
||||
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
|
||||
각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요.
|
||||
|
||||
{html_content}
|
||||
|
||||
코드 블록 없이 압축된 완전한 HTML만 출력하세요."""
|
||||
|
||||
compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000)
|
||||
html_content = extract_html(compress_response)
|
||||
|
||||
# 세션에 저장
|
||||
session['original_html'] = content
|
||||
session['current_html'] = html_content
|
||||
session['structure_json'] = json.dumps(structure_json, ensure_ascii=False)
|
||||
session['conversation'] = []
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': html_content,
|
||||
'structure': structure_json
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. 나머지 구조와 스타일은 그대로 유지
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능
|
||||
|
||||
## 원본 HTML (참고용)
|
||||
{original_html[:3000] if original_html else '없음'}...
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등)
|
||||
|
||||
2. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용)
|
||||
|
||||
3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이)
|
||||
4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지)
|
||||
5. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
@@ -0,0 +1,122 @@
|
||||
당신은 HTML 문서 구조 분석 전문가입니다.
|
||||
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
|
||||
2. 문서의 논리적 구조를 정확히 파악
|
||||
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
|
||||
|
||||
## 출력 JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "문서 제목 (원문 그대로)",
|
||||
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
|
||||
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
|
||||
"lead": {
|
||||
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
|
||||
"highlight_keywords": ["강조할 키워드1", "키워드2"]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "섹션 제목 (원문 그대로)",
|
||||
"type": "list | table | grid | process | qa | text",
|
||||
"content": {
|
||||
// type에 따라 다름 (아래 참조)
|
||||
}
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"label": "라벨 (예: 핵심 결론, 요약 등)",
|
||||
"text": "결론 텍스트 (원문 그대로, 한 문장)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 섹션 type별 content 구조
|
||||
|
||||
### type: "list"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
|
||||
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "table"
|
||||
```json
|
||||
{
|
||||
"columns": ["컬럼1", "컬럼2", "컬럼3"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
|
||||
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
|
||||
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnote": "표 하단 주석 (있으면)"
|
||||
}
|
||||
```
|
||||
- badge 값: "safe" | "caution" | "risk" | null
|
||||
- highlight: true면 빨간색 강조
|
||||
|
||||
### type: "grid"
|
||||
```json
|
||||
{
|
||||
"columns": 2,
|
||||
"items": [
|
||||
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
|
||||
{"title": "② 항목 제목", "text": "설명", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "two-column"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
|
||||
{"title": "② 제목", "text": "내용", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "process"
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{"number": 1, "title": "단계명", "text": "설명"},
|
||||
{"number": 2, "title": "단계명", "text": "설명"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "qa"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"question": "질문?", "answer": "답변"},
|
||||
{"question": "질문?", "answer": "답변"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "text"
|
||||
```json
|
||||
{
|
||||
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
|
||||
}
|
||||
```
|
||||
|
||||
## 중요
|
||||
|
||||
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
|
||||
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
|
||||
3. **JSON만 출력** - 설명 없이 순수 JSON만
|
||||
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑
|
||||
@@ -0,0 +1,440 @@
|
||||
당신은 HTML 보고서 생성 전문가입니다.
|
||||
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
|
||||
|
||||
## 출력 규칙
|
||||
|
||||
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
|
||||
2. 코드 블록(```) 없이 **순수 HTML만** 출력
|
||||
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
|
||||
4. 아래 CSS를 **정확히** 사용
|
||||
|
||||
## 페이지 옵션
|
||||
|
||||
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
|
||||
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
|
||||
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
|
||||
|
||||
## HTML 템플릿 구조
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* 아래 CSS 전체 포함 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">{{department}}</div>
|
||||
<div class="header-right">{{title_en}}</div>
|
||||
</header>
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{{title}}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>{{lead.text}} - <b>키워드</b> 강조</div>
|
||||
</div>
|
||||
<!-- sections -->
|
||||
<div class="bottom-box">
|
||||
<div class="bottom-left">{{conclusion.label}}</div>
|
||||
<div class="bottom-right">{{conclusion.text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="page-footer">- 1 -</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 섹션 type별 HTML 변환
|
||||
|
||||
### list → ul/li
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<ul>
|
||||
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### table → data-table
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">{{col1}}</th>
|
||||
<th width="25%">{{col2}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{text}}</strong></td>
|
||||
<td class="highlight-red">{{text}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
|
||||
- highlight가 true면: `class="highlight-red"`
|
||||
|
||||
### grid → strategy-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### two-column → two-col
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="two-col">
|
||||
<div class="info-box">
|
||||
<div class="info-box-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### process → process-container
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="process-container">
|
||||
<div class="process-step">
|
||||
<div class="step-num">{{step.number}}</div>
|
||||
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
|
||||
</div>
|
||||
<div class="arrow">▼</div>
|
||||
<!-- 반복 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### qa → qa-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="qa-grid">
|
||||
<div class="qa-item">
|
||||
<strong>Q. {{question}}</strong><br>
|
||||
A. {{answer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 완전한 CSS (반드시 이대로 사용)
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-navy: #1a365d;
|
||||
--secondary-navy: #2c5282;
|
||||
--accent-navy: #3182ce;
|
||||
--dark-gray: #2d3748;
|
||||
--medium-gray: #4a5568;
|
||||
--light-gray: #e2e8f0;
|
||||
--bg-light: #f7fafc;
|
||||
--text-black: #1a202c;
|
||||
--border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; gap: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
font-size: 9pt;
|
||||
color: var(--medium-gray);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 23pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary-navy);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary-navy);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 11.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lead-box b { color: var(--primary-navy); font-weight: 700; }
|
||||
|
||||
.body-content { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-navy);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--secondary-navy);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 19pt;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--primary-navy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul { list-style: none; padding-left: 10px; }
|
||||
|
||||
li {
|
||||
font-size: 10.5pt;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--secondary-navy);
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.bottom-box {
|
||||
border: 1.5px solid var(--border-color);
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
min-height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
width: 18%;
|
||||
background-color: var(--primary-navy);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10.5pt;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
width: 82%;
|
||||
background-color: var(--bg-light);
|
||||
padding: 12px 18px;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.bottom-right b { display: inline; }
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 10mm;
|
||||
left: 20mm;
|
||||
right: 20mm;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 8.5pt;
|
||||
color: var(--medium-gray);
|
||||
border-top: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
b { font-weight: 700; color: var(--primary-navy); display: inline; }
|
||||
.keyword { font-weight: 600; color: var(--text-black); }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9.5pt;
|
||||
border-top: 2px solid var(--primary-navy);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--secondary-navy);
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 7px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight-red { color: #c53030; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
|
||||
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
|
||||
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
|
||||
|
||||
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
|
||||
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
|
||||
.qa-item strong { color: var(--primary-navy); }
|
||||
|
||||
.two-col { display: flex; gap: 12px; margin-top: 6px; }
|
||||
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
|
||||
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
|
||||
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
|
||||
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
|
||||
.step-content strong { color: var(--primary-navy); font-weight: 600; }
|
||||
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
|
||||
```
|
||||
|
||||
## 1페이지 본문 구성 논리
|
||||
|
||||
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
|
||||
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
|
||||
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
|
||||
|
||||
## 첨부 페이지 구성
|
||||
|
||||
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
|
||||
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
|
||||
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
|
||||
|
||||
## 중요 규칙
|
||||
|
||||
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
|
||||
- 위치 재편성, 통합/분할
|
||||
- 표 ↔ 본문 ↔ 리스트 형식 변환
|
||||
|
||||
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
|
||||
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
|
||||
- ❌ "~입니다", "~합니다", "~됩니다"
|
||||
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
|
||||
- 예시:
|
||||
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
|
||||
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
|
||||
|
||||
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
|
||||
|
||||
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
|
||||
|
||||
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
|
||||
|
||||
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
|
||||
|
||||
## 첨부 페이지 규칙
|
||||
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
|
||||
- 제목은 좌측 정렬, 16pt
|
||||
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함
|
||||
84
03. Code/geulbeot_10th/handlers/common.py
Normal file
84
03. Code/geulbeot_10th/handlers/common.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
공통 유틸리티 함수
|
||||
- Claude API 호출
|
||||
- JSON/HTML 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import anthropic
|
||||
from api_config import API_KEYS
|
||||
|
||||
# Claude API 클라이언트
|
||||
client = anthropic.Anthropic(
|
||||
api_key=API_KEYS.get('CLAUDE_API_KEY', '')
|
||||
)
|
||||
|
||||
|
||||
def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str:
|
||||
"""Claude API 호출"""
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}]
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
|
||||
def extract_json(text: str) -> dict:
|
||||
"""텍스트에서 JSON 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```json' in text:
|
||||
text = text.split('```json')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
text = text.split('```')[1].split('```')[0]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 부분만 추출 시도
|
||||
match = re.search(r'\{[\s\S]*\}', text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def extract_html(text: str) -> str:
|
||||
"""텍스트에서 HTML 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```html' in text:
|
||||
text = text.split('```html')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
parts = text.split('```')
|
||||
if len(parts) >= 2:
|
||||
text = parts[1]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# <!DOCTYPE 또는 <html로 시작하는지 확인
|
||||
if not text.startswith('<!DOCTYPE') and not text.startswith('<html'):
|
||||
# HTML 부분만 추출
|
||||
match = re.search(r'(<!DOCTYPE html[\s\S]*</html>)', text, re.IGNORECASE)
|
||||
if match:
|
||||
text = match.group(1)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def load_prompt(prompts_dir: str, filename: str) -> str:
|
||||
"""프롬프트 파일 로드"""
|
||||
prompt_path = os.path.join(prompts_dir, filename)
|
||||
try:
|
||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
2
03. Code/geulbeot_10th/handlers/doc/__init__.py
Normal file
2
03. Code/geulbeot_10th/handlers/doc/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""doc 패키지 - 문서 유형 분석/정의/생성"""
|
||||
640
03. Code/geulbeot_10th/handlers/doc/content_analyzer.py
Normal file
640
03. Code/geulbeot_10th/handlers/doc/content_analyzer.py
Normal file
@@ -0,0 +1,640 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Content Analyzer (Phase 3 — Layer A)
|
||||
- template_info + semantic_map → content_prompt.json
|
||||
- 각 placeholder의 의미/유형/예시값/작성 패턴 추출
|
||||
- Phase 5에서 AI가 새 문서 생성 시 "레시피"로 참조
|
||||
|
||||
★ 원칙: 모든 분류는 코드 100% (AI 없음)
|
||||
purpose_hint / audience_hint / tone_hint는 빈 문자열로 남김
|
||||
→ 추후 AI enrichment 단계에서 채울 수 있도록 설계
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def generate(template_info: dict, semantic_map: dict,
|
||||
parsed: dict = None) -> dict:
|
||||
"""
|
||||
content_prompt.json 생성
|
||||
|
||||
Args:
|
||||
template_info: doc_template_analyzer 추출 결과
|
||||
semantic_map: semantic_mapper 분류 결과
|
||||
parsed: HWPX 파싱 원본 (선택)
|
||||
|
||||
Returns:
|
||||
content_prompt.json 구조
|
||||
"""
|
||||
placeholders = {}
|
||||
table_guide = {}
|
||||
|
||||
# ① 문서 기본 정보
|
||||
document = _analyze_document(template_info)
|
||||
|
||||
# ② 헤더 placeholders
|
||||
_analyze_header(template_info, placeholders)
|
||||
|
||||
# ③ 푸터 placeholders
|
||||
_analyze_footer(template_info, placeholders)
|
||||
|
||||
# ④ 제목 placeholder
|
||||
_analyze_title(template_info, semantic_map, placeholders)
|
||||
|
||||
# ⑤ 섹션 placeholders
|
||||
_analyze_sections(semantic_map, placeholders, template_info)
|
||||
|
||||
# ⑤-b content_order 기반 문단/이미지 placeholders
|
||||
_analyze_content_order(template_info, semantic_map, placeholders)
|
||||
|
||||
# ⑥ 표 가이드 + placeholders
|
||||
_analyze_tables(template_info, semantic_map,
|
||||
placeholders, table_guide)
|
||||
|
||||
# ⑦ 작성 패턴
|
||||
writing_guide = _analyze_writing_patterns(template_info, semantic_map)
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"document": document,
|
||||
"placeholders": placeholders,
|
||||
"table_guide": table_guide,
|
||||
"writing_guide": writing_guide
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 문서 기본 정보
|
||||
# ================================================================
|
||||
|
||||
def _analyze_document(template_info: dict) -> dict:
|
||||
"""문서 레벨 정보 추출"""
|
||||
page = template_info.get("page", {})
|
||||
paper = page.get("paper", {})
|
||||
|
||||
return {
|
||||
"paper": paper.get("name", "A4"),
|
||||
"layout": "landscape" if paper.get("landscape") else "portrait",
|
||||
"margins": page.get("margins", {}),
|
||||
"purpose_hint": "", # AI enrichment 예약
|
||||
"audience_hint": "", # AI enrichment 예약
|
||||
"tone_hint": "" # AI enrichment 예약
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 텍스트 유형 분류 (코드 100%, AI 없음)
|
||||
# ================================================================
|
||||
|
||||
def _classify_text(text: str) -> dict:
|
||||
"""텍스트 패턴으로 콘텐츠 유형 분류"""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return {"type": "empty", "pattern": "빈 셀"}
|
||||
|
||||
# 날짜: "2025. 1. 30(금)", "2025-01-30", "2025.01.30"
|
||||
if re.match(r'\d{4}[\.\-/]\s*\d{1,2}[\.\-/]\s*\d{1,2}', text):
|
||||
return {"type": "date", "pattern": "날짜 (YYYY. M. D)"}
|
||||
|
||||
# ★ 직급+이름 (부서보다 먼저!)
|
||||
positions = [
|
||||
'사원', '대리', '과장', '차장', '부장', '이사', '상무', '전무',
|
||||
'연구원', '선임연구원', '책임연구원', '수석연구원',
|
||||
'주임', '계장', '팀장', '실장', '부서장', '센터장'
|
||||
]
|
||||
for pos in positions:
|
||||
if pos in text:
|
||||
return {"type": "author", "pattern": f"이름 + 직급({pos})"}
|
||||
|
||||
# 부서 (직급 아닌 것만 여기로)
|
||||
if re.search(r'(실|부|국|과|원|처|센터|본부)$', text) and len(text) <= 12:
|
||||
return {"type": "department", "pattern": "조직명"}
|
||||
|
||||
# 팀
|
||||
if re.search(r'팀$', text) and len(text) <= 10:
|
||||
return {"type": "team", "pattern": "팀명"}
|
||||
|
||||
# 페이지 참조: "1p", "2p"
|
||||
if re.match(r'\d+p$', text):
|
||||
return {"type": "page_ref", "pattern": "페이지 참조"}
|
||||
|
||||
# 문서 제목: ~계획(안), ~보고서, ~제안서 등
|
||||
if re.search(r'(계획|보고서|제안서|기획서|결과|방안|현황|분석)'
|
||||
r'\s*(\(안\))?\s*$', text):
|
||||
return {"type": "doc_title", "pattern": "문서 제목"}
|
||||
|
||||
# 슬로건/비전 (길고 추상적 키워드 포함)
|
||||
if len(text) > 10 and any(k in text for k in
|
||||
['함께', '세상', '미래', '가치', '만들어']):
|
||||
return {"type": "slogan", "pattern": "회사 슬로건/비전"}
|
||||
|
||||
# 기본
|
||||
return {"type": "text", "pattern": "자유 텍스트"}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 헤더 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_header(template_info: dict, placeholders: dict):
|
||||
"""헤더 영역 placeholder 분석"""
|
||||
header = template_info.get("header", {})
|
||||
if not header or not header.get("exists"):
|
||||
return
|
||||
|
||||
if header.get("type") == "table" and header.get("table"):
|
||||
_analyze_table_area(header["table"], "HEADER", "header",
|
||||
placeholders)
|
||||
else:
|
||||
texts = header.get("texts", [])
|
||||
for i in range(max(len(texts), 1)):
|
||||
ph = f"HEADER_TEXT_{i+1}"
|
||||
example = texts[i] if i < len(texts) else ""
|
||||
info = _classify_text(example)
|
||||
info["example"] = example.strip()
|
||||
info["location"] = "header"
|
||||
placeholders[ph] = info
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 푸터 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_footer(template_info: dict, placeholders: dict):
|
||||
"""푸터 영역 placeholder 분석"""
|
||||
footer = template_info.get("footer", {})
|
||||
if not footer or not footer.get("exists"):
|
||||
return
|
||||
|
||||
if footer.get("type") == "table" and footer.get("table"):
|
||||
_analyze_table_area(footer["table"], "FOOTER", "footer",
|
||||
placeholders)
|
||||
else:
|
||||
placeholders["PAGE_NUMBER"] = {
|
||||
"type": "page_number",
|
||||
"pattern": "페이지 번호",
|
||||
"example": "1",
|
||||
"location": "footer"
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 헤더/푸터 공통: 표 형태 영역 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_table_area(tbl: dict, prefix: str, location: str,
|
||||
placeholders: dict):
|
||||
"""표 형태의 헤더/푸터 → placeholder 매핑
|
||||
|
||||
Args:
|
||||
tbl: header["table"] 또는 footer["table"]
|
||||
prefix: "HEADER" 또는 "FOOTER"
|
||||
location: "header" 또는 "footer"
|
||||
placeholders: 결과 dict (in-place 수정)
|
||||
"""
|
||||
rows = tbl.get("rows", [])
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell in enumerate(row):
|
||||
lines = cell.get("lines", [])
|
||||
|
||||
if len(lines) > 1:
|
||||
for l_idx, line_text in enumerate(lines):
|
||||
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}_LINE_{l_idx+1}"
|
||||
info = _classify_text(line_text)
|
||||
info["example"] = line_text.strip()
|
||||
info["location"] = location
|
||||
placeholders[ph] = info
|
||||
elif lines:
|
||||
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}"
|
||||
info = _classify_text(lines[0])
|
||||
info["example"] = lines[0].strip()
|
||||
info["location"] = location
|
||||
placeholders[ph] = info
|
||||
else:
|
||||
ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}"
|
||||
placeholders[ph] = {
|
||||
"type": "empty",
|
||||
"pattern": "빈 셀 (로고/여백)",
|
||||
"example": "",
|
||||
"location": location
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 제목 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_title(template_info: dict, semantic_map: dict,
|
||||
placeholders: dict):
|
||||
"""제목 블록 placeholder 분석
|
||||
|
||||
★ v1.1: template_manager._build_title_block_html()과 동일한
|
||||
TITLE_R{r}_C{c} 명명 규칙 사용 (범용 매핑)
|
||||
"""
|
||||
title_idx = semantic_map.get("title_table")
|
||||
if title_idx is None:
|
||||
return
|
||||
|
||||
tables = template_info.get("tables", [])
|
||||
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
|
||||
if not title_tbl:
|
||||
return
|
||||
|
||||
# 각 셀별로 placeholder 생성 (template과 동일한 이름)
|
||||
for r_idx, row in enumerate(title_tbl.get("rows", [])):
|
||||
for c_idx, cell in enumerate(row):
|
||||
cell_text = cell.get("text", "").strip()
|
||||
if not cell_text:
|
||||
continue # 빈 셀은 template에서도 placeholder 없음
|
||||
|
||||
ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}"
|
||||
info = _classify_text(cell_text)
|
||||
if "title" not in info["type"] and "doc_title" not in info["type"]:
|
||||
# 제목표 안의 텍스트가 doc_title이 아닐 수도 있음 (부제 등)
|
||||
# 가장 긴 텍스트만 doc_title로 분류
|
||||
pass
|
||||
info["example"] = cell_text
|
||||
info["location"] = "title_block"
|
||||
placeholders[ph_name] = info
|
||||
|
||||
# 가장 긴 텍스트를 가진 셀을 doc_title로 마킹
|
||||
longest_ph = None
|
||||
longest_len = 0
|
||||
for ph_key in list(placeholders.keys()):
|
||||
if ph_key.startswith("TITLE_R"):
|
||||
ex = placeholders[ph_key].get("example", "")
|
||||
if len(ex) > longest_len:
|
||||
longest_len = len(ex)
|
||||
longest_ph = ph_key
|
||||
if longest_ph:
|
||||
placeholders[longest_ph]["type"] = "doc_title"
|
||||
placeholders[longest_ph]["pattern"] = "문서 제목"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 섹션 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_sections(semantic_map: dict, placeholders: dict,
|
||||
template_info: dict = None):
|
||||
"""섹션 placeholder 분석.
|
||||
|
||||
content_order에 문단이 있으면 SECTION_n_CONTENT는 생략
|
||||
(개별 PARA_n이 본문 역할을 대신함).
|
||||
"""
|
||||
sections = semantic_map.get("sections", [])
|
||||
|
||||
# content_order에 문단이 있으면 개별 PARA_n이 본문 담당 → CONTENT 불필요
|
||||
has_co_paragraphs = False
|
||||
if template_info:
|
||||
co = template_info.get("content_order", [])
|
||||
has_co_paragraphs = any(c['type'] == 'paragraph' for c in co) if co else False
|
||||
|
||||
if not sections:
|
||||
placeholders["SECTION_1_TITLE"] = {
|
||||
"type": "section_title", "pattern": "섹션 제목",
|
||||
"example": "", "location": "body"
|
||||
}
|
||||
if not has_co_paragraphs:
|
||||
placeholders["SECTION_1_CONTENT"] = {
|
||||
"type": "section_content", "pattern": "섹션 본문",
|
||||
"example": "", "location": "body"
|
||||
}
|
||||
return
|
||||
|
||||
for i, sec in enumerate(sections):
|
||||
s_num = i + 1
|
||||
title_text = sec if isinstance(sec, str) else sec.get("title", "")
|
||||
|
||||
placeholders[f"SECTION_{s_num}_TITLE"] = {
|
||||
"type": "section_title", "pattern": "섹션 제목",
|
||||
"example": title_text, "location": "body"
|
||||
}
|
||||
if not has_co_paragraphs:
|
||||
placeholders[f"SECTION_{s_num}_CONTENT"] = {
|
||||
"type": "section_content", "pattern": "섹션 본문",
|
||||
"example": "", "location": "body"
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# content_order 기반 문단/이미지 분석 (v5.2+)
|
||||
# ================================================================
|
||||
|
||||
def _analyze_content_order(template_info: dict, semantic_map: dict,
|
||||
placeholders: dict):
|
||||
"""content_order의 paragraph/image → PARA_n, IMAGE_n placeholder 생성.
|
||||
|
||||
content_order가 없거나 문단이 없으면 아무것도 안 함 (legacy 호환).
|
||||
"""
|
||||
content_order = template_info.get("content_order")
|
||||
if not content_order:
|
||||
return
|
||||
if not any(c['type'] == 'paragraph' for c in content_order):
|
||||
return
|
||||
|
||||
# 섹션 제목 패턴 (template_manager와 동일)
|
||||
sec_patterns = [
|
||||
re.compile(r'^\d+\.\s+\S'),
|
||||
re.compile(r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.\s*\S'),
|
||||
re.compile(r'^제\s*\d+\s*[장절항]\s*\S'),
|
||||
]
|
||||
|
||||
para_num = 0
|
||||
img_num = 0
|
||||
section_num = 0
|
||||
|
||||
for item in content_order:
|
||||
itype = item['type']
|
||||
|
||||
if itype == 'empty':
|
||||
continue
|
||||
|
||||
# ── 표: _analyze_tables에서 처리 → 건너뛰기 ──
|
||||
if itype == 'table':
|
||||
continue
|
||||
|
||||
# ── 이미지 ──
|
||||
if itype == 'image':
|
||||
img_num += 1
|
||||
placeholders[f"IMAGE_{img_num}"] = {
|
||||
"type": "image",
|
||||
"pattern": "이미지",
|
||||
"example_ref": item.get("binaryItemIDRef", ""),
|
||||
"location": "body"
|
||||
}
|
||||
caption = item.get("text", "")
|
||||
if caption:
|
||||
placeholders[f"IMAGE_{img_num}_CAPTION"] = {
|
||||
"type": "image_caption",
|
||||
"pattern": "이미지 캡션",
|
||||
"example": caption,
|
||||
"location": "body"
|
||||
}
|
||||
continue
|
||||
|
||||
# ── 문단 ──
|
||||
if itype == 'paragraph':
|
||||
text = item.get('text', '')
|
||||
|
||||
# 섹션 제목 → SECTION_n_TITLE (이미 _analyze_sections에서 등록됐을 수 있음)
|
||||
if any(p.match(text) for p in sec_patterns):
|
||||
section_num += 1
|
||||
ph = f"SECTION_{section_num}_TITLE"
|
||||
if ph not in placeholders:
|
||||
placeholders[ph] = {
|
||||
"type": "section_title",
|
||||
"pattern": "섹션 제목",
|
||||
"example": text,
|
||||
"location": "body"
|
||||
}
|
||||
continue
|
||||
|
||||
# 일반 문단
|
||||
para_num += 1
|
||||
runs = item.get('runs', [])
|
||||
|
||||
if len(runs) > 1:
|
||||
# 다중 run → 각 run별 placeholder
|
||||
for r_idx, run in enumerate(runs):
|
||||
ph = f"PARA_{para_num}_RUN_{r_idx+1}"
|
||||
run_text = run.get("text", "")
|
||||
info = _classify_text(run_text)
|
||||
info["example"] = run_text[:100] if len(run_text) > 100 else run_text
|
||||
info["location"] = "body"
|
||||
info["run_index"] = r_idx + 1
|
||||
placeholders[ph] = info
|
||||
else:
|
||||
ph = f"PARA_{para_num}"
|
||||
info = _classify_text(text)
|
||||
info["example"] = text[:100] if len(text) > 100 else text
|
||||
info["location"] = "body"
|
||||
placeholders[ph] = info
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 표 분석 → placeholder + 표 가이드
|
||||
# ================================================================
|
||||
|
||||
def _analyze_tables(template_info: dict, semantic_map: dict,
|
||||
placeholders: dict, table_guide: dict):
|
||||
"""본문 데이터 표 → placeholder + table_guide"""
|
||||
tables = template_info.get("tables", [])
|
||||
body_indices = semantic_map.get("body_tables", [])
|
||||
table_roles = semantic_map.get("table_roles", {})
|
||||
|
||||
for tbl_num_0, tbl_idx in enumerate(body_indices):
|
||||
tbl_num = tbl_num_0 + 1
|
||||
tbl = next((t for t in tables if t["index"] == tbl_idx), None)
|
||||
if not tbl:
|
||||
continue
|
||||
|
||||
role_info = table_roles.get(tbl_idx, table_roles.get(str(tbl_idx), {}))
|
||||
col_headers = role_info.get("col_headers", [])
|
||||
col_cnt = len(col_headers) if col_headers else tbl.get("colCnt", 0)
|
||||
|
||||
# ── 헤더 placeholder ──
|
||||
for c_idx, h_text in enumerate(col_headers):
|
||||
ph = f"TABLE_{tbl_num}_H_C{c_idx+1}"
|
||||
placeholders[ph] = {
|
||||
"type": "table_header", "pattern": "표 열 제목",
|
||||
"example": h_text, "location": f"table_{tbl_num}"
|
||||
}
|
||||
|
||||
# ── BODY placeholder ──
|
||||
placeholders[f"TABLE_{tbl_num}_BODY"] = {
|
||||
"type": "table_body",
|
||||
"pattern": "표 데이터 행들 (HTML <tr> 반복)",
|
||||
"example": "",
|
||||
"location": f"table_{tbl_num}"
|
||||
}
|
||||
|
||||
# ── 표 가이드 ──
|
||||
table_guide[str(tbl_num)] = {
|
||||
"col_headers": col_headers,
|
||||
"col_count": col_cnt,
|
||||
"row_count": tbl.get("rowCnt", 0),
|
||||
"merge_pattern": _detect_merge_pattern(tbl),
|
||||
"bullet_chars": _detect_bullet_chars(tbl),
|
||||
"example_rows": _extract_example_rows(tbl, role_info),
|
||||
"col_types": _classify_columns(col_headers),
|
||||
"row_bf_pattern": _extract_row_bf_pattern(tbl, role_info),
|
||||
}
|
||||
|
||||
|
||||
def _detect_merge_pattern(tbl: dict) -> dict:
|
||||
"""셀 병합 패턴 감지"""
|
||||
pattern = {}
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
col = cell.get("colAddr", 0)
|
||||
if cell.get("rowSpan", 1) > 1:
|
||||
pattern.setdefault(f"col_{col}", "row_group")
|
||||
if cell.get("colSpan", 1) > 1:
|
||||
pattern.setdefault(f"col_{col}", "col_span")
|
||||
return pattern
|
||||
|
||||
|
||||
def _detect_bullet_chars(tbl: dict) -> list:
|
||||
"""표 셀 텍스트에서 불릿 문자 감지"""
|
||||
bullets = set()
|
||||
pats = [
|
||||
(r'^-\s', '- '), (r'^·\s', '· '), (r'^•\s', '• '),
|
||||
(r'^▸\s', '▸ '), (r'^▶\s', '▶ '), (r'^※\s', '※ '),
|
||||
(r'^◈\s', '◈ '), (r'^○\s', '○ '), (r'^●\s', '● '),
|
||||
]
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
for line in cell.get("lines", []):
|
||||
for pat, char in pats:
|
||||
if re.match(pat, line.strip()):
|
||||
bullets.add(char)
|
||||
return sorted(bullets)
|
||||
|
||||
|
||||
def _extract_example_rows(tbl: dict, role_info: dict) -> list:
|
||||
"""데이터 행에서 예시 최대 3행 추출"""
|
||||
rows = tbl.get("rows", [])
|
||||
header_row = role_info.get("header_row")
|
||||
if header_row is None:
|
||||
header_row = -1
|
||||
|
||||
examples = []
|
||||
for r_idx, row in enumerate(rows):
|
||||
if r_idx <= header_row:
|
||||
continue
|
||||
row_data = []
|
||||
for cell in row:
|
||||
text = cell.get("text", "").strip()
|
||||
if len(text) > 80:
|
||||
text = text[:77] + "..."
|
||||
row_data.append(text)
|
||||
examples.append(row_data)
|
||||
if len(examples) >= 3:
|
||||
break
|
||||
return examples
|
||||
|
||||
|
||||
def _classify_columns(col_headers: list) -> list:
|
||||
"""열 헤더 키워드로 용도 추론"""
|
||||
type_map = {
|
||||
"category": ['구분', '분류', '항목', '카테고리'],
|
||||
"content": ['내용', '설명', '상세', '세부내용'],
|
||||
"note": ['비고', '참고', '기타', '메모'],
|
||||
"date": ['날짜', '일자', '일시', '기간'],
|
||||
"person": ['담당', '담당자', '작성자', '책임'],
|
||||
"number": ['수량', '금액', '단가', '합계'],
|
||||
}
|
||||
result = []
|
||||
for c_idx, header in enumerate(col_headers):
|
||||
h = header.strip()
|
||||
col_type = "text"
|
||||
for t, keywords in type_map.items():
|
||||
if h in keywords:
|
||||
col_type = t
|
||||
break
|
||||
result.append({"col": c_idx, "type": col_type, "header": h})
|
||||
return result
|
||||
|
||||
def _extract_row_bf_pattern(tbl: dict, role_info: dict) -> list:
|
||||
"""첫 데이터행의 셀별 borderFillIDRef → 열별 bf class 패턴.
|
||||
|
||||
AI가 TABLE_BODY <td> 생성 시 class="bf-{id}" 적용하도록 안내.
|
||||
예: [{"col": 0, "bf_class": "bf-12"}, {"col": 1, "bf_class": "bf-8"}, ...]
|
||||
"""
|
||||
rows = tbl.get("rows", [])
|
||||
header_row = role_info.get("header_row")
|
||||
if header_row is None:
|
||||
header_row = -1
|
||||
|
||||
# 첫 데이터행 찾기
|
||||
for r_idx, row in enumerate(rows):
|
||||
if r_idx <= header_row:
|
||||
continue
|
||||
pattern = []
|
||||
for cell in row:
|
||||
bf_id = cell.get("borderFillIDRef")
|
||||
pattern.append({
|
||||
"col": cell.get("colAddr", len(pattern)),
|
||||
"bf_class": f"bf-{bf_id}" if bf_id else "",
|
||||
"colSpan": cell.get("colSpan", 1),
|
||||
"rowSpan": cell.get("rowSpan", 1),
|
||||
})
|
||||
return pattern
|
||||
|
||||
return []
|
||||
# ================================================================
|
||||
# 작성 패턴 분석
|
||||
# ================================================================
|
||||
|
||||
def _analyze_writing_patterns(template_info: dict,
|
||||
semantic_map: dict) -> dict:
|
||||
"""문서 전체의 작성 패턴 분석"""
|
||||
result = {
|
||||
"bullet_styles": [],
|
||||
"numbering_patterns": [],
|
||||
"avg_line_length": 0,
|
||||
"font_primary": "",
|
||||
"font_size_body": ""
|
||||
}
|
||||
|
||||
# ── 불릿 수집 (모든 표 텍스트) ──
|
||||
all_bullets = set()
|
||||
tables = template_info.get("tables", [])
|
||||
for tbl in tables:
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
for line in cell.get("lines", []):
|
||||
if re.match(r'^[-·•▸▶※◈○●]\s', line.strip()):
|
||||
all_bullets.add(line.strip()[0] + " ")
|
||||
|
||||
# ── numbering tools 데이터 ──
|
||||
numbering = template_info.get("numbering", {})
|
||||
for num in numbering.get("numberings", []):
|
||||
levels = num.get("levels", [])
|
||||
patterns = [lv.get("pattern", "") for lv in levels[:3]]
|
||||
if patterns:
|
||||
result["numbering_patterns"].append(patterns)
|
||||
|
||||
for b in numbering.get("bullets", []):
|
||||
char = b.get("char", "")
|
||||
if char:
|
||||
all_bullets.add(char + " ")
|
||||
|
||||
result["bullet_styles"] = sorted(all_bullets)
|
||||
|
||||
# ── 평균 라인 길이 ──
|
||||
lengths = []
|
||||
for tbl in tables:
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
for line in cell.get("lines", []):
|
||||
if line.strip():
|
||||
lengths.append(len(line.strip()))
|
||||
|
||||
# content_order 문단 텍스트도 포함
|
||||
content_order = template_info.get("content_order", [])
|
||||
for item in content_order:
|
||||
if item['type'] == 'paragraph':
|
||||
text = item.get('text', '').strip()
|
||||
if text:
|
||||
lengths.append(len(text))
|
||||
# 불릿 감지도 추가
|
||||
if re.match(r'^[-·•▸▶※◈○●]\s', text):
|
||||
all_bullets.add(text[0] + " ")
|
||||
|
||||
if lengths:
|
||||
result["avg_line_length"] = round(sum(lengths) / len(lengths))
|
||||
|
||||
# ── 주요 폰트 ──
|
||||
fonts = template_info.get("fonts", {})
|
||||
hangul = fonts.get("HANGUL", [])
|
||||
if hangul and isinstance(hangul, list) and len(hangul) > 0:
|
||||
result["font_primary"] = hangul[0].get("face", "")
|
||||
|
||||
# ── 본문 글자 크기 (char_styles id=0 기본) ──
|
||||
char_styles = template_info.get("char_styles", [])
|
||||
if char_styles:
|
||||
result["font_size_body"] = f"{char_styles[0].get('height_pt', 10)}pt"
|
||||
|
||||
return result
|
||||
555
03. Code/geulbeot_10th/handlers/doc/custom_doc_type.py
Normal file
555
03. Code/geulbeot_10th/handlers/doc/custom_doc_type.py
Normal file
@@ -0,0 +1,555 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
사용자 정의 문서 유형 프로세서 (v2.1 - 템플릿 기반)
|
||||
- template.html 로드
|
||||
- config.json의 구조/가이드 활용
|
||||
- 사용자 입력 내용을 템플릿에 정리하여 채움
|
||||
- 창작 X, 정리/재구성 O
|
||||
|
||||
★ v2.1 변경사항:
|
||||
- 한글 포함 placeholder 지원 (TABLE_1_H_구분 등)
|
||||
- TABLE_*_BODY / TABLE_*_H_* placeholder 구분 처리
|
||||
- 개조식 항목 <ul class="bullet-list"> 래핑
|
||||
- 페이지 분량 제한 프롬프트 강화
|
||||
- 헤더/푸터 다중행 placeholder 설명 추가
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from handlers.template.template_manager import TemplateManager
|
||||
from pathlib import Path
|
||||
from handlers.common import call_claude, extract_html
|
||||
|
||||
|
||||
# ★ 한글 포함 placeholder 정규식 (영문 + 숫자 + 언더스코어 + 한글)
|
||||
PH_PATTERN = re.compile(r'\{\{([A-Za-z0-9_\uAC00-\uD7AF]+)\}\}')
|
||||
|
||||
|
||||
class CustomDocTypeProcessor:
|
||||
"""사용자 정의 문서 유형 처리기 (템플릿 기반)"""
|
||||
|
||||
def __init__(self):
|
||||
self.doc_types_user = Path('templates/user/doc_types')
|
||||
self.template_manager = TemplateManager()
|
||||
|
||||
def load_config(self, doc_type_id: str) -> dict:
|
||||
"""config.json 로드"""
|
||||
config_path = self.doc_types_user / doc_type_id / 'config.json'
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"문서 유형을 찾을 수 없습니다: {doc_type_id}")
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_content_prompt(self, doc_type_id: str, template_id: str = None) -> dict:
|
||||
"""content_prompt.json 로드 (doc_type 우선 → template fallback)"""
|
||||
# ① doc_type 폴더
|
||||
path = self.doc_types_user / doc_type_id / 'content_prompt.json'
|
||||
if path.exists():
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
# ② template 폴더 fallback
|
||||
if template_id:
|
||||
tpl_path = Path('templates/user/templates') / template_id / 'content_prompt.json'
|
||||
if tpl_path.exists():
|
||||
with open(tpl_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
return {}
|
||||
|
||||
def load_template(self, doc_type_id: str) -> str:
|
||||
"""template.html 로드 — template_manager 경유 (분리 구조)"""
|
||||
# ① config에서 template_id 확인
|
||||
config = self.load_config(doc_type_id)
|
||||
tpl_id = config.get("template_id")
|
||||
|
||||
if tpl_id:
|
||||
# ★ 새 구조: template_manager에서 로드
|
||||
tpl_data = self.template_manager.load_template(tpl_id)
|
||||
if "html" in tpl_data:
|
||||
return tpl_data["html"]
|
||||
|
||||
# ★ 하위 호환: 레거시 방식 (같은 폴더의 template.html)
|
||||
template_path = self.doc_types_user / doc_type_id / 'template.html'
|
||||
if template_path.exists():
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
return None
|
||||
|
||||
def generate(self, content: str, doc_type_id: str, options: dict = None,
|
||||
image_data: dict = None) -> dict:
|
||||
"""문서 생성 - 템플릿 + 사용자 입력
|
||||
|
||||
Args:
|
||||
content: 사용자 입력 텍스트
|
||||
doc_type_id: 문서 유형 ID
|
||||
options: 추가 옵션 (instruction 등)
|
||||
image_data: 이미지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}}
|
||||
None이면 템플릿 폴더에서 자동 로드 시도
|
||||
"""
|
||||
try:
|
||||
config = self.load_config(doc_type_id)
|
||||
template = self.load_template(doc_type_id)
|
||||
|
||||
if template:
|
||||
# 이미지 데이터 준비
|
||||
if image_data is None:
|
||||
image_data = self._load_image_data(config)
|
||||
result = self._generate_with_template(
|
||||
content, config, template, options, image_data
|
||||
)
|
||||
else:
|
||||
result = self._generate_with_guide(content, config, options)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def _generate_with_template(self, content: str, config: dict,
|
||||
template: str, options: dict,
|
||||
image_data: dict = None) -> dict:
|
||||
"""템플릿 기반 생성 — content_prompt.json 활용"""
|
||||
|
||||
context = config.get('context', {})
|
||||
structure = config.get('structure', {})
|
||||
instruction = options.get('instruction', '') if options else ''
|
||||
|
||||
# ★ content_prompt 로드
|
||||
doc_type_id = config.get('id', '')
|
||||
template_id = config.get('template_id', '')
|
||||
cp = self.load_content_prompt(doc_type_id, template_id)
|
||||
|
||||
placeholders_info = cp.get('placeholders', {})
|
||||
table_guide = cp.get('table_guide', {})
|
||||
writing_guide = cp.get('writing_guide', {})
|
||||
doc_info = cp.get('document', {})
|
||||
|
||||
# ★ placeholder 가이드 생성 (type/pattern/example 포함)
|
||||
ph_guide_lines = []
|
||||
for ph_key, ph_info in placeholders_info.items():
|
||||
ph_type = ph_info.get('type', 'text')
|
||||
pattern = ph_info.get('pattern', '')
|
||||
example = ph_info.get('example', '')
|
||||
location = ph_info.get('location', '')
|
||||
|
||||
line = f" {ph_key}:"
|
||||
line += f"\n type: {ph_type}"
|
||||
line += f"\n pattern: {pattern}"
|
||||
if example:
|
||||
line += f"\n example: \"{example}\""
|
||||
line += f"\n location: {location}"
|
||||
ph_guide_lines.append(line)
|
||||
|
||||
ph_guide = "\n".join(ph_guide_lines) if ph_guide_lines else "(no guide available)"
|
||||
|
||||
# ★ 표 가이드 생성
|
||||
tbl_guide_lines = []
|
||||
for tbl_num, tbl_info in table_guide.items():
|
||||
headers = tbl_info.get('col_headers', [])
|
||||
col_types = tbl_info.get('col_types', [])
|
||||
merge = tbl_info.get('merge_pattern', {})
|
||||
bullets = tbl_info.get('bullet_chars', [])
|
||||
examples = tbl_info.get('example_rows', [])
|
||||
|
||||
tbl_guide_lines.append(f"\n### Table {tbl_num}:")
|
||||
tbl_guide_lines.append(f" Columns: {json.dumps(headers, ensure_ascii=False)}")
|
||||
if col_types:
|
||||
for ct in col_types:
|
||||
tbl_guide_lines.append(
|
||||
f" Col {ct['col']} '{ct['header']}': {ct['type']}")
|
||||
if merge:
|
||||
tbl_guide_lines.append(f" Merge: {json.dumps(merge, ensure_ascii=False)}")
|
||||
tbl_guide_lines.append(
|
||||
f" → row_group means: use rowspan to group rows by that column")
|
||||
if bullets:
|
||||
tbl_guide_lines.append(f" Bullet chars: {bullets}")
|
||||
|
||||
# ★ row_bf_pattern 추가
|
||||
bf_pattern = tbl_info.get('row_bf_pattern', [])
|
||||
if bf_pattern:
|
||||
tbl_guide_lines.append(f" Row cell classes (apply to each <td>):")
|
||||
for bp in bf_pattern:
|
||||
col = bp.get('col', '?')
|
||||
bf_cls = bp.get('bf_class', '')
|
||||
cs = bp.get('colSpan', 1)
|
||||
rs = bp.get('rowSpan', 1)
|
||||
span_info = ""
|
||||
if cs > 1: span_info += f" colSpan={cs}"
|
||||
if rs > 1: span_info += f" rowSpan={rs}"
|
||||
tbl_guide_lines.append(
|
||||
f' col_{col}: class="{bf_cls}"{span_info}')
|
||||
|
||||
if examples:
|
||||
tbl_guide_lines.append(f" Example rows:")
|
||||
for ex in examples[:2]:
|
||||
tbl_guide_lines.append(
|
||||
f" {json.dumps(ex, ensure_ascii=False)}")
|
||||
|
||||
tbl_guide = "\n".join(tbl_guide_lines) if tbl_guide_lines else "No table guide"
|
||||
|
||||
# ★ 페이지 추정
|
||||
page_estimate = structure.get('pageEstimate', 1)
|
||||
|
||||
# ★ placeholder 키 목록 (from template)
|
||||
placeholders = PH_PATTERN.findall(template)
|
||||
placeholders = list(dict.fromkeys(placeholders))
|
||||
|
||||
prompt = f"""Fill the template placeholders with reorganized content.
|
||||
|
||||
## Document Definition
|
||||
{context.get('documentDefinition', 'structured document')}
|
||||
|
||||
## Context
|
||||
- Type: {context.get('documentType', '')}
|
||||
- Purpose: {context.get('purpose', '')}
|
||||
- Audience: {context.get('audience', '')}
|
||||
- Tone: {context.get('tone', '')}
|
||||
- Layout: {doc_info.get('layout', 'portrait')}
|
||||
- Page limit: {page_estimate} page(s). Be CONCISE.
|
||||
|
||||
## Writing Style
|
||||
- Bullet chars: {writing_guide.get('bullet_styles', ['- ', '· '])}
|
||||
- Primary font: {writing_guide.get('font_primary', '')}
|
||||
- Keep lines ~{writing_guide.get('avg_line_length', 25)} chars average
|
||||
|
||||
## Placeholder Guide (type, pattern, example for each)
|
||||
{ph_guide}
|
||||
|
||||
## Table Structure Guide
|
||||
{tbl_guide}
|
||||
|
||||
## Input Content
|
||||
{content[:6000] if content else '(empty)'}
|
||||
|
||||
## Additional Instructions
|
||||
{instruction if instruction else 'None'}
|
||||
|
||||
## ALL Placeholders to fill (JSON keys):
|
||||
{json.dumps(placeholders, ensure_ascii=False)}
|
||||
|
||||
## ★ Critical Rules
|
||||
1. Output ONLY valid JSON — every placeholder above as a key
|
||||
2. HEADER/FOOTER: use the PATTERN and modify the EXAMPLE for new content
|
||||
- department → user's department or keep example
|
||||
- author → user's name or keep example
|
||||
- date → today's date in same format
|
||||
- slogan → keep exactly as example
|
||||
3. TITLE: create title matching doc_title pattern from input content
|
||||
4. TABLE_*_H_*: plain text column headers (use col_headers from guide)
|
||||
5. TABLE_*_BODY: HTML <tr> rows only (no <table> wrapper)
|
||||
- Follow merge_pattern: row_group → use rowspan
|
||||
- Use bullet_chars from guide inside cells
|
||||
- Match example_rows structure
|
||||
5b. TABLE_*_BODY <td>: apply class from 'Row cell classes' guide\n
|
||||
- e.g. <td class=\"bf-12\">content</td>\n
|
||||
6. SECTION_*_CONTENT: use bullet style from writing guide
|
||||
7. Empty string "" for inapplicable placeholders
|
||||
8. Do NOT invent content — reorganize input only
|
||||
9. PARA_*: reorganize input text for each paragraph placeholder
|
||||
- Keep the meaning, improve clarity and structure
|
||||
- PARA_n_RUN_m: if a paragraph has multiple runs, fill each run separately
|
||||
10. IMAGE_*: output exactly "KEEP_ORIGINAL" (image is auto-inserted from source)
|
||||
11. IMAGE_*_CAPTION: write a concise caption describing the image context
|
||||
12. Total volume: {page_estimate} page(s)
|
||||
|
||||
Output ONLY valid JSON:"""
|
||||
|
||||
try:
|
||||
response = call_claude(
|
||||
"You fill document template placeholders with reorganized content. "
|
||||
"Output valid JSON only. Respect the template structure exactly.",
|
||||
prompt,
|
||||
max_tokens=6000
|
||||
)
|
||||
|
||||
fill_data = self._extract_json(response)
|
||||
|
||||
if not fill_data:
|
||||
return {'error': 'JSON extraction failed', 'raw': response[:500]}
|
||||
|
||||
html = self._fill_template(template, fill_data, image_data)
|
||||
|
||||
return {'success': True, 'html': html}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def _fill_template(self, template: str, data: dict,
|
||||
image_data: dict = None) -> str:
|
||||
"""템플릿에 데이터 채우기
|
||||
|
||||
Args:
|
||||
template: HTML 템플릿
|
||||
data: AI가 채운 placeholder → value dict
|
||||
image_data: 이미지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}}
|
||||
"""
|
||||
html = template
|
||||
|
||||
# ★ content_prompt에서 IMAGE_n → binaryItemIDRef 매핑 빌드
|
||||
image_ref_map = self._build_image_ref_map(data, image_data)
|
||||
|
||||
for key, value in data.items():
|
||||
placeholder = '{{' + key + '}}'
|
||||
|
||||
# ── IMAGE_n: 원본 이미지 삽입 ──
|
||||
if re.match(r'^IMAGE_\d+$', key):
|
||||
img_tag = image_ref_map.get(key, '')
|
||||
html = html.replace(placeholder, img_tag)
|
||||
continue
|
||||
|
||||
if isinstance(value, str) and value.strip():
|
||||
# ★ 개조식 내용 처리: · 또는 - 로 시작하는 항목
|
||||
lines = value.strip().split('\n')
|
||||
is_bullet_list = sum(
|
||||
1 for l in lines
|
||||
if l.strip().startswith('·') or l.strip().startswith('-')
|
||||
) > len(lines) * 0.5
|
||||
|
||||
if is_bullet_list and len(lines) > 1:
|
||||
# ★ v2.2: inline context (<p><span> 안)에서는 <ul> 금지
|
||||
# PARA_*, SECTION_*_TITLE, HEADER_*, FOOTER_*, TITLE_*, *_RUN_*
|
||||
# 이들은 <p> 또는 <td> 안에 있어 block 요소 삽입 시 HTML 깨짐
|
||||
_is_inline = re.match(
|
||||
r'^(PARA_|SECTION_\d+_TITLE|HEADER_|FOOTER_|TITLE_|.*_RUN_)',
|
||||
key
|
||||
)
|
||||
if _is_inline:
|
||||
# <br> 줄바꿈으로 구조 보존
|
||||
clean_lines = []
|
||||
for item in lines:
|
||||
item = item.strip()
|
||||
if item.startswith('·'):
|
||||
item = item[1:].strip()
|
||||
elif item.startswith('-'):
|
||||
item = item[1:].strip()
|
||||
if item:
|
||||
clean_lines.append(f'· {item}')
|
||||
value = '<br>\n'.join(clean_lines)
|
||||
else:
|
||||
# <div> 안 (SECTION_*_CONTENT 등) → <ul><li> 허용
|
||||
li_items = []
|
||||
for item in lines:
|
||||
item = item.strip()
|
||||
if item.startswith('·'):
|
||||
item = item[1:].strip()
|
||||
elif item.startswith('-'):
|
||||
item = item[1:].strip()
|
||||
if item:
|
||||
li_items.append(f'<li>{item}</li>')
|
||||
value = '<ul class="bullet-list">\n' + '\n'.join(li_items) + '\n</ul>'
|
||||
|
||||
html = html.replace(placeholder, str(value) if value else '')
|
||||
|
||||
# ★ 남은 placeholder 정리 (한글 포함)
|
||||
html = PH_PATTERN.sub('', html)
|
||||
|
||||
return html
|
||||
|
||||
def _build_image_ref_map(self, data: dict, image_data: dict = None) -> dict:
|
||||
"""IMAGE_n placeholder → <img> 태그 매핑 생성.
|
||||
|
||||
content_prompt.json의 placeholders에서 IMAGE_n의 example_ref
|
||||
(= binaryItemIDRef)를 찾고, image_data에서 base64를 가져옴.
|
||||
"""
|
||||
ref_map = {}
|
||||
if not image_data:
|
||||
return ref_map
|
||||
|
||||
# content_prompt placeholders에서 IMAGE_n → ref 매핑
|
||||
# (generate 호출 시 content_prompt를 아직 안 가지고 있으므로
|
||||
# template HTML의 data-ref 속성 또는 순서 매칭으로 해결)
|
||||
# 방법: template에서 IMAGE_1, IMAGE_2... 순서와
|
||||
# image_data의 키 순서를 매칭
|
||||
|
||||
# image_data 키 목록 (BinData 등장 순서)
|
||||
img_refs = sorted(image_data.keys())
|
||||
|
||||
img_num = 0
|
||||
for ref in img_refs:
|
||||
img_num += 1
|
||||
key = f"IMAGE_{img_num}"
|
||||
img_info = image_data[ref]
|
||||
|
||||
b64 = img_info.get("base64", "")
|
||||
mime = img_info.get("mime", "image/png")
|
||||
|
||||
if b64:
|
||||
ref_map[key] = (
|
||||
f'<img src="data:{mime};base64,{b64}" '
|
||||
f'alt="{ref}" style="max-width:100%; height:auto;">'
|
||||
)
|
||||
else:
|
||||
# base64 없으면 파일 경로 참조
|
||||
file_path = img_info.get("path", "")
|
||||
if file_path:
|
||||
ref_map[key] = (
|
||||
f'<img src="{file_path}" '
|
||||
f'alt="{ref}" style="max-width:100%; height:auto;">'
|
||||
)
|
||||
else:
|
||||
ref_map[key] = f'<!-- image not found: {ref} -->'
|
||||
|
||||
return ref_map
|
||||
|
||||
def _load_image_data(self, config: dict) -> dict:
|
||||
"""템플릿 폴더에서 images.json 로드 (BinData 추출 결과).
|
||||
|
||||
images.json 구조:
|
||||
{
|
||||
"IMG001": {"base64": "iVBOR...", "mime": "image/png"},
|
||||
"IMG002": {"base64": "...", "mime": "image/jpeg"}
|
||||
}
|
||||
|
||||
또는 이미지 파일이 직접 저장된 경우 경로를 반환.
|
||||
"""
|
||||
tpl_id = config.get("template_id", "")
|
||||
if not tpl_id:
|
||||
return {}
|
||||
|
||||
tpl_path = Path('templates/user/templates') / tpl_id
|
||||
|
||||
# ① images.json (base64 저장 방식)
|
||||
images_json = tpl_path / 'images.json'
|
||||
if images_json.exists():
|
||||
try:
|
||||
with open(images_json, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# ② images/ 폴더 (파일 저장 방식)
|
||||
images_dir = tpl_path / 'images'
|
||||
if images_dir.exists():
|
||||
result = {}
|
||||
mime_map = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp', '.svg': 'image/svg+xml',
|
||||
'.wmf': 'image/x-wmf', '.emf': 'image/x-emf',
|
||||
}
|
||||
for img_file in sorted(images_dir.iterdir()):
|
||||
if img_file.suffix.lower() in mime_map:
|
||||
ref = img_file.stem # 파일명 = binaryItemIDRef
|
||||
result[ref] = {
|
||||
"path": str(img_file),
|
||||
"mime": mime_map.get(img_file.suffix.lower(), "image/png")
|
||||
}
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
def _extract_json(self, response: str) -> dict:
|
||||
"""응답에서 JSON 추출"""
|
||||
# ```json ... ``` 블록 찾기
|
||||
match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 가장 큰 { } 블록 찾기
|
||||
brace_depth = 0
|
||||
start = -1
|
||||
for i, ch in enumerate(response):
|
||||
if ch == '{':
|
||||
if brace_depth == 0:
|
||||
start = i
|
||||
brace_depth += 1
|
||||
elif ch == '}':
|
||||
brace_depth -= 1
|
||||
if brace_depth == 0 and start >= 0:
|
||||
try:
|
||||
return json.loads(response[start:i+1])
|
||||
except:
|
||||
start = -1
|
||||
|
||||
return None
|
||||
|
||||
def _generate_with_guide(self, content: str, config: dict, options: dict) -> dict:
|
||||
"""가이드 기반 생성 (템플릿 없을 때)"""
|
||||
|
||||
context = config.get('context', {})
|
||||
structure = config.get('structure', {})
|
||||
layout = config.get('layout', {})
|
||||
style = config.get('style', {})
|
||||
|
||||
instruction = options.get('instruction', '') if options else ''
|
||||
|
||||
# 섹션 구조 설명
|
||||
sections = layout.get('sections', [])
|
||||
sections_desc = ""
|
||||
for i, sec in enumerate(sections, 1):
|
||||
sections_desc += f"""
|
||||
{i}. {sec.get('name', f'섹션{i}')}
|
||||
- 작성 스타일: {sec.get('writingStyle', '혼합')}
|
||||
- 불릿: {'있음' if sec.get('hasBulletIcon') else '없음'}
|
||||
- 표: {'있음' if sec.get('hasTable') else '없음'}
|
||||
- 내용: {sec.get('contentDescription', '')}
|
||||
"""
|
||||
|
||||
page_estimate = structure.get('pageEstimate', 1)
|
||||
|
||||
system_prompt = f"""당신은 "{context.get('documentType', '문서')}" 작성 전문가입니다.
|
||||
|
||||
## 문서 특성
|
||||
- 목적: {context.get('purpose', '')}
|
||||
- 대상: {context.get('audience', '')}
|
||||
- 톤: {context.get('tone', '')}
|
||||
- 전체 스타일: {structure.get('writingStyle', '혼합')}
|
||||
- 분량: 약 {page_estimate}페이지
|
||||
|
||||
## 문서 구조
|
||||
{sections_desc}
|
||||
|
||||
## 작성 원칙
|
||||
{chr(10).join('- ' + p for p in structure.get('writingPrinciples', []))}
|
||||
|
||||
## 주의사항
|
||||
{chr(10).join('- ' + m for m in structure.get('commonMistakes', []))}
|
||||
|
||||
## 핵심!
|
||||
- 사용자 입력을 **정리/재구성**하세요
|
||||
- **새로 창작하지 마세요**
|
||||
- 분석된 문서 구조를 그대로 따르세요
|
||||
- 개조식 섹션은 "· " 불릿 사용
|
||||
- 분량을 {page_estimate}페이지 내로 제한하세요"""
|
||||
|
||||
user_prompt = f"""다음 내용을 "{context.get('documentType', '문서')}" 양식으로 정리해주세요.
|
||||
|
||||
## 입력 내용
|
||||
{content[:6000] if content else '(내용 없음)'}
|
||||
|
||||
## 추가 요청
|
||||
{instruction if instruction else '없음'}
|
||||
|
||||
## 출력 형식
|
||||
완전한 A4 규격 HTML 문서로 출력하세요.
|
||||
- <!DOCTYPE html>로 시작
|
||||
- UTF-8 인코딩
|
||||
- @page {{ size: A4 }} CSS 포함
|
||||
- 폰트: {style.get('font', {}).get('name', '맑은 고딕')}
|
||||
- 머릿말/꼬리말 포함
|
||||
- 약 {page_estimate}페이지 분량
|
||||
|
||||
HTML만 출력하세요."""
|
||||
|
||||
try:
|
||||
response = call_claude(system_prompt, user_prompt, max_tokens=6000)
|
||||
html = extract_html(response)
|
||||
|
||||
if not html:
|
||||
return {'error': 'HTML 생성 실패'}
|
||||
|
||||
return {'success': True, 'html': html}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
1058
03. Code/geulbeot_10th/handlers/doc/doc_type_analyzer.py
Normal file
1058
03. Code/geulbeot_10th/handlers/doc/doc_type_analyzer.py
Normal file
File diff suppressed because it is too large
Load Diff
5
03. Code/geulbeot_10th/handlers/report/__init__.py
Normal file
5
03. Code/geulbeot_10th/handlers/report/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 모듈
|
||||
"""
|
||||
from .processor import ReportProcessor
|
||||
161
03. Code/geulbeot_10th/handlers/report/processor.py
Normal file
161
03. Code/geulbeot_10th/handlers/report/processor.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 로직
|
||||
- 다페이지 보고서
|
||||
- 원본 구조 유지
|
||||
- RAG 파이프라인 연동 (긴 문서)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from flask import session
|
||||
|
||||
from handlers.common import call_claude, extract_html, load_prompt, client
|
||||
from converters.pipeline.router import process_document, convert_image_paths
|
||||
|
||||
|
||||
class ReportProcessor:
|
||||
"""보고서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""보고서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용이 비어있습니다.'}
|
||||
|
||||
# ⭐ 템플릿 스타일 로드
|
||||
template_id = options.get('template_id')
|
||||
if template_id:
|
||||
from handlers.template import TemplateProcessor
|
||||
template_processor = TemplateProcessor()
|
||||
style = template_processor.get_style(template_id)
|
||||
if style and style.get('css'):
|
||||
options['template_css'] = style['css']
|
||||
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(content)
|
||||
|
||||
# router를 통해 분량에 따라 파이프라인 분기
|
||||
result = process_document(processed_html, options)
|
||||
|
||||
if result.get('success'):
|
||||
session['original_html'] = content
|
||||
session['current_html'] = result.get('html', '')
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. **페이지 구조(sheet, body-content, page-header 등)는 절대 변경하지 마세요**
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정 (보고서용 - 페이지 구조 보존)"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html[:5000]}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. **절대로 페이지 구조(sheet, body-content, page-header, page-footer)를 변경하지 마세요**
|
||||
2. 선택된 텍스트만 수정하고, 주변 HTML 태그는 그대로 유지
|
||||
3. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가 등)
|
||||
|
||||
4. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용만 - 선택된 텍스트의 수정본만)
|
||||
|
||||
5. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이, 선택된 텍스트의 수정본만)
|
||||
6. STRUCTURE인 경우: 해당 요소만 출력 (전체 페이지 구조 X)
|
||||
7. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
3
03. Code/geulbeot_10th/handlers/template/__init__.py
Normal file
3
03. Code/geulbeot_10th/handlers/template/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .processor import TemplateProcessor
|
||||
|
||||
__all__ = ['TemplateProcessor']
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
문서 템플릿 분석기 v5.1 (오케스트레이터)
|
||||
|
||||
역할: tools/ 모듈을 조합하여 HWPX → 템플릿 정보 추출
|
||||
- 직접 파싱 로직 없음 (모두 tools에 위임)
|
||||
- 디폴트값 생성 없음 (tools가 None 반환하면 결과에서 제외)
|
||||
- 사용자 추가 사항(config.json) → 템플릿에도 반영
|
||||
|
||||
구조:
|
||||
tools/
|
||||
page_setup.py §7 용지/여백
|
||||
font.py §3 글꼴
|
||||
char_style.py §4 글자 모양
|
||||
para_style.py §5 문단 모양
|
||||
border_fill.py §2 테두리/배경
|
||||
table.py §6 표
|
||||
header_footer.py §8 머리말/꼬리말
|
||||
section.py §9 구역 정의
|
||||
style_def.py 스타일 정의
|
||||
numbering.py 번호매기기/글머리표
|
||||
image.py 이미지
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .tools import (
|
||||
page_setup,
|
||||
font,
|
||||
char_style,
|
||||
para_style,
|
||||
border_fill,
|
||||
table,
|
||||
header_footer,
|
||||
section,
|
||||
style_def,
|
||||
numbering,
|
||||
image,
|
||||
content_order,
|
||||
)
|
||||
|
||||
|
||||
class DocTemplateAnalyzer:
|
||||
"""HWPX → 템플릿 추출 오케스트레이터"""
|
||||
|
||||
# ================================================================
|
||||
# Phase 1: 추출 (모든 tools 호출)
|
||||
# ================================================================
|
||||
|
||||
def analyze(self, parsed: dict) -> dict:
|
||||
"""HWPX parsed 결과에서 템플릿 구조 추출.
|
||||
|
||||
Args:
|
||||
parsed: processor.py가 HWPX를 파싱한 결과 dict.
|
||||
raw_xml, section_xml, header_xml, footer_xml,
|
||||
tables, paragraphs 등 포함.
|
||||
|
||||
Returns:
|
||||
추출된 항목만 포함하는 dict (None인 항목은 제외).
|
||||
"""
|
||||
raw_xml = parsed.get("raw_xml", {})
|
||||
|
||||
extractors = {
|
||||
"page": lambda: page_setup.extract(raw_xml, parsed),
|
||||
"fonts": lambda: font.extract(raw_xml, parsed),
|
||||
"char_styles": lambda: char_style.extract(raw_xml, parsed),
|
||||
"para_styles": lambda: para_style.extract(raw_xml, parsed),
|
||||
"border_fills": lambda: border_fill.extract(raw_xml, parsed),
|
||||
"tables": lambda: table.extract(raw_xml, parsed),
|
||||
"header": lambda: header_footer.extract_header(raw_xml, parsed),
|
||||
"footer": lambda: header_footer.extract_footer(raw_xml, parsed),
|
||||
"section": lambda: section.extract(raw_xml, parsed),
|
||||
"styles": lambda: style_def.extract(raw_xml, parsed),
|
||||
"numbering": lambda: numbering.extract(raw_xml, parsed),
|
||||
"images": lambda: image.extract(raw_xml, parsed),
|
||||
"content_order":lambda: content_order.extract(raw_xml, parsed),
|
||||
}
|
||||
|
||||
result = {}
|
||||
for key, extractor in extractors.items():
|
||||
try:
|
||||
value = extractor()
|
||||
if value is not None:
|
||||
result[key] = value
|
||||
except Exception as e:
|
||||
# 개별 tool 실패 시 로그만, 전체 중단 안 함
|
||||
result.setdefault("_errors", []).append(
|
||||
f"{key}: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Phase 2: 사용자 추가 사항 병합
|
||||
# ================================================================
|
||||
|
||||
def merge_user_config(self, template_info: dict,
|
||||
config: dict) -> dict:
|
||||
"""config.json의 사용자 요구사항을 template_info에 병합.
|
||||
|
||||
사용자가 문서 유형 추가 시 지정한 커스텀 사항을 반영:
|
||||
- 색상 오버라이드
|
||||
- 글꼴 오버라이드
|
||||
- 제목 크기 오버라이드
|
||||
- 기타 레이아웃 커스텀
|
||||
|
||||
이 병합 결과는 style.json에 저장되고,
|
||||
이후 template.html 생성 시에도 반영됨.
|
||||
|
||||
Args:
|
||||
template_info: analyze()의 결과
|
||||
config: config.json 내용
|
||||
|
||||
Returns:
|
||||
병합된 template_info (원본 수정됨)
|
||||
"""
|
||||
user_overrides = config.get("user_overrides", {})
|
||||
if not user_overrides:
|
||||
return template_info
|
||||
|
||||
# 모든 사용자 오버라이드를 template_info에 기록
|
||||
template_info["user_overrides"] = user_overrides
|
||||
|
||||
return template_info
|
||||
|
||||
# ================================================================
|
||||
# Phase 3: template_info → style.json 저장
|
||||
# ================================================================
|
||||
|
||||
def save_style(self, template_info: dict,
|
||||
save_path: Path) -> Path:
|
||||
"""template_info를 style.json으로 저장.
|
||||
|
||||
Args:
|
||||
template_info: analyze() + merge_user_config() 결과
|
||||
save_path: 저장 경로 (예: templates/user/{doc_type}/style.json)
|
||||
|
||||
Returns:
|
||||
저장된 파일 경로
|
||||
"""
|
||||
save_path = Path(save_path)
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(save_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template_info, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return save_path
|
||||
1442
03. Code/geulbeot_10th/handlers/template/html_table_template_css.txt
Normal file
1442
03. Code/geulbeot_10th/handlers/template/html_table_template_css.txt
Normal file
File diff suppressed because it is too large
Load Diff
625
03. Code/geulbeot_10th/handlers/template/processor.py
Normal file
625
03. Code/geulbeot_10th/handlers/template/processor.py
Normal file
@@ -0,0 +1,625 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
템플릿 처리 로직 (v3 - 실제 구조 정확 분석)
|
||||
- HWPX 파일의 실제 표 구조, 이미지 배경, 테두리 정확히 추출
|
||||
- ARGB 8자리 색상 정규화
|
||||
- NONE 테두리 색상 제외
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import shutil
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# 템플릿 저장 경로
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates' / 'user' / 'templates'
|
||||
TEMPLATES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# HWP 명세서 기반 상수
|
||||
LINE_TYPES = {
|
||||
'NONE': '없음',
|
||||
'SOLID': '실선',
|
||||
'DASH': '긴 점선',
|
||||
'DOT': '점선',
|
||||
'DASH_DOT': '-.-.-.-.',
|
||||
'DASH_DOT_DOT': '-..-..-..',
|
||||
'DOUBLE_SLIM': '2중선',
|
||||
'SLIM_THICK': '가는선+굵은선',
|
||||
'THICK_SLIM': '굵은선+가는선',
|
||||
'SLIM_THICK_SLIM': '가는선+굵은선+가는선',
|
||||
'WAVE': '물결',
|
||||
'DOUBLE_WAVE': '물결 2중선',
|
||||
}
|
||||
|
||||
|
||||
class TemplateProcessor:
|
||||
"""템플릿 처리 클래스 (v3)"""
|
||||
|
||||
NS = {
|
||||
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
|
||||
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
|
||||
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
|
||||
'hs': 'http://www.hancom.co.kr/hwpml/2011/section',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = TEMPLATES_DIR
|
||||
self.templates_dir.mkdir(exist_ok=True)
|
||||
|
||||
# =========================================================================
|
||||
# 공개 API
|
||||
# =========================================================================
|
||||
|
||||
def get_list(self) -> Dict[str, Any]:
|
||||
"""저장된 템플릿 목록"""
|
||||
templates = []
|
||||
for item in self.templates_dir.iterdir():
|
||||
if item.is_dir():
|
||||
meta_path = item / 'meta.json'
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding='utf-8'))
|
||||
templates.append({
|
||||
'id': meta.get('id', item.name),
|
||||
'name': meta.get('name', item.name),
|
||||
'features': meta.get('features', []),
|
||||
'created_at': meta.get('created_at', '')
|
||||
})
|
||||
except:
|
||||
pass
|
||||
templates.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
return {'templates': templates}
|
||||
|
||||
def analyze(self, file, name: str) -> Dict[str, Any]:
|
||||
"""템플릿 파일 분석 및 저장"""
|
||||
filename = file.filename
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext not in ['.hwpx', '.hwp', '.pdf']:
|
||||
return {'error': f'지원하지 않는 파일 형식: {ext}'}
|
||||
|
||||
template_id = str(uuid.uuid4())[:8]
|
||||
template_dir = self.templates_dir / template_id
|
||||
template_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
original_path = template_dir / f'original{ext}'
|
||||
file.save(str(original_path))
|
||||
|
||||
if ext == '.hwpx':
|
||||
style_data = self._analyze_hwpx(original_path, template_dir)
|
||||
else:
|
||||
style_data = self._analyze_fallback(ext)
|
||||
|
||||
if 'error' in style_data:
|
||||
shutil.rmtree(template_dir)
|
||||
return style_data
|
||||
|
||||
# 특징 추출
|
||||
features = self._extract_features(style_data)
|
||||
|
||||
# 메타 저장
|
||||
meta = {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'original_file': filename,
|
||||
'file_type': ext,
|
||||
'features': features,
|
||||
'created_at': datetime.now().isoformat()
|
||||
}
|
||||
(template_dir / 'meta.json').write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# 스타일 저장
|
||||
(template_dir / 'style.json').write_text(
|
||||
json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# CSS 저장
|
||||
css = style_data.get('css', '')
|
||||
css_dir = template_dir / 'css'
|
||||
css_dir.mkdir(exist_ok=True)
|
||||
(css_dir / 'template.css').write_text(css, encoding='utf-8')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'template': {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'features': features,
|
||||
'created_at': meta['created_at']
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
if template_dir.exists():
|
||||
shutil.rmtree(template_dir)
|
||||
raise e
|
||||
|
||||
def delete(self, template_id: str) -> Dict[str, Any]:
|
||||
"""템플릿 삭제"""
|
||||
template_dir = self.templates_dir / template_id
|
||||
if not template_dir.exists():
|
||||
return {'error': '템플릿을 찾을 수 없습니다'}
|
||||
shutil.rmtree(template_dir)
|
||||
return {'success': True, 'deleted': template_id}
|
||||
|
||||
def get_style(self, template_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""템플릿 스타일 반환"""
|
||||
style_path = self.templates_dir / template_id / 'style.json'
|
||||
if not style_path.exists():
|
||||
return None
|
||||
return json.loads(style_path.read_text(encoding='utf-8'))
|
||||
|
||||
# =========================================================================
|
||||
# HWPX 분석 (핵심)
|
||||
# =========================================================================
|
||||
|
||||
def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]:
|
||||
"""HWPX 분석 - 실제 구조 정확히 추출"""
|
||||
extract_dir = template_dir / 'extracted'
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
result = {
|
||||
'version': 'v3',
|
||||
'fonts': {},
|
||||
'colors': {
|
||||
'background': [],
|
||||
'border': [],
|
||||
'text': []
|
||||
},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {},
|
||||
'css': ''
|
||||
}
|
||||
|
||||
# 1. header.xml 분석
|
||||
header_path = extract_dir / 'Contents' / 'header.xml'
|
||||
if header_path.exists():
|
||||
self._parse_header(header_path, result)
|
||||
|
||||
# 2. section0.xml 분석
|
||||
section_path = extract_dir / 'Contents' / 'section0.xml'
|
||||
if section_path.exists():
|
||||
self._parse_section(section_path, result)
|
||||
|
||||
# 3. 스타일 요약 생성
|
||||
result['style_summary'] = self._create_style_summary(result)
|
||||
|
||||
# 4. CSS 생성
|
||||
result['css'] = self._generate_css(result)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
def _parse_header(self, header_path: Path, result: Dict):
|
||||
"""header.xml 파싱 - 폰트, borderFill"""
|
||||
tree = ET.parse(header_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# 폰트
|
||||
for fontface in root.findall('.//hh:fontface', self.NS):
|
||||
if fontface.get('lang') == 'HANGUL':
|
||||
for font in fontface.findall('hh:font', self.NS):
|
||||
result['fonts'][font.get('id')] = font.get('face')
|
||||
|
||||
# borderFill
|
||||
for bf in root.findall('.//hh:borderFill', self.NS):
|
||||
bf_id = bf.get('id')
|
||||
bf_data = self._parse_border_fill(bf, result)
|
||||
result['border_fills'][bf_id] = bf_data
|
||||
|
||||
def _parse_border_fill(self, bf, result: Dict) -> Dict:
|
||||
"""개별 borderFill 파싱"""
|
||||
bf_id = bf.get('id')
|
||||
data = {
|
||||
'id': bf_id,
|
||||
'type': 'empty',
|
||||
'background': None,
|
||||
'image': None,
|
||||
'borders': {}
|
||||
}
|
||||
|
||||
# 이미지 배경
|
||||
img_brush = bf.find('.//hc:imgBrush', self.NS)
|
||||
if img_brush is not None:
|
||||
img = img_brush.find('hc:img', self.NS)
|
||||
if img is not None:
|
||||
data['type'] = 'image'
|
||||
data['image'] = {
|
||||
'ref': img.get('binaryItemIDRef'),
|
||||
'effect': img.get('effect')
|
||||
}
|
||||
|
||||
# 단색 배경
|
||||
win_brush = bf.find('.//hc:winBrush', self.NS)
|
||||
if win_brush is not None:
|
||||
face_color = self._normalize_color(win_brush.get('faceColor'))
|
||||
if face_color and face_color != 'none':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'solid'
|
||||
data['background'] = face_color
|
||||
if face_color not in result['colors']['background']:
|
||||
result['colors']['background'].append(face_color)
|
||||
|
||||
# 4방향 테두리
|
||||
for side in ['top', 'bottom', 'left', 'right']:
|
||||
border = bf.find(f'hh:{side}Border', self.NS)
|
||||
if border is not None:
|
||||
border_type = border.get('type', 'NONE')
|
||||
width = border.get('width', '0.1 mm')
|
||||
color = self._normalize_color(border.get('color', '#000000'))
|
||||
|
||||
data['borders'][side] = {
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'width_mm': self._parse_width(width),
|
||||
'color': color
|
||||
}
|
||||
|
||||
# 보이는 테두리만 색상 수집
|
||||
if border_type != 'NONE':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'border_only'
|
||||
if color and color not in result['colors']['border']:
|
||||
result['colors']['border'].append(color)
|
||||
|
||||
# 특수 테두리 수집
|
||||
if border_type not in ['SOLID', 'NONE']:
|
||||
result['special_borders'].append({
|
||||
'bf_id': bf_id,
|
||||
'side': side,
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'color': color
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def _parse_section(self, section_path: Path, result: Dict):
|
||||
"""section0.xml 파싱 - 표 구조"""
|
||||
tree = ET.parse(section_path)
|
||||
root = tree.getroot()
|
||||
|
||||
border_fills = result['border_fills']
|
||||
|
||||
for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'):
|
||||
table_data = {
|
||||
'rows': int(tbl.get('rowCnt', 0)),
|
||||
'cols': int(tbl.get('colCnt', 0)),
|
||||
'cells': [],
|
||||
'structure': {
|
||||
'header_row_style': None,
|
||||
'first_col_style': None,
|
||||
'body_style': None,
|
||||
'has_image_cells': False
|
||||
}
|
||||
}
|
||||
|
||||
# 셀별 분석
|
||||
cell_by_position = {}
|
||||
for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'):
|
||||
cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr')
|
||||
if cell_addr is None:
|
||||
continue
|
||||
|
||||
row = int(cell_addr.get('rowAddr', 0))
|
||||
col = int(cell_addr.get('colAddr', 0))
|
||||
bf_id = tc.get('borderFillIDRef')
|
||||
bf_info = border_fills.get(bf_id, {})
|
||||
|
||||
# 텍스트 추출
|
||||
text = ''
|
||||
for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'):
|
||||
if t.text:
|
||||
text += t.text
|
||||
|
||||
cell_data = {
|
||||
'row': row,
|
||||
'col': col,
|
||||
'bf_id': bf_id,
|
||||
'bf_type': bf_info.get('type'),
|
||||
'background': bf_info.get('background'),
|
||||
'image': bf_info.get('image'),
|
||||
'text_preview': text[:30] if text else ''
|
||||
}
|
||||
|
||||
table_data['cells'].append(cell_data)
|
||||
cell_by_position[(row, col)] = cell_data
|
||||
|
||||
if bf_info.get('type') == 'image':
|
||||
table_data['structure']['has_image_cells'] = True
|
||||
|
||||
# 구조 분석: 헤더행, 첫열 스타일
|
||||
self._analyze_table_structure(table_data, cell_by_position, border_fills)
|
||||
|
||||
result['tables'].append(table_data)
|
||||
|
||||
def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict):
|
||||
"""표 구조 분석 - 헤더행/첫열 스타일 파악"""
|
||||
rows = table_data['rows']
|
||||
cols = table_data['cols']
|
||||
|
||||
if rows == 0 or cols == 0:
|
||||
return
|
||||
|
||||
# 첫 행 (헤더) 분석
|
||||
header_styles = []
|
||||
for c in range(cols):
|
||||
cell = cells.get((0, c))
|
||||
if cell:
|
||||
header_styles.append(cell.get('bf_id'))
|
||||
|
||||
if header_styles:
|
||||
# 가장 많이 쓰인 스타일
|
||||
most_common = Counter(header_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['header_row_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background'),
|
||||
'borders': bf.get('borders', {})
|
||||
}
|
||||
|
||||
# 첫 열 분석 (행 1부터)
|
||||
first_col_styles = []
|
||||
for r in range(1, rows):
|
||||
cell = cells.get((r, 0))
|
||||
if cell:
|
||||
first_col_styles.append(cell.get('bf_id'))
|
||||
|
||||
if first_col_styles:
|
||||
most_common = Counter(first_col_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['first_col_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background')
|
||||
}
|
||||
|
||||
# 본문 셀 스타일 (첫열 제외)
|
||||
body_styles = []
|
||||
for r in range(1, rows):
|
||||
for c in range(1, cols):
|
||||
cell = cells.get((r, c))
|
||||
if cell:
|
||||
body_styles.append(cell.get('bf_id'))
|
||||
|
||||
if body_styles:
|
||||
most_common = Counter(body_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
table_data['structure']['body_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background') if bf else None
|
||||
}
|
||||
|
||||
def _create_style_summary(self, result: Dict) -> Dict:
|
||||
"""AI 프롬프트용 스타일 요약"""
|
||||
summary = {
|
||||
'폰트': list(result['fonts'].values())[:3],
|
||||
'색상': {
|
||||
'배경색': result['colors']['background'],
|
||||
'테두리색': result['colors']['border']
|
||||
},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
}
|
||||
|
||||
# 표별 스타일 요약
|
||||
for i, tbl in enumerate(result['tables']):
|
||||
tbl_summary = {
|
||||
'표번호': i + 1,
|
||||
'크기': f"{tbl['rows']}행 × {tbl['cols']}열",
|
||||
'이미지셀': tbl['structure']['has_image_cells']
|
||||
}
|
||||
|
||||
header = tbl['structure'].get('header_row_style')
|
||||
if header:
|
||||
tbl_summary['헤더행'] = f"배경={header.get('background')}"
|
||||
|
||||
first_col = tbl['structure'].get('first_col_style')
|
||||
if first_col:
|
||||
tbl_summary['첫열'] = f"배경={first_col.get('background')}"
|
||||
|
||||
body = tbl['structure'].get('body_style')
|
||||
if body:
|
||||
tbl_summary['본문'] = f"배경={body.get('background') or '없음'}"
|
||||
|
||||
summary['표_스타일'].append(tbl_summary)
|
||||
|
||||
# 특수 테두리 요약
|
||||
seen = set()
|
||||
for sb in result['special_borders']:
|
||||
key = f"{sb['type_name']} {sb['width']} {sb['color']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
summary['특수_테두리'].append(key)
|
||||
|
||||
return summary
|
||||
|
||||
def _generate_css(self, result: Dict) -> str:
|
||||
"""CSS 생성 - 실제 구조 반영"""
|
||||
fonts = list(result['fonts'].values())[:2]
|
||||
font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'"
|
||||
|
||||
bg_colors = result['colors']['background']
|
||||
header_bg = bg_colors[0] if bg_colors else '#D6D6D6'
|
||||
|
||||
# 특수 테두리에서 2중선 찾기
|
||||
double_border = None
|
||||
for sb in result['special_borders']:
|
||||
if 'DOUBLE' in sb['type']:
|
||||
double_border = sb
|
||||
break
|
||||
|
||||
css = f"""/* 템플릿 스타일 v3 - HWPX 구조 기반 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {{
|
||||
--font-primary: 'Noto Sans KR', {font_family}, sans-serif;
|
||||
--color-header-bg: {header_bg};
|
||||
--color-border: #000000;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: var(--font-primary);
|
||||
font-size: 10pt;
|
||||
line-height: 1.6;
|
||||
color: #000000;
|
||||
}}
|
||||
|
||||
.sheet {{
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 20mm;
|
||||
margin: 10px auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
|
||||
@media print {{
|
||||
.sheet {{ margin: 0; box-shadow: none; page-break-after: always; }}
|
||||
}}
|
||||
|
||||
/* 표 기본 */
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
border: 0.12mm solid var(--color-border);
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}}
|
||||
|
||||
/* 헤더 행 */
|
||||
thead th, tr:first-child th, tr:first-child td {{
|
||||
background-color: var(--color-header-bg);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
/* 첫 열 (구분 열) - 배경색 */
|
||||
td:first-child {{
|
||||
background-color: var(--color-header-bg);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
/* 본문 셀 - 배경 없음 */
|
||||
td:not(:first-child) {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* 2중선 테두리 (헤더 하단) */
|
||||
thead tr:last-child th,
|
||||
thead tr:last-child td,
|
||||
tr:first-child th,
|
||||
tr:first-child td {{
|
||||
border-bottom: 0.5mm double var(--color-border);
|
||||
}}
|
||||
"""
|
||||
return css
|
||||
|
||||
# =========================================================================
|
||||
# 유틸리티
|
||||
# =========================================================================
|
||||
|
||||
def _normalize_color(self, color: str) -> str:
|
||||
"""ARGB 8자리 → RGB 6자리"""
|
||||
if not color or color == 'none':
|
||||
return color
|
||||
color = color.strip()
|
||||
# #AARRGGBB → #RRGGBB
|
||||
if color.startswith('#') and len(color) == 9:
|
||||
return '#' + color[3:]
|
||||
return color
|
||||
|
||||
def _parse_width(self, width_str: str) -> float:
|
||||
"""너비 문자열 → mm"""
|
||||
if not width_str:
|
||||
return 0.1
|
||||
try:
|
||||
return float(width_str.split()[0])
|
||||
except:
|
||||
return 0.1
|
||||
|
||||
def _extract_features(self, data: Dict) -> List[str]:
|
||||
"""특징 목록"""
|
||||
features = []
|
||||
|
||||
fonts = list(data.get('fonts', {}).values())
|
||||
if fonts:
|
||||
features.append(f"폰트: {', '.join(fonts[:2])}")
|
||||
|
||||
bg_colors = data.get('colors', {}).get('background', [])
|
||||
if bg_colors:
|
||||
features.append(f"배경색: {', '.join(bg_colors[:2])}")
|
||||
|
||||
tables = data.get('tables', [])
|
||||
if tables:
|
||||
has_img = any(t['structure']['has_image_cells'] for t in tables)
|
||||
if has_img:
|
||||
features.append("이미지 배경 셀")
|
||||
|
||||
special = data.get('special_borders', [])
|
||||
if special:
|
||||
types = set(s['type_name'] for s in special)
|
||||
features.append(f"특수 테두리: {', '.join(list(types)[:2])}")
|
||||
|
||||
return features if features else ['기본 템플릿']
|
||||
|
||||
def _analyze_fallback(self, ext: str) -> Dict:
|
||||
"""HWP, PDF 기본 분석"""
|
||||
return {
|
||||
'version': 'v3',
|
||||
'fonts': {'0': '맑은 고딕'},
|
||||
'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {
|
||||
'폰트': ['맑은 고딕'],
|
||||
'색상': {'배경색': [], '테두리색': ['#000000']},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
},
|
||||
'css': self._get_default_css(),
|
||||
'note': f'{ext} 파일은 기본 분석만 지원. HWPX 권장.'
|
||||
}
|
||||
|
||||
def _get_default_css(self) -> str:
|
||||
return """/* 기본 스타일 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; }
|
||||
.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 0.5pt solid #000; padding: 8px; }
|
||||
th { background: #D6D6D6; }
|
||||
"""
|
||||
@@ -0,0 +1,28 @@
|
||||
당신은 문서 템플릿 분석 전문가입니다.
|
||||
|
||||
주어진 HWPX/HWP/PDF 템플릿의 구조를 분석하여 다음 정보를 추출해주세요:
|
||||
|
||||
1. 제목 스타일 (H1~H6)
|
||||
- 폰트명, 크기(pt), 굵기, 색상
|
||||
- 정렬 방식
|
||||
- 번호 체계 (제1장, 1.1, 가. 등)
|
||||
|
||||
2. 본문 스타일
|
||||
- 기본 폰트, 크기, 줄간격
|
||||
- 들여쓰기
|
||||
|
||||
3. 표 스타일
|
||||
- 헤더 배경색
|
||||
- 테두리 스타일 (선 두께, 색상)
|
||||
- 이중선 사용 여부
|
||||
|
||||
4. 그림/캡션 스타일
|
||||
- 캡션 위치 (상/하)
|
||||
- 캡션 형식
|
||||
|
||||
5. 페이지 구성
|
||||
- 표지 유무
|
||||
- 목차 유무
|
||||
- 머리말/꼬리말
|
||||
|
||||
분석 결과를 JSON 형식으로 출력해주세요.
|
||||
382
03. Code/geulbeot_10th/handlers/template/semantic_mapper.py
Normal file
382
03. Code/geulbeot_10th/handlers/template/semantic_mapper.py
Normal file
@@ -0,0 +1,382 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Semantic Mapper v1.0
|
||||
|
||||
HWPX tools 추출 결과(template_info)에서 각 요소의 "의미"를 판별.
|
||||
|
||||
역할:
|
||||
- 표 분류: 헤더표 / 푸터표 / 제목블록 / 데이터표
|
||||
- 섹션 감지: 본문 텍스트에서 섹션 패턴 탐색
|
||||
- 스타일 매핑 준비: charPr→HTML태그, borderFill→CSS클래스 (Phase 2에서 구현)
|
||||
|
||||
입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결과)
|
||||
출력: semantic_map dict → semantic_map.json으로 저장
|
||||
|
||||
★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토리
|
||||
★ 호출: template_manager.extract_and_save() 내에서 analyze() 직후
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 메인 엔트리포인트
|
||||
# ================================================================
|
||||
|
||||
def generate(template_info: dict, parsed: dict) -> dict:
|
||||
"""semantic_map 생성 — 모든 판별 로직 조합.
|
||||
|
||||
Args:
|
||||
template_info: DocTemplateAnalyzer.analyze() 결과
|
||||
parsed: HWPX 파서 결과 (raw_xml, section_xml, paragraphs 등)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"version": "1.0",
|
||||
"table_roles": { "0": {"role": "footer_table", ...}, ... },
|
||||
"body_tables": [3], # 본문에 들어갈 표 index 목록
|
||||
"title_table": 2, # 제목 블록 index (없으면 None)
|
||||
"sections": [...], # 감지된 섹션 목록
|
||||
"style_mappings": {...}, # Phase 2용 스타일 매핑 (현재 빈 구조)
|
||||
}
|
||||
"""
|
||||
tables = template_info.get("tables", [])
|
||||
header = template_info.get("header")
|
||||
footer = template_info.get("footer")
|
||||
|
||||
# ① 표 역할 분류
|
||||
table_roles = _classify_tables(tables, header, footer)
|
||||
|
||||
# ② 본문 전용 표 / 제목 블록 추출
|
||||
body_tables = sorted(
|
||||
idx for idx, info in table_roles.items()
|
||||
if info["role"] == "data_table"
|
||||
)
|
||||
title_table = next(
|
||||
(idx for idx, info in table_roles.items()
|
||||
if info["role"] == "title_block"),
|
||||
None
|
||||
)
|
||||
|
||||
# ③ 섹션 감지
|
||||
sections = _detect_sections(parsed)
|
||||
|
||||
# ④ 스타일 매핑 (Phase 2에서 구현, 현재는 빈 구조)
|
||||
style_mappings = _prepare_style_mappings(template_info)
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"table_roles": table_roles,
|
||||
"body_tables": body_tables,
|
||||
"title_table": title_table,
|
||||
"sections": sections,
|
||||
"style_mappings": style_mappings,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 표 분류
|
||||
# ================================================================
|
||||
|
||||
def _classify_tables(tables: list, header: dict | None,
|
||||
footer: dict | None) -> dict:
|
||||
"""각 표의 역할 판별: header_table / footer_table / title_block / data_table
|
||||
|
||||
판별 순서:
|
||||
Pass 1 — header/footer 텍스트 매칭
|
||||
Pass 2 — 제목 블록 패턴 (1행, 좁은+넓은 열 구조)
|
||||
Pass 3 — 나머지 → 데이터 표
|
||||
"""
|
||||
header_texts = _collect_hf_texts(header)
|
||||
footer_texts = _collect_hf_texts(footer)
|
||||
|
||||
roles = {}
|
||||
classified = set()
|
||||
|
||||
# ── Pass 1: header/footer 매칭 ──
|
||||
for tbl in tables:
|
||||
idx = tbl["index"]
|
||||
tbl_texts = _collect_table_texts(tbl)
|
||||
if not tbl_texts:
|
||||
continue
|
||||
|
||||
# header 매칭
|
||||
if header_texts:
|
||||
overlap = len(tbl_texts & header_texts)
|
||||
if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5:
|
||||
roles[idx] = {
|
||||
"role": "header_table",
|
||||
"match_source": "header",
|
||||
"matched_texts": list(tbl_texts & header_texts),
|
||||
}
|
||||
classified.add(idx)
|
||||
continue
|
||||
|
||||
# footer 매칭
|
||||
if footer_texts:
|
||||
overlap = len(tbl_texts & footer_texts)
|
||||
if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5:
|
||||
roles[idx] = {
|
||||
"role": "footer_table",
|
||||
"match_source": "footer",
|
||||
"matched_texts": list(tbl_texts & footer_texts),
|
||||
}
|
||||
classified.add(idx)
|
||||
continue
|
||||
|
||||
# ── Pass 2: 제목 블록 탐지 ──
|
||||
for tbl in tables:
|
||||
idx = tbl["index"]
|
||||
if idx in classified:
|
||||
continue
|
||||
|
||||
if _is_title_block(tbl):
|
||||
title_text = _extract_longest_text(tbl)
|
||||
roles[idx] = {
|
||||
"role": "title_block",
|
||||
"title_text": title_text,
|
||||
}
|
||||
classified.add(idx)
|
||||
continue
|
||||
|
||||
# ── Pass 3: 나머지 → 데이터 표 ──
|
||||
for tbl in tables:
|
||||
idx = tbl["index"]
|
||||
if idx in classified:
|
||||
continue
|
||||
|
||||
col_headers = _detect_table_headers(tbl)
|
||||
roles[idx] = {
|
||||
"role": "data_table",
|
||||
"header_row": 0 if col_headers else None,
|
||||
"col_headers": col_headers,
|
||||
"row_count": tbl.get("rowCnt", 0),
|
||||
"col_count": tbl.get("colCnt", 0),
|
||||
}
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
# ── 표 분류 보조 함수 ──
|
||||
|
||||
def _collect_hf_texts(hf_info: dict | None) -> set:
|
||||
"""header/footer의 table 셀 텍스트 수집"""
|
||||
if not hf_info or not hf_info.get("table"):
|
||||
return set()
|
||||
texts = set()
|
||||
for row in hf_info["table"].get("rows", []):
|
||||
for cell in row:
|
||||
t = cell.get("text", "").strip()
|
||||
if t:
|
||||
texts.add(t)
|
||||
return texts
|
||||
|
||||
|
||||
def _collect_table_texts(tbl: dict) -> set:
|
||||
"""표의 모든 셀 텍스트 수집"""
|
||||
texts = set()
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
t = cell.get("text", "").strip()
|
||||
if t:
|
||||
texts.add(t)
|
||||
return texts
|
||||
|
||||
|
||||
def _extract_longest_text(tbl: dict) -> str:
|
||||
"""표에서 가장 긴 텍스트 추출 (제목 블록용)"""
|
||||
longest = ""
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
t = cell.get("text", "").strip()
|
||||
if len(t) > len(longest):
|
||||
longest = t
|
||||
return longest
|
||||
|
||||
|
||||
def _is_title_block(tbl: dict) -> bool:
|
||||
"""제목 블록 패턴 판별.
|
||||
|
||||
조건 (하나라도 충족):
|
||||
A) 1행 2열, 왼쪽 열 비율 ≤ 10% (불릿아이콘 + 제목)
|
||||
B) 1행 1열, 텍스트 길이 5~100자 (제목 단독)
|
||||
"""
|
||||
if tbl.get("rowCnt", 0) != 1:
|
||||
return False
|
||||
|
||||
col_cnt = tbl.get("colCnt", 0)
|
||||
col_pcts = tbl.get("colWidths_pct", [])
|
||||
|
||||
# 패턴 A: 좁은 왼쪽 + 넓은 오른쪽
|
||||
if col_cnt == 2 and len(col_pcts) >= 2:
|
||||
if col_pcts[0] <= 10:
|
||||
return True
|
||||
|
||||
# 패턴 B: 단일 셀 제목
|
||||
if col_cnt == 1:
|
||||
rows = tbl.get("rows", [])
|
||||
if rows and rows[0]:
|
||||
text = rows[0][0].get("text", "")
|
||||
if 5 < len(text) < 100:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _detect_table_headers(tbl: dict) -> list:
|
||||
"""표 첫 행의 컬럼 헤더 텍스트 반환.
|
||||
|
||||
헤더 판별: 첫 행의 모든 텍스트가 짧음 (20자 이하)
|
||||
"""
|
||||
rows = tbl.get("rows", [])
|
||||
if not rows or len(rows) < 2:
|
||||
return []
|
||||
|
||||
first_row = rows[0]
|
||||
headers = []
|
||||
for cell in first_row:
|
||||
t = cell.get("text", "").strip()
|
||||
headers.append(t)
|
||||
|
||||
# 전부 짧은 텍스트이면 헤더행
|
||||
if headers and all(len(h) <= 20 for h in headers if h):
|
||||
non_empty = [h for h in headers if h]
|
||||
if non_empty: # 최소 1개는 텍스트가 있어야
|
||||
return headers
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 섹션 감지
|
||||
# ================================================================
|
||||
|
||||
_SECTION_PATTERNS = [
|
||||
(r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요"
|
||||
(r'^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ][\.\s]+(.+)', "roman"), # "Ⅰ. 개요"
|
||||
(r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요"
|
||||
(r'^[▶►▸●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요"
|
||||
]
|
||||
|
||||
|
||||
def _detect_sections(parsed: dict) -> list:
|
||||
"""parsed 텍스트에서 섹션 제목 패턴 탐색.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"},
|
||||
{"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"},
|
||||
...
|
||||
]
|
||||
"""
|
||||
paragraphs = _extract_paragraphs(parsed)
|
||||
sections = []
|
||||
sec_idx = 0
|
||||
|
||||
for text in paragraphs:
|
||||
text = text.strip()
|
||||
if not text or len(text) > 100:
|
||||
# 너무 긴 텍스트는 제목이 아님
|
||||
continue
|
||||
|
||||
for pat, pat_type in _SECTION_PATTERNS:
|
||||
m = re.match(pat, text)
|
||||
if m:
|
||||
# numbered 패턴: 숫자가 100 이상이면 섹션 번호가 아님 (연도 등 제외)
|
||||
if pat_type == "numbered" and int(m.group(1)) > 99:
|
||||
continue
|
||||
sec_idx += 1
|
||||
sections.append({
|
||||
"index": sec_idx,
|
||||
"title": text,
|
||||
"pattern_type": pat_type,
|
||||
})
|
||||
break
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _extract_paragraphs(parsed: dict) -> list:
|
||||
"""parsed에서 텍스트 단락 추출.
|
||||
|
||||
우선순위:
|
||||
1. parsed["paragraphs"] (파서가 직접 제공)
|
||||
2. section_xml의 <hp:t> 태그에서 추출
|
||||
"""
|
||||
paragraphs = parsed.get("paragraphs", [])
|
||||
if paragraphs:
|
||||
return [
|
||||
p.get("text", "") if isinstance(p, dict) else str(p)
|
||||
for p in paragraphs
|
||||
]
|
||||
|
||||
# section_xml에서 <hp:t> 추출
|
||||
section_xml = ""
|
||||
raw_xml = parsed.get("raw_xml", {})
|
||||
for key, val in raw_xml.items():
|
||||
if "section" in key.lower() and isinstance(val, str):
|
||||
section_xml = val
|
||||
break
|
||||
|
||||
if not section_xml:
|
||||
section_xml = parsed.get("section_xml", "")
|
||||
|
||||
if section_xml:
|
||||
return [
|
||||
t.strip()
|
||||
for t in re.findall(r'<hp:t>([^<]+)</hp:t>', section_xml)
|
||||
if t.strip()
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 스타일 매핑 (Phase 2에서 확장)
|
||||
# ================================================================
|
||||
|
||||
def _prepare_style_mappings(template_info: dict) -> dict:
|
||||
"""스타일 매핑 빈 구조 생성.
|
||||
|
||||
Phase 2에서 이 구조를 채움:
|
||||
- char_styles → CSS font/color rules
|
||||
- border_fills → CSS border/background rules
|
||||
- para_styles → CSS margin/alignment rules
|
||||
"""
|
||||
mappings = {
|
||||
"char_pr": {},
|
||||
"border_fill": {},
|
||||
"para_pr": {},
|
||||
}
|
||||
|
||||
# border_fills가 있으면 기본 매핑 생성
|
||||
border_fills = template_info.get("border_fills", {})
|
||||
for bf_id, bf_data in border_fills.items():
|
||||
# ★ 실제 키 구조 대응 (bg→background, sides→css/직접키)
|
||||
bg = bf_data.get("background", bf_data.get("bg", ""))
|
||||
|
||||
# borders: css dict 또는 직접 키에서 추출
|
||||
borders = {}
|
||||
css_dict = bf_data.get("css", {})
|
||||
if css_dict:
|
||||
for prop, val in css_dict.items():
|
||||
if prop.startswith("border-") and val and val != "none":
|
||||
borders[prop] = val
|
||||
else:
|
||||
# fallback: 직접 side 키
|
||||
for side in ("top", "bottom", "left", "right"):
|
||||
si = bf_data.get(side, {})
|
||||
if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE":
|
||||
borders[f"border-{side}"] = (
|
||||
f"{si.get('width','0.1mm')} "
|
||||
f"{si.get('type','solid').lower()} "
|
||||
f"{si.get('color','#000')}"
|
||||
)
|
||||
|
||||
mappings["border_fill"][str(bf_id)] = {
|
||||
"css_class": f"bf-{bf_id}",
|
||||
"bg": bg,
|
||||
"borders": borders,
|
||||
}
|
||||
|
||||
return mappings
|
||||
824
03. Code/geulbeot_10th/handlers/template/style_generator.py
Normal file
824
03. Code/geulbeot_10th/handlers/template/style_generator.py
Normal file
@@ -0,0 +1,824 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Style Generator v2.1 (Phase 4 — 하드코딩 제거)
|
||||
|
||||
template_info의 tools 추출값 → CSS 문자열 생성.
|
||||
|
||||
★ v2.1 변경사항:
|
||||
- 하드코딩 간격 → 추출값 대체:
|
||||
· .doc-header margin-bottom → page.margins.header에서 계산
|
||||
· .doc-footer margin-top → page.margins.footer에서 계산
|
||||
· .title-block margin/padding → title paraPr spacing에서 유도
|
||||
- .img-wrap, .img-caption CSS 추가 (content_order 이미지 지원)
|
||||
|
||||
★ v2.0 변경사항 (v1.0 대비):
|
||||
- charPr 28개 전체 → .cpr-{id} CSS 클래스 생성
|
||||
- paraPr 23개 전체 → .ppr-{id} CSS 클래스 생성
|
||||
- styles 12개 → .sty-{id} CSS 클래스 (charPr + paraPr 조합)
|
||||
- fontRef → 실제 폰트명 해석 (font_map 빌드)
|
||||
- 제목 블록: 하드코딩 제거 → 실제 추출 데이터 사용
|
||||
- 줄간격: paraPr별 line-height 개별 적용
|
||||
- 여백: @page는 인쇄용, .page는 화면용 (이중 적용 제거)
|
||||
- bf CSS: NONE-only borderFill도 클래스 생성 (border: none 명시)
|
||||
- 텍스트 색상: charPr별 color 반영
|
||||
- 폰트: charPr별 fontRef → 실제 font-family 해석
|
||||
|
||||
★ 원칙: hwpx_domain_guide.md §1~§8 매핑 규칙 100% 준수
|
||||
★ 원칙: 하드코딩 값 0개. 모든 CSS 값은 template_info에서 유래.
|
||||
"""
|
||||
|
||||
HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 메인 엔트리포인트
|
||||
# ================================================================
|
||||
|
||||
def generate_css(template_info: dict, semantic_map: dict = None) -> str:
|
||||
"""template_info + semantic_map → CSS 문자열 전체 생성."""
|
||||
# font_map 빌드 (charPr CSS에서 재사용)
|
||||
fm = _build_font_map(template_info)
|
||||
|
||||
parts = [
|
||||
_page_css(template_info),
|
||||
_body_css(template_info, fm),
|
||||
_layout_css(template_info),
|
||||
_header_footer_css(template_info),
|
||||
_title_block_css(template_info, fm, semantic_map),
|
||||
_section_css(template_info),
|
||||
_table_base_css(template_info),
|
||||
_border_fill_css(template_info),
|
||||
_char_pr_css(template_info, fm),
|
||||
_para_pr_css(template_info),
|
||||
_named_style_css(template_info),
|
||||
_table_detail_css(template_info, semantic_map),
|
||||
]
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# @page (인쇄 전용)
|
||||
# ================================================================
|
||||
|
||||
def _page_css(ti: dict) -> str:
|
||||
page = ti.get("page", {})
|
||||
paper = page.get("paper", {})
|
||||
margins = page.get("margins", {})
|
||||
|
||||
w = paper.get("width_mm", 210)
|
||||
h = paper.get("height_mm", 297)
|
||||
mt = margins.get("top", "20mm")
|
||||
mb = margins.get("bottom", "20mm")
|
||||
ml = margins.get("left", "20mm")
|
||||
mr = margins.get("right", "20mm")
|
||||
|
||||
return (
|
||||
"@page {\n"
|
||||
f" size: {w}mm {h}mm;\n"
|
||||
f" margin: {mt} {mr} {mb} {ml};\n"
|
||||
"}\n"
|
||||
"@media screen {\n"
|
||||
" @page { margin: 0; }\n" # 화면에서는 .page padding만 사용
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# body
|
||||
# ================================================================
|
||||
|
||||
def _body_css(ti: dict, fm: dict) -> str:
|
||||
"""바탕글 스타일 기준 body CSS"""
|
||||
# '바탕글' 스타일 → charPr → fontRef → 실제 폰트
|
||||
base_charpr = _resolve_style_charpr(ti, "바탕글")
|
||||
base_parapr = _resolve_style_parapr(ti, "바탕글")
|
||||
|
||||
# 폰트
|
||||
font_family = _charpr_font_family(base_charpr, fm)
|
||||
# 크기
|
||||
size_pt = base_charpr.get("height_pt", 10.0)
|
||||
# 색상
|
||||
color = base_charpr.get("textColor", "#000000")
|
||||
# 줄간격
|
||||
line_height = _parapr_line_height(base_parapr)
|
||||
# 정렬
|
||||
# body에는 정렬 넣지 않음 (paraPr별로)
|
||||
|
||||
return (
|
||||
"body {\n"
|
||||
f" font-family: {font_family};\n"
|
||||
f" font-size: {size_pt}pt;\n"
|
||||
f" line-height: {line_height};\n"
|
||||
f" color: {color};\n"
|
||||
" margin: 0; padding: 0;\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# .page 레이아웃 (화면 전용 — 여백은 여기서만)
|
||||
# ================================================================
|
||||
|
||||
def _layout_css(ti: dict) -> str:
|
||||
page = ti.get("page", {})
|
||||
paper = page.get("paper", {})
|
||||
margins = page.get("margins", {})
|
||||
|
||||
w = paper.get("width_mm", 210)
|
||||
ml = _mm(margins.get("left", "20mm"))
|
||||
mr = _mm(margins.get("right", "20mm"))
|
||||
body_w = w - ml - mr
|
||||
|
||||
mt = margins.get("top", "20mm")
|
||||
mb = margins.get("bottom", "20mm")
|
||||
m_left = margins.get("left", "20mm")
|
||||
m_right = margins.get("right", "20mm")
|
||||
|
||||
return (
|
||||
".page {\n"
|
||||
f" width: {body_w:.0f}mm;\n"
|
||||
" margin: 0 auto;\n"
|
||||
f" padding: {mt} {m_right} {mb} {m_left};\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 헤더 / 푸터
|
||||
# ================================================================
|
||||
|
||||
def _header_footer_css(ti: dict) -> str:
|
||||
page = ti.get("page", {})
|
||||
margins = page.get("margins", {})
|
||||
|
||||
# 헤더 margin-bottom: page.margins.header에서 유도
|
||||
# 푸터 margin-top: page.margins.footer에서 유도
|
||||
hdr_margin = margins.get("header", "")
|
||||
ftr_margin = margins.get("footer", "")
|
||||
|
||||
hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm"
|
||||
ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm"
|
||||
|
||||
lines = [
|
||||
"/* 헤더/푸터 */",
|
||||
f".doc-header {{ margin-bottom: {hdr_mb}; }}",
|
||||
f".doc-footer {{ margin-top: {ftr_mt}; }}",
|
||||
".doc-header table, .doc-footer table {",
|
||||
" width: 100%; border-collapse: collapse;",
|
||||
"}",
|
||||
]
|
||||
|
||||
hdr_padding = _hf_cell_padding(ti.get("header"))
|
||||
ftr_padding = _hf_cell_padding(ti.get("footer"))
|
||||
|
||||
lines.append(
|
||||
f".doc-header td {{ {hdr_padding} vertical-align: middle; }}"
|
||||
)
|
||||
lines.append(
|
||||
f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 제목 블록 — ★ 하드코딩 제거, 실제 데이터 사용
|
||||
# ================================================================
|
||||
|
||||
def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str:
|
||||
"""제목 블록 CSS — title_table의 실제 셀 데이터에서 추출"""
|
||||
tables = ti.get("tables", [])
|
||||
|
||||
# semantic_map에서 title_table 인덱스 가져오기
|
||||
title_idx = None
|
||||
if sm:
|
||||
title_idx = sm.get("title_table")
|
||||
|
||||
title_tbl = None
|
||||
if title_idx is not None:
|
||||
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
|
||||
|
||||
# 못 찾으면 1행 표 중 텍스트 있는 것 검색
|
||||
if not title_tbl:
|
||||
for t in tables:
|
||||
rows = t.get("rows", [])
|
||||
if rows and len(rows) == 1:
|
||||
for cell in rows[0]:
|
||||
if cell.get("text", "").strip():
|
||||
title_tbl = t
|
||||
break
|
||||
if title_tbl:
|
||||
break
|
||||
|
||||
lines = ["/* 제목 블록 */"]
|
||||
|
||||
if title_tbl:
|
||||
# 텍스트 있는 셀에서 charPr, paraPr, bf 추출
|
||||
title_charpr = None
|
||||
title_parapr = None
|
||||
title_bf_id = None
|
||||
|
||||
for row in title_tbl.get("rows", []):
|
||||
for cell in row:
|
||||
if cell.get("text", "").strip():
|
||||
# ★ primaryCharPrIDRef 사용 (table_v2 추출)
|
||||
cpr_id = cell.get("primaryCharPrIDRef")
|
||||
if cpr_id is not None:
|
||||
title_charpr = next(
|
||||
(c for c in ti.get("char_styles", [])
|
||||
if c.get("id") == cpr_id), None
|
||||
)
|
||||
ppr_id = cell.get("primaryParaPrIDRef")
|
||||
if ppr_id is not None:
|
||||
title_parapr = next(
|
||||
(p for p in ti.get("para_styles", [])
|
||||
if p.get("id") == ppr_id), None
|
||||
)
|
||||
title_bf_id = cell.get("borderFillIDRef")
|
||||
break
|
||||
if title_charpr:
|
||||
break
|
||||
|
||||
# charPr 못 찾으면 폴백 (charPrIDRef가 없는 구버전 table.py)
|
||||
if not title_charpr:
|
||||
title_charpr = _find_title_charpr(ti)
|
||||
|
||||
# CSS 생성
|
||||
font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif"
|
||||
size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0
|
||||
bold = title_charpr.get("bold", False) if title_charpr else False
|
||||
color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000"
|
||||
|
||||
# 줄간격
|
||||
line_height = _parapr_line_height(title_parapr) if title_parapr else "180%"
|
||||
align = _parapr_align(title_parapr) if title_parapr else "center"
|
||||
|
||||
# ★ margin/padding — paraPr 또는 page.margins에서 유도
|
||||
title_after_mm = "4mm" # 기본값
|
||||
title_padding = "4mm 0" # 기본값
|
||||
if title_parapr:
|
||||
margin_info = title_parapr.get("margin", {})
|
||||
after_hu = margin_info.get("after_hu", 0)
|
||||
if after_hu:
|
||||
title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm"
|
||||
before_hu = margin_info.get("before_hu", 0)
|
||||
if before_hu or after_hu:
|
||||
b_mm = before_hu * HU_TO_MM if before_hu else 4
|
||||
a_mm = after_hu * HU_TO_MM if after_hu else 0
|
||||
title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0"
|
||||
|
||||
lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}")
|
||||
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
|
||||
lines.append(
|
||||
f".title-block h1 {{\n"
|
||||
f" font-family: {font_family};\n"
|
||||
f" font-size: {size_pt}pt;\n"
|
||||
f" font-weight: {'bold' if bold else 'normal'};\n"
|
||||
f" color: {color};\n"
|
||||
f" text-align: {align};\n"
|
||||
f" line-height: {line_height};\n"
|
||||
f" margin: 0; padding: {title_padding};\n"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
# bf 적용 (파란 하단선 등)
|
||||
if title_bf_id:
|
||||
bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {})
|
||||
css_dict = bf_data.get("css", {})
|
||||
bf_rules = []
|
||||
for prop, val in css_dict.items():
|
||||
if val and val.lower() != "none":
|
||||
bf_rules.append(f" {prop}: {val};")
|
||||
if bf_rules:
|
||||
lines.append(
|
||||
f".title-block {{\n"
|
||||
+ "\n".join(bf_rules)
|
||||
+ "\n}"
|
||||
)
|
||||
else:
|
||||
lines.append(".title-block { margin-bottom: 4mm; }")
|
||||
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
|
||||
lines.append(
|
||||
".title-block h1 {\n"
|
||||
" font-size: 15pt; font-weight: normal;\n"
|
||||
" text-align: center; margin: 0; padding: 4mm 0;\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 섹션 — 하드코딩 제거
|
||||
# ================================================================
|
||||
|
||||
def _section_css(ti: dict) -> str:
|
||||
"""섹션 CSS — '#큰아이콘' 또는 '개요1' 스타일에서 추출"""
|
||||
lines = ["/* 섹션 */"]
|
||||
|
||||
# 섹션 제목: '#큰아이콘' 또는 가장 큰 bold charPr
|
||||
title_charpr = _resolve_style_charpr(ti, "#큰아이콘")
|
||||
if not title_charpr or title_charpr.get("id") == 0:
|
||||
title_charpr = _resolve_style_charpr(ti, "개요1")
|
||||
if not title_charpr or title_charpr.get("id") == 0:
|
||||
# 폴백: bold인 charPr 중 가장 큰 것
|
||||
for cs in sorted(ti.get("char_styles", []),
|
||||
key=lambda x: x.get("height_pt", 0), reverse=True):
|
||||
if cs.get("bold"):
|
||||
title_charpr = cs
|
||||
break
|
||||
|
||||
if title_charpr:
|
||||
size = title_charpr.get("height_pt", 11)
|
||||
bold = title_charpr.get("bold", True)
|
||||
color = title_charpr.get("textColor", "#000000")
|
||||
lines.append(
|
||||
f".section-title {{\n"
|
||||
f" font-size: {size}pt;\n"
|
||||
f" font-weight: {'bold' if bold else 'normal'};\n"
|
||||
f" color: {color};\n"
|
||||
f" margin-bottom: 3mm;\n"
|
||||
f"}}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
".section-title { font-weight: bold; margin-bottom: 3mm; }"
|
||||
)
|
||||
|
||||
lines.append(".section { margin-bottom: 6mm; }")
|
||||
lines.append(".section-content { text-align: justify; }")
|
||||
|
||||
# content_order 기반 본문용 스타일
|
||||
lines.append("/* 이미지/문단 (content_order) */")
|
||||
lines.append(
|
||||
".img-wrap { text-align: center; margin: 3mm 0; }"
|
||||
)
|
||||
lines.append(
|
||||
".img-wrap img { max-width: 100%; height: auto; }"
|
||||
)
|
||||
lines.append(
|
||||
".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 데이터 표 기본 CSS
|
||||
# ================================================================
|
||||
|
||||
def _table_base_css(ti: dict) -> str:
|
||||
"""표 기본 — '표내용' 스타일 charPr에서 추출"""
|
||||
tbl_charpr = _resolve_style_charpr(ti, "표내용")
|
||||
tbl_parapr = _resolve_style_parapr(ti, "표내용")
|
||||
|
||||
size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0
|
||||
line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%"
|
||||
align = _parapr_align(tbl_parapr) if tbl_parapr else "justify"
|
||||
|
||||
border_fills = ti.get("border_fills", {})
|
||||
if border_fills:
|
||||
# bf-{id} 클래스가 셀별 테두리를 담당 → 기본값은 none
|
||||
# (하드코딩 border를 넣으면 bf 클래스보다 specificity가 높아 덮어씀)
|
||||
border_rule = "border: none;"
|
||||
else:
|
||||
# border_fills 추출 실패 시에만 폴백
|
||||
border_rule = "border: 1px solid #000;"
|
||||
|
||||
return (
|
||||
"/* 데이터 표 */\n"
|
||||
".data-table {\n"
|
||||
" width: 100%; border-collapse: collapse; margin: 4mm 0;\n"
|
||||
"}\n"
|
||||
".data-table th, .data-table td {\n"
|
||||
f" {border_rule}\n"
|
||||
f" font-size: {size_pt}pt;\n"
|
||||
f" line-height: {line_height};\n"
|
||||
f" text-align: {align};\n"
|
||||
" vertical-align: middle;\n"
|
||||
"}\n"
|
||||
".data-table th {\n"
|
||||
" font-weight: bold; text-align: center;\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# borderFill → .bf-{id} CSS 클래스
|
||||
# ================================================================
|
||||
|
||||
def _border_fill_css(ti: dict) -> str:
|
||||
"""★ v2.0: NONE-only bf도 클래스 생성 (border: none 명시)"""
|
||||
border_fills = ti.get("border_fills", {})
|
||||
if not border_fills:
|
||||
return ""
|
||||
|
||||
parts = ["/* borderFill → CSS 클래스 */"]
|
||||
|
||||
for bf_id, bf in border_fills.items():
|
||||
rules = []
|
||||
|
||||
css_dict = bf.get("css", {})
|
||||
for prop, val in css_dict.items():
|
||||
if val:
|
||||
# NONE도 포함 (border: none 명시)
|
||||
rules.append(f" {prop}: {val};")
|
||||
|
||||
# background
|
||||
if "background-color" not in css_dict:
|
||||
bg = bf.get("background", "")
|
||||
if bg and bg.lower() not in ("", "none", "transparent",
|
||||
"#ffffff", "#fff"):
|
||||
rules.append(f" background-color: {bg};")
|
||||
|
||||
if rules:
|
||||
parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}")
|
||||
|
||||
return "\n".join(parts) if len(parts) > 1 else ""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# ★ NEW: charPr → .cpr-{id} CSS 클래스
|
||||
# ================================================================
|
||||
|
||||
def _char_pr_css(ti: dict, fm: dict) -> str:
|
||||
"""charPr 전체 → 개별 CSS 클래스 생성.
|
||||
|
||||
각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포함.
|
||||
HTML에서 <span class="cpr-5"> 등으로 참조.
|
||||
"""
|
||||
char_styles = ti.get("char_styles", [])
|
||||
if not char_styles:
|
||||
return ""
|
||||
|
||||
parts = ["/* charPr → CSS 클래스 (글자 모양) */"]
|
||||
|
||||
for cs in char_styles:
|
||||
cid = cs.get("id")
|
||||
rules = []
|
||||
|
||||
# font-family
|
||||
ff = _charpr_font_family(cs, fm)
|
||||
if ff:
|
||||
rules.append(f" font-family: {ff};")
|
||||
|
||||
# font-size
|
||||
pt = cs.get("height_pt")
|
||||
if pt:
|
||||
rules.append(f" font-size: {pt}pt;")
|
||||
|
||||
# bold
|
||||
if cs.get("bold"):
|
||||
rules.append(" font-weight: bold;")
|
||||
|
||||
# italic
|
||||
if cs.get("italic"):
|
||||
rules.append(" font-style: italic;")
|
||||
|
||||
# color
|
||||
color = cs.get("textColor", "#000000")
|
||||
if color and color.lower() != "#000000":
|
||||
rules.append(f" color: {color};")
|
||||
|
||||
# underline — type이 NONE이 아닌 실제 밑줄만
|
||||
underline = cs.get("underline", "NONE")
|
||||
ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"}
|
||||
if underline in ACTIVE_UNDERLINE:
|
||||
rules.append(" text-decoration: underline;")
|
||||
|
||||
# strikeout — shape="NONE" 또는 "3D"는 취소선 아님
|
||||
# 실제 취소선: CONTINUOUS, DASH, DOT 등 선 스타일만
|
||||
strikeout = cs.get("strikeout", "NONE")
|
||||
ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT",
|
||||
"DASH_DOT_DOT", "LONG_DASH", "DOUBLE"}
|
||||
if strikeout in ACTIVE_STRIKEOUT:
|
||||
rules.append(" text-decoration: line-through;")
|
||||
|
||||
# ── 자간 (letter-spacing) ──
|
||||
# HWPX spacing은 % 단위: letter-spacing = height_pt × spacing / 100
|
||||
spacing_pct = cs.get("spacing", {}).get("hangul", 0)
|
||||
if spacing_pct != 0 and pt:
|
||||
ls_val = round(pt * spacing_pct / 100, 2)
|
||||
rules.append(f" letter-spacing: {ls_val}pt;")
|
||||
|
||||
# ── 장평 (scaleX) ──
|
||||
# HWPX ratio는 글자 폭 비율 (100=기본). CSS transform으로 변환
|
||||
ratio_pct = cs.get("ratio", {}).get("hangul", 100)
|
||||
if ratio_pct != 100:
|
||||
rules.append(f" transform: scaleX({ratio_pct / 100});")
|
||||
rules.append(" display: inline-block;") # scaleX 적용 필수
|
||||
|
||||
if rules:
|
||||
parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}")
|
||||
|
||||
return "\n".join(parts) if len(parts) > 1 else ""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# ★ NEW: paraPr → .ppr-{id} CSS 클래스
|
||||
# ================================================================
|
||||
|
||||
def _para_pr_css(ti: dict) -> str:
|
||||
"""paraPr 전체 → 개별 CSS 클래스 생성.
|
||||
|
||||
각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포함.
|
||||
HTML에서 <p class="ppr-3"> 등으로 참조.
|
||||
"""
|
||||
para_styles = ti.get("para_styles", [])
|
||||
if not para_styles:
|
||||
return ""
|
||||
|
||||
parts = ["/* paraPr → CSS 클래스 (문단 모양) */"]
|
||||
|
||||
for ps in para_styles:
|
||||
pid = ps.get("id")
|
||||
rules = []
|
||||
|
||||
# text-align
|
||||
align = _parapr_align(ps)
|
||||
if align:
|
||||
rules.append(f" text-align: {align};")
|
||||
|
||||
# line-height
|
||||
lh = _parapr_line_height(ps)
|
||||
if lh:
|
||||
rules.append(f" line-height: {lh};")
|
||||
|
||||
# text-indent
|
||||
margin = ps.get("margin", {})
|
||||
indent_hu = margin.get("indent_hu", 0)
|
||||
if indent_hu:
|
||||
indent_mm = indent_hu * HU_TO_MM
|
||||
rules.append(f" text-indent: {indent_mm:.1f}mm;")
|
||||
|
||||
# margin-left
|
||||
left_hu = margin.get("left_hu", 0)
|
||||
if left_hu:
|
||||
left_mm = left_hu * HU_TO_MM
|
||||
rules.append(f" margin-left: {left_mm:.1f}mm;")
|
||||
|
||||
# margin-right
|
||||
right_hu = margin.get("right_hu", 0)
|
||||
if right_hu:
|
||||
right_mm = right_hu * HU_TO_MM
|
||||
rules.append(f" margin-right: {right_mm:.1f}mm;")
|
||||
|
||||
# spacing before/after
|
||||
before = margin.get("before_hu", 0)
|
||||
if before:
|
||||
rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;")
|
||||
after = margin.get("after_hu", 0)
|
||||
if after:
|
||||
rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;")
|
||||
|
||||
if rules:
|
||||
parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}")
|
||||
|
||||
return "\n".join(parts) if len(parts) > 1 else ""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# ★ NEW: named style → .sty-{id} CSS 클래스
|
||||
# ================================================================
|
||||
|
||||
def _named_style_css(ti: dict) -> str:
|
||||
"""styles 목록 → .sty-{id} CSS 클래스.
|
||||
|
||||
각 style은 charPrIDRef + paraPrIDRef 조합.
|
||||
→ .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의미.
|
||||
HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 참조.
|
||||
"""
|
||||
styles = ti.get("styles", [])
|
||||
if not styles:
|
||||
return ""
|
||||
|
||||
parts = ["/* named styles */"]
|
||||
|
||||
for s in styles:
|
||||
sid = s.get("id")
|
||||
name = s.get("name", "")
|
||||
cpr_id = s.get("charPrIDRef")
|
||||
ppr_id = s.get("paraPrIDRef")
|
||||
|
||||
# 주석으로 매핑 기록
|
||||
parts.append(
|
||||
f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 표 상세 CSS (열 너비, 셀 패딩)
|
||||
# ================================================================
|
||||
|
||||
def _table_detail_css(ti: dict, sm: dict = None) -> str:
|
||||
if not sm:
|
||||
return ""
|
||||
|
||||
body_indices = sm.get("body_tables", [])
|
||||
tables = ti.get("tables", [])
|
||||
if not body_indices or not tables:
|
||||
return ""
|
||||
|
||||
parts = ["/* 표 상세 (tools 추출값) */"]
|
||||
|
||||
for tbl_num, tbl_idx in enumerate(body_indices, 1):
|
||||
tbl = next((t for t in tables if t["index"] == tbl_idx), None)
|
||||
if not tbl:
|
||||
continue
|
||||
|
||||
cls = f"tbl-{tbl_num}"
|
||||
|
||||
# 열 너비
|
||||
col_pcts = tbl.get("colWidths_pct", [])
|
||||
if col_pcts:
|
||||
for c_idx, pct in enumerate(col_pcts):
|
||||
parts.append(
|
||||
f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}"
|
||||
)
|
||||
|
||||
# 셀 패딩
|
||||
cm = _first_cell_margin(tbl)
|
||||
if cm:
|
||||
ct = cm.get("top", 0) * HU_TO_MM
|
||||
cb = cm.get("bottom", 0) * HU_TO_MM
|
||||
cl = cm.get("left", 0) * HU_TO_MM
|
||||
cr = cm.get("right", 0) * HU_TO_MM
|
||||
parts.append(
|
||||
f".{cls} td, .{cls} th {{\n"
|
||||
f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
# 헤더행 높이
|
||||
first_row = tbl.get("rows", [[]])[0]
|
||||
if first_row:
|
||||
h_hu = first_row[0].get("height_hu", 0)
|
||||
if h_hu > 0:
|
||||
h_mm = h_hu * HU_TO_MM
|
||||
parts.append(
|
||||
f".{cls} thead th {{ height: {h_mm:.1f}mm; }}"
|
||||
)
|
||||
|
||||
return "\n".join(parts) if len(parts) > 1 else ""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 보조 함수
|
||||
# ================================================================
|
||||
|
||||
def _build_font_map(ti: dict) -> dict:
|
||||
"""fonts → {(lang, id): face_name} 딕셔너리"""
|
||||
fm = {}
|
||||
for lang, flist in ti.get("fonts", {}).items():
|
||||
if isinstance(flist, list):
|
||||
for f in flist:
|
||||
fm[(lang, f.get("id", 0))] = f.get("face", "")
|
||||
return fm
|
||||
|
||||
|
||||
def _charpr_font_family(charpr: dict, fm: dict) -> str:
|
||||
"""charPr의 fontRef → 실제 font-family CSS 값"""
|
||||
if not charpr:
|
||||
return "'맑은 고딕', sans-serif"
|
||||
|
||||
fr = charpr.get("fontRef", {})
|
||||
hangul_id = fr.get("hangul", 0)
|
||||
latin_id = fr.get("latin", 0)
|
||||
|
||||
hangul_face = fm.get(("HANGUL", hangul_id), "")
|
||||
latin_face = fm.get(("LATIN", latin_id), "")
|
||||
|
||||
faces = []
|
||||
if hangul_face:
|
||||
faces.append(f"'{hangul_face}'")
|
||||
if latin_face and latin_face != hangul_face:
|
||||
faces.append(f"'{latin_face}'")
|
||||
faces.append("sans-serif")
|
||||
|
||||
return ", ".join(faces)
|
||||
|
||||
|
||||
def _resolve_style_charpr(ti: dict, style_name: str) -> dict:
|
||||
"""스타일 이름 → charPr dict 해석"""
|
||||
styles = ti.get("styles", [])
|
||||
char_styles = ti.get("char_styles", [])
|
||||
|
||||
for s in styles:
|
||||
if s.get("name") == style_name:
|
||||
cpr_id = s.get("charPrIDRef")
|
||||
for cs in char_styles:
|
||||
if cs.get("id") == cpr_id:
|
||||
return cs
|
||||
|
||||
# 못 찾으면 charPr[0] (바탕글 기본)
|
||||
return char_styles[0] if char_styles else {}
|
||||
|
||||
|
||||
def _resolve_style_parapr(ti: dict, style_name: str) -> dict:
|
||||
"""스타일 이름 → paraPr dict 해석"""
|
||||
styles = ti.get("styles", [])
|
||||
para_styles = ti.get("para_styles", [])
|
||||
|
||||
for s in styles:
|
||||
if s.get("name") == style_name:
|
||||
ppr_id = s.get("paraPrIDRef")
|
||||
for ps in para_styles:
|
||||
if ps.get("id") == ppr_id:
|
||||
return ps
|
||||
|
||||
return para_styles[0] if para_styles else {}
|
||||
|
||||
|
||||
def _find_title_charpr(ti: dict) -> dict:
|
||||
"""제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폴백).
|
||||
|
||||
헤드라인 폰트 or 가장 큰 크기 기준.
|
||||
"""
|
||||
headline_keywords = ["헤드라인", "headline", "제목", "title"]
|
||||
fm = _build_font_map(ti)
|
||||
|
||||
best = {}
|
||||
best_pt = 0
|
||||
for cs in ti.get("char_styles", []):
|
||||
pt = cs.get("height_pt", 0)
|
||||
fr = cs.get("fontRef", {})
|
||||
hangul_id = fr.get("hangul", 0)
|
||||
face = fm.get(("HANGUL", hangul_id), "").lower()
|
||||
|
||||
# 헤드라인 폰트면 우선
|
||||
if any(kw in face for kw in headline_keywords):
|
||||
if pt > best_pt:
|
||||
best_pt = pt
|
||||
best = cs
|
||||
|
||||
# 헤드라인 폰트 못 찾으면 가장 큰 것
|
||||
if not best:
|
||||
for cs in ti.get("char_styles", []):
|
||||
pt = cs.get("height_pt", 0)
|
||||
if pt > best_pt:
|
||||
best_pt = pt
|
||||
best = cs
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def _parapr_line_height(parapr: dict) -> str:
|
||||
"""paraPr → CSS line-height"""
|
||||
if not parapr:
|
||||
return "160%"
|
||||
ls = parapr.get("lineSpacing", {})
|
||||
ls_type = ls.get("type", "PERCENT")
|
||||
ls_val = ls.get("value", 160)
|
||||
if ls_type == "PERCENT":
|
||||
return f"{ls_val}%"
|
||||
elif ls_type == "FIXED":
|
||||
return f"{ls_val / 100:.1f}pt"
|
||||
else:
|
||||
return f"{ls_val}%"
|
||||
|
||||
|
||||
def _parapr_align(parapr: dict) -> str:
|
||||
"""paraPr → CSS text-align"""
|
||||
if not parapr:
|
||||
return "justify"
|
||||
align = parapr.get("align", "JUSTIFY")
|
||||
return {
|
||||
"JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right",
|
||||
"CENTER": "center", "DISTRIBUTE": "justify",
|
||||
"DISTRIBUTE_SPACE": "justify"
|
||||
}.get(align, "justify")
|
||||
|
||||
|
||||
def _hf_cell_padding(hf_info: dict | None) -> str:
|
||||
if not hf_info or not hf_info.get("table"):
|
||||
return "padding: 2px 4px;"
|
||||
rows = hf_info["table"].get("rows", [])
|
||||
if not rows or not rows[0]:
|
||||
return "padding: 2px 4px;"
|
||||
cm = rows[0][0].get("cellMargin", {})
|
||||
if not cm:
|
||||
return "padding: 2px 4px;"
|
||||
ct = cm.get("top", 0) * HU_TO_MM
|
||||
cb = cm.get("bottom", 0) * HU_TO_MM
|
||||
cl = cm.get("left", 0) * HU_TO_MM
|
||||
cr = cm.get("right", 0) * HU_TO_MM
|
||||
return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;"
|
||||
|
||||
|
||||
def _first_cell_margin(tbl: dict) -> dict | None:
|
||||
for row in tbl.get("rows", []):
|
||||
for cell in row:
|
||||
cm = cell.get("cellMargin")
|
||||
if cm:
|
||||
return cm
|
||||
return None
|
||||
|
||||
|
||||
def _mm(val) -> float:
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
try:
|
||||
return float(str(val).replace("mm", "").strip())
|
||||
except (ValueError, TypeError):
|
||||
return 20.0
|
||||
1010
03. Code/geulbeot_10th/handlers/template/template_manager.py
Normal file
1010
03. Code/geulbeot_10th/handlers/template/template_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
51
03. Code/geulbeot_10th/handlers/template/tools/__init__.py
Normal file
51
03. Code/geulbeot_10th/handlers/template/tools/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
HWPX 템플릿 추출 도구 모음
|
||||
|
||||
각 모듈은 HWPX XML에서 특정 항목을 코드 기반으로 추출한다.
|
||||
- 추출 실패 시 None 반환 (디폴트값 절대 생성 안 함)
|
||||
- 모든 단위 변환은 hwpx_utils 사용
|
||||
- hwpx_domain_guide.md 기준 준수
|
||||
|
||||
모듈 목록:
|
||||
page_setup : §7 용지/여백 (pagePr + margin)
|
||||
font : §3 글꼴 (fontface → font)
|
||||
char_style : §4 글자 모양 (charPr)
|
||||
para_style : §5 문단 모양 (paraPr)
|
||||
border_fill : §2 테두리/배경 (borderFill)
|
||||
table : §6 표 (tbl, tc)
|
||||
header_footer: §8 머리말/꼬리말 (headerFooter)
|
||||
section : §9 구역 정의 (secPr)
|
||||
style_def : 스타일 정의 (styles)
|
||||
numbering : 번호매기기/글머리표
|
||||
image : 이미지/그리기 객체
|
||||
content_order: 본문 콘텐츠 순서 (section*.xml)
|
||||
"""
|
||||
|
||||
from . import page_setup
|
||||
from . import font
|
||||
from . import char_style
|
||||
from . import para_style
|
||||
from . import border_fill
|
||||
from . import table
|
||||
from . import header_footer
|
||||
from . import section
|
||||
from . import style_def
|
||||
from . import numbering
|
||||
from . import image
|
||||
from . import content_order
|
||||
|
||||
__all__ = [
|
||||
"page_setup",
|
||||
"font",
|
||||
"char_style",
|
||||
"para_style",
|
||||
"border_fill",
|
||||
"table",
|
||||
"header_footer",
|
||||
"section",
|
||||
"style_def",
|
||||
"numbering",
|
||||
"image",
|
||||
"content_order"
|
||||
]
|
||||
127
03. Code/geulbeot_10th/handlers/template/tools/border_fill.py
Normal file
127
03. Code/geulbeot_10th/handlers/template/tools/border_fill.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§2 테두리/배경(BorderFill) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:borderFill id="3" threeD="0" shadow="0" centerLine="NONE" ...>
|
||||
<hh:leftBorder type="SOLID" width="0.12 mm" color="#000000"/>
|
||||
<hh:rightBorder type="SOLID" width="0.12 mm" color="#000000"/>
|
||||
<hh:topBorder type="SOLID" width="0.12 mm" color="#000000"/>
|
||||
<hh:bottomBorder type="SOLID" width="0.12 mm" color="#000000"/>
|
||||
<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>
|
||||
<hc:fillBrush>
|
||||
<hc:winBrush faceColor="#EDEDED" hatchColor="#FFE7E7E7" alpha="0"/>
|
||||
</hc:fillBrush>
|
||||
</hh:borderFill>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import BORDER_TYPE_TO_CSS, hwpx_border_to_css
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""§2 borderFill 전체 추출 → id별 dict.
|
||||
|
||||
Returns:
|
||||
{
|
||||
3: {
|
||||
"id": 3,
|
||||
"left": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
|
||||
"right": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
|
||||
"top": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
|
||||
"bottom": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"},
|
||||
"diagonal": {"type": "SOLID", "width": "0.1 mm", "color": "#000000"},
|
||||
"background": "#EDEDED", # fillBrush faceColor
|
||||
"css": { # 편의: 미리 변환된 CSS
|
||||
"border-left": "0.12mm solid #000000",
|
||||
...
|
||||
"background-color": "#EDEDED",
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
또는 추출 실패 시 None
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
blocks = re.findall(
|
||||
r'<hh:borderFill\b([^>]*)>(.*?)</hh:borderFill>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
for attrs_str, inner in blocks:
|
||||
id_m = re.search(r'\bid="(\d+)"', attrs_str)
|
||||
if not id_m:
|
||||
continue
|
||||
bf_id = int(id_m.group(1))
|
||||
|
||||
item = {"id": bf_id}
|
||||
|
||||
# 4방향 + diagonal
|
||||
for side, tag in [
|
||||
("left", "leftBorder"),
|
||||
("right", "rightBorder"),
|
||||
("top", "topBorder"),
|
||||
("bottom", "bottomBorder"),
|
||||
("diagonal", "diagonal"),
|
||||
]:
|
||||
# 태그 전체를 먼저 찾고, 속성을 개별 추출 (순서 무관)
|
||||
tag_m = re.search(rf'<hh:{tag}\b([^/]*?)/?>', inner)
|
||||
if tag_m:
|
||||
tag_attrs = tag_m.group(1)
|
||||
t = re.search(r'\btype="([^"]+)"', tag_attrs)
|
||||
w = re.search(r'\bwidth="([^"]+)"', tag_attrs)
|
||||
c = re.search(r'\bcolor="([^"]+)"', tag_attrs)
|
||||
item[side] = {
|
||||
"type": t.group(1) if t else "NONE",
|
||||
"width": w.group(1).replace(" ", "") if w else "0.12mm",
|
||||
"color": c.group(1) if c else "#000000",
|
||||
}
|
||||
|
||||
# 배경 (fillBrush > winBrush faceColor)
|
||||
bg_m = re.search(
|
||||
r'<hc:winBrush\b[^>]*\bfaceColor="([^"]+)"', inner
|
||||
)
|
||||
if bg_m:
|
||||
face = bg_m.group(1)
|
||||
if face and face.lower() != "none":
|
||||
item["background"] = face
|
||||
|
||||
# CSS 편의 변환
|
||||
css = {}
|
||||
for side in ["left", "right", "top", "bottom"]:
|
||||
border_data = item.get(side)
|
||||
if border_data:
|
||||
css[f"border-{side}"] = hwpx_border_to_css(border_data)
|
||||
else:
|
||||
css[f"border-{side}"] = "none"
|
||||
# border_data가 없으면 CSS에도 넣지 않음
|
||||
|
||||
if "background" in item:
|
||||
css["background-color"] = item["background"]
|
||||
|
||||
if css:
|
||||
item["css"] = css
|
||||
|
||||
result[bf_id] = item
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
133
03. Code/geulbeot_10th/handlers/template/tools/char_style.py
Normal file
133
03. Code/geulbeot_10th/handlers/template/tools/char_style.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§4 글자 모양(CharShape) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none"
|
||||
useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="2">
|
||||
<hh:fontRef hangul="7" latin="6" hanja="6" .../>
|
||||
<hh:ratio hangul="100" latin="100" .../>
|
||||
<hh:spacing hangul="0" latin="0" .../>
|
||||
<hh:relSz hangul="100" latin="100" .../>
|
||||
<hh:offset hangul="0" latin="0" .../>
|
||||
<hh:bold/> <!-- 존재하면 bold -->
|
||||
<hh:italic/> <!-- 존재하면 italic -->
|
||||
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
|
||||
<hh:strikeout shape="NONE" color="#000000"/>
|
||||
</hh:charPr>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import charsize_to_pt
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
|
||||
"""§4 charPr 전체 목록 추출.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"height_pt": 10.0,
|
||||
"textColor": "#000000",
|
||||
"bold": False,
|
||||
"italic": False,
|
||||
"underline": "NONE",
|
||||
"strikeout": "NONE",
|
||||
"fontRef": {"hangul": 7, "latin": 6, ...},
|
||||
"ratio": {"hangul": 100, "latin": 100, ...},
|
||||
"spacing": {"hangul": 0, "latin": 0, ...},
|
||||
"borderFillIDRef": 2,
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
# charPr 블록 추출 (self-closing이 아닌 블록)
|
||||
blocks = re.findall(
|
||||
r'<hh:charPr\b([^>]*)>(.*?)</hh:charPr>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for attrs_str, inner in blocks:
|
||||
item = {}
|
||||
|
||||
# 속성 파싱
|
||||
id_m = re.search(r'\bid="(\d+)"', attrs_str)
|
||||
if id_m:
|
||||
item["id"] = int(id_m.group(1))
|
||||
|
||||
height_m = re.search(r'\bheight="(\d+)"', attrs_str)
|
||||
if height_m:
|
||||
item["height_pt"] = charsize_to_pt(int(height_m.group(1)))
|
||||
|
||||
color_m = re.search(r'\btextColor="([^"]+)"', attrs_str)
|
||||
if color_m:
|
||||
item["textColor"] = color_m.group(1)
|
||||
|
||||
shade_m = re.search(r'\bshadeColor="([^"]+)"', attrs_str)
|
||||
if shade_m and shade_m.group(1) != "none":
|
||||
item["shadeColor"] = shade_m.group(1)
|
||||
|
||||
bf_m = re.search(r'\bborderFillIDRef="(\d+)"', attrs_str)
|
||||
if bf_m:
|
||||
item["borderFillIDRef"] = int(bf_m.group(1))
|
||||
|
||||
# bold / italic (태그 존재 여부로 판단)
|
||||
item["bold"] = bool(re.search(r'<hh:bold\s*/?>', inner))
|
||||
item["italic"] = bool(re.search(r'<hh:italic\s*/?>', inner))
|
||||
|
||||
# fontRef
|
||||
fr = re.search(r'<hh:fontRef\b([^/]*)/>', inner)
|
||||
if fr:
|
||||
item["fontRef"] = _parse_lang_attrs(fr.group(1))
|
||||
|
||||
# ratio
|
||||
ra = re.search(r'<hh:ratio\b([^/]*)/>', inner)
|
||||
if ra:
|
||||
item["ratio"] = _parse_lang_attrs(ra.group(1))
|
||||
|
||||
# spacing
|
||||
sp = re.search(r'<hh:spacing\b([^/]*)/>', inner)
|
||||
if sp:
|
||||
item["spacing"] = _parse_lang_attrs(sp.group(1))
|
||||
|
||||
# underline
|
||||
ul = re.search(r'<hh:underline\b[^>]*\btype="([^"]+)"', inner)
|
||||
if ul:
|
||||
item["underline"] = ul.group(1)
|
||||
|
||||
# strikeout
|
||||
so = re.search(r'<hh:strikeout\b[^>]*\bshape="([^"]+)"', inner)
|
||||
if so:
|
||||
item["strikeout"] = so.group(1)
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _parse_lang_attrs(attrs_str: str) -> dict:
|
||||
"""hangul="7" latin="6" ... → {"hangul": 7, "latin": 6, ...}"""
|
||||
pairs = re.findall(r'(\w+)="(-?\d+)"', attrs_str)
|
||||
return {k: int(v) for k, v in pairs}
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
550
03. Code/geulbeot_10th/handlers/template/tools/content_order.py
Normal file
550
03. Code/geulbeot_10th/handlers/template/tools/content_order.py
Normal file
@@ -0,0 +1,550 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
content_order.py — HWPX section*.xml 본문 콘텐츠 순서 추출
|
||||
|
||||
기존 12개 tool이 header.xml의 "정의(definition)"를 추출하는 반면,
|
||||
이 tool은 section0.xml의 "본문(content)" 순서를 추출한다.
|
||||
|
||||
추출 결과는 template_manager._build_body_html()이
|
||||
원본 순서 그대로 HTML을 조립하는 데 사용된다.
|
||||
|
||||
콘텐츠 유형:
|
||||
- paragraph : 일반 텍스트 문단
|
||||
- table : 표 (<hp:tbl>)
|
||||
- image : 이미지 (<hp:pic>)
|
||||
- empty : 빈 문단 (줄바꿈 역할)
|
||||
|
||||
참조: hwpx_domain_guide.md §6(표), §7(본문 구조)
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ================================================================
|
||||
# 네임스페이스
|
||||
# ================================================================
|
||||
# HWPX는 여러 네임스페이스를 사용한다.
|
||||
# section*.xml: hp: (본문), ha: (속성)
|
||||
# header.xml: hh: (헤더 정의)
|
||||
# 실제 파일에서 네임스페이스 URI가 다를 수 있으므로 로컬명 기반 탐색도 병행한다.
|
||||
|
||||
DEFAULT_NS = {
|
||||
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
|
||||
'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes',
|
||||
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
|
||||
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 공개 API
|
||||
# ================================================================
|
||||
|
||||
def extract(raw_xml, parsed, ns=None):
|
||||
"""section*.xml에서 본문 콘텐츠 순서를 추출한다.
|
||||
|
||||
Args:
|
||||
raw_xml (dict): 원본 XML 문자열 딕셔너리.
|
||||
raw_xml.get("section0") 등으로 section XML에 접근.
|
||||
parsed (dict): processor.py가 HWPX를 파싱한 전체 결과 dict.
|
||||
parsed.get("section_xml") 등으로 parsed Element에 접근.
|
||||
ns (dict, optional): 네임스페이스 매핑. None이면 자동 감지.
|
||||
|
||||
Returns:
|
||||
list[dict]: 콘텐츠 순서 리스트. 각 항목은 다음 키를 포함:
|
||||
- type: "paragraph" | "table" | "image" | "empty"
|
||||
- index: 전체 순서 내 인덱스 (0부터)
|
||||
- paraPrIDRef: 문단모양 참조 ID (str or None)
|
||||
- styleIDRef: 스타일 참조 ID (str or None)
|
||||
+ type별 추가 키 (아래 참조)
|
||||
추출 실패 시 None 반환 (analyzer가 결과에서 제외함).
|
||||
"""
|
||||
# ── section XML 찾기 ──
|
||||
# raw_xml dict에서 section 원본 문자열 추출
|
||||
section_raw = None
|
||||
if isinstance(raw_xml, dict):
|
||||
# 키 이름은 프로젝트마다 다를 수 있음: section0, section_xml 등
|
||||
for key in ['section0', 'section_xml', 'section0.xml']:
|
||||
if key in raw_xml:
|
||||
section_raw = raw_xml[key]
|
||||
break
|
||||
# 못 찾으면 "section"으로 시작하는 첫 번째 키
|
||||
if section_raw is None:
|
||||
for key, val in raw_xml.items():
|
||||
if key.startswith('section') and isinstance(val, str):
|
||||
section_raw = val
|
||||
break
|
||||
elif isinstance(raw_xml, str):
|
||||
section_raw = raw_xml
|
||||
|
||||
# parsed dict에서 section Element 또는 문자열 추출
|
||||
section_parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']:
|
||||
val = parsed.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, str):
|
||||
# 문자열이면 section_raw로 활용 (table.py와 동일)
|
||||
if section_raw is None:
|
||||
section_raw = val
|
||||
elif not isinstance(val, dict):
|
||||
# Element 객체로 추정
|
||||
section_parsed = val
|
||||
break
|
||||
# fallback: raw_xml 문자열을 직접 파싱
|
||||
if section_parsed is None and section_raw:
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
section_parsed = ET.fromstring(section_raw)
|
||||
except ET.ParseError:
|
||||
logger.warning("section XML 파싱 실패")
|
||||
return None
|
||||
else:
|
||||
# parsed 자체가 Element일 수 있음 (직접 호출 시)
|
||||
section_parsed = parsed
|
||||
|
||||
if section_parsed is None:
|
||||
logger.warning("section XML을 찾을 수 없음 — content_order 추출 생략")
|
||||
return None
|
||||
|
||||
if ns is None:
|
||||
ns = _detect_namespaces(section_raw or '', section_parsed)
|
||||
|
||||
# <hp:p> 엘리먼트 수집 — secPr 내부는 제외
|
||||
paragraphs = _collect_body_paragraphs(section_parsed, ns)
|
||||
|
||||
content_order = []
|
||||
table_idx = 0
|
||||
image_idx = 0
|
||||
|
||||
for p_elem in paragraphs:
|
||||
para_pr_id = _get_attr(p_elem, 'paraPrIDRef')
|
||||
style_id = _get_attr(p_elem, 'styleIDRef')
|
||||
|
||||
base = {
|
||||
'index': len(content_order),
|
||||
'paraPrIDRef': para_pr_id,
|
||||
'styleIDRef': style_id,
|
||||
}
|
||||
|
||||
# ── (1) 표 확인 ──
|
||||
tbl = _find_element(p_elem, 'tbl', ns)
|
||||
if tbl is not None:
|
||||
tbl_info = _extract_table_info(tbl, ns)
|
||||
content_order.append({
|
||||
**base,
|
||||
'type': 'table',
|
||||
'table_idx': table_idx,
|
||||
**tbl_info,
|
||||
})
|
||||
table_idx += 1
|
||||
continue
|
||||
|
||||
# ── (2) 이미지 확인 ──
|
||||
pic = _find_element(p_elem, 'pic', ns)
|
||||
if pic is not None:
|
||||
img_info = _extract_image_info(pic, p_elem, ns)
|
||||
|
||||
# ★ 인라인 아이콘이면 텍스트와 합쳐서 paragraph로 처리
|
||||
if img_info.get('is_inline_icon') and img_info.get('text'):
|
||||
content_order.append({
|
||||
**base,
|
||||
'type': 'paragraph',
|
||||
'text': img_info['text'].strip(),
|
||||
'charPrIDRef': None,
|
||||
'has_icon': True,
|
||||
})
|
||||
continue
|
||||
|
||||
# 일반 이미지
|
||||
content_order.append({
|
||||
**base,
|
||||
'type': 'image',
|
||||
'image_idx': image_idx,
|
||||
**img_info,
|
||||
})
|
||||
image_idx += 1
|
||||
continue
|
||||
|
||||
# ── (3) 텍스트 문단 / 빈 문단 ──
|
||||
text = _collect_text(p_elem, ns)
|
||||
runs_info = _extract_runs_info(p_elem, ns)
|
||||
|
||||
if not text.strip():
|
||||
content_order.append({
|
||||
**base,
|
||||
'type': 'empty',
|
||||
})
|
||||
else:
|
||||
content_order.append({
|
||||
**base,
|
||||
'type': 'paragraph',
|
||||
'text': text,
|
||||
'charPrIDRef': runs_info.get('first_charPrIDRef'),
|
||||
'runs': runs_info.get('runs', []),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"content_order 추출 완료: %d items "
|
||||
"(paragraphs=%d, tables=%d, images=%d, empty=%d)",
|
||||
len(content_order),
|
||||
sum(1 for c in content_order if c['type'] == 'paragraph'),
|
||||
table_idx,
|
||||
image_idx,
|
||||
sum(1 for c in content_order if c['type'] == 'empty'),
|
||||
)
|
||||
|
||||
return content_order
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 본문 <hp:p> 수집 — secPr 내부 제외
|
||||
# ================================================================
|
||||
|
||||
def _collect_body_paragraphs(root, ns):
|
||||
"""<hp:sec> 직계 <hp:p> 만 수집한다.
|
||||
|
||||
secPr, headerFooter 내부의 <hp:p>는 본문이 아니므로 제외.
|
||||
subList 내부(셀 안 문단)도 제외 — 표는 통째로 하나의 항목.
|
||||
"""
|
||||
paragraphs = []
|
||||
|
||||
# 방법 1: sec 직계 자식 중 p 태그만
|
||||
sec = _find_element(root, 'sec', ns)
|
||||
if sec is None:
|
||||
# 루트 자체가 sec일 수 있음
|
||||
sec = root
|
||||
|
||||
for child in sec:
|
||||
tag = _local_tag(child)
|
||||
if tag == 'p':
|
||||
paragraphs.append(child)
|
||||
|
||||
# 직계 자식에서 못 찾았으면 fallback: 전체 탐색 (but secPr/subList 제외)
|
||||
if not paragraphs:
|
||||
paragraphs = _collect_paragraphs_fallback(root, ns)
|
||||
|
||||
return paragraphs
|
||||
|
||||
|
||||
def _collect_paragraphs_fallback(root, ns):
|
||||
"""fallback: 전체에서 <hp:p>를 찾되, secPr/headerFooter/subList 내부는 제외"""
|
||||
skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'}
|
||||
result = []
|
||||
|
||||
def _walk(elem, skip=False):
|
||||
if skip:
|
||||
return
|
||||
tag = _local_tag(elem)
|
||||
if tag in skip_tags:
|
||||
return
|
||||
if tag == 'p':
|
||||
# 부모가 sec이거나 루트 직계인 경우만
|
||||
result.append(elem)
|
||||
return # p 내부의 하위 p는 수집하지 않음
|
||||
for child in elem:
|
||||
_walk(child)
|
||||
|
||||
_walk(root)
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 표 정보 추출
|
||||
# ================================================================
|
||||
|
||||
def _extract_table_info(tbl, ns):
|
||||
"""<hp:tbl> 에서 기본 메타 정보 추출"""
|
||||
info = {
|
||||
'rowCnt': _get_attr(tbl, 'rowCnt'),
|
||||
'colCnt': _get_attr(tbl, 'colCnt'),
|
||||
'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'),
|
||||
}
|
||||
|
||||
# 열 너비
|
||||
col_sz = _find_element(tbl, 'colSz', ns)
|
||||
if col_sz is not None:
|
||||
width_list_elem = _find_element(col_sz, 'widthList', ns)
|
||||
if width_list_elem is not None and width_list_elem.text:
|
||||
info['colWidths'] = width_list_elem.text.strip().split()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 이미지 정보 추출
|
||||
# ================================================================
|
||||
|
||||
def _extract_image_info(pic, p_elem, ns):
|
||||
"""<hp:pic> 에서 이미지 참조 정보 추출"""
|
||||
info = {
|
||||
'binaryItemIDRef': None,
|
||||
'text': '',
|
||||
'is_inline_icon': False, # ★ 추가
|
||||
}
|
||||
|
||||
# img 태그에서 binaryItemIDRef
|
||||
img = _find_element(pic, 'img', ns)
|
||||
if img is not None:
|
||||
info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef')
|
||||
|
||||
# ★ pos 태그에서 treatAsChar 확인 — 인라인 아이콘 판별
|
||||
pos = _find_element(pic, 'pos', ns)
|
||||
if pos is not None:
|
||||
treat_as_char = _get_attr(pos, 'treatAsChar')
|
||||
if treat_as_char == '1':
|
||||
info['is_inline_icon'] = True
|
||||
|
||||
# imgRect에서 크기 정보
|
||||
img_rect = _find_element(pic, 'imgRect', ns)
|
||||
if img_rect is not None:
|
||||
info['imgRect'] = {
|
||||
'x': _get_attr(img_rect, 'x'),
|
||||
'y': _get_attr(img_rect, 'y'),
|
||||
'w': _get_attr(img_rect, 'w'),
|
||||
'h': _get_attr(img_rect, 'h'),
|
||||
}
|
||||
|
||||
# 같은 문단 내 텍스트 (pic 바깥의 run들)
|
||||
info['text'] = _collect_text_outside(p_elem, pic, ns)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 텍스트 수집
|
||||
# ================================================================
|
||||
|
||||
def _collect_text(p_elem, ns):
|
||||
"""<hp:p> 내 모든 <hp:t> 텍스트를 순서대로 합침
|
||||
|
||||
주의: t.tail은 XML 들여쓰기 공백이므로 수집하지 않는다.
|
||||
HWPX에서 실제 텍스트는 항상 <hp:t>...</hp:t> 안에 있다.
|
||||
"""
|
||||
parts = []
|
||||
for t in _find_all_elements(p_elem, 't', ns):
|
||||
if t.text:
|
||||
parts.append(t.text)
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def _collect_text_outside(p_elem, exclude_elem, ns):
|
||||
"""p_elem 내에서 exclude_elem(예: pic) 바깥의 텍스트만 수집"""
|
||||
parts = []
|
||||
|
||||
def _walk(elem):
|
||||
if elem is exclude_elem:
|
||||
return
|
||||
tag = _local_tag(elem)
|
||||
if tag == 't' and elem.text:
|
||||
parts.append(elem.text)
|
||||
for child in elem:
|
||||
_walk(child)
|
||||
|
||||
_walk(p_elem)
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Run 정보 추출
|
||||
# ================================================================
|
||||
|
||||
def _extract_runs_info(p_elem, ns):
|
||||
"""<hp:p> 내 <hp:run> 들의 charPrIDRef와 텍스트 추출
|
||||
|
||||
Returns:
|
||||
{
|
||||
'first_charPrIDRef': str or None,
|
||||
'runs': [
|
||||
{'charPrIDRef': '8', 'text': '1. SamanPro...'},
|
||||
{'charPrIDRef': '24', 'text': '포장설계...'},
|
||||
]
|
||||
}
|
||||
"""
|
||||
runs = []
|
||||
first_char_pr = None
|
||||
|
||||
for run_elem in _find_direct_runs(p_elem, ns):
|
||||
char_pr = _get_attr(run_elem, 'charPrIDRef')
|
||||
if first_char_pr is None and char_pr is not None:
|
||||
first_char_pr = char_pr
|
||||
|
||||
text_parts = []
|
||||
for t in _find_all_elements(run_elem, 't', ns):
|
||||
if t.text:
|
||||
text_parts.append(t.text)
|
||||
|
||||
if text_parts:
|
||||
runs.append({
|
||||
'charPrIDRef': char_pr,
|
||||
'text': ''.join(text_parts),
|
||||
})
|
||||
|
||||
return {
|
||||
'first_charPrIDRef': first_char_pr,
|
||||
'runs': runs,
|
||||
}
|
||||
|
||||
|
||||
def _find_direct_runs(p_elem, ns):
|
||||
"""<hp:p> 직계 <hp:run>만 찾음 (subList 내부 제외)"""
|
||||
results = []
|
||||
for child in p_elem:
|
||||
tag = _local_tag(child)
|
||||
if tag == 'run':
|
||||
results.append(child)
|
||||
return results
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 네임스페이스 감지
|
||||
# ================================================================
|
||||
|
||||
def _detect_namespaces(raw_xml, parsed):
|
||||
"""XML에서 실제 사용된 네임스페이스 URI를 감지한다.
|
||||
|
||||
HWPX 버전에 따라 네임스페이스 URI가 다를 수 있다:
|
||||
- 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph
|
||||
- 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (일부)
|
||||
"""
|
||||
ns = dict(DEFAULT_NS)
|
||||
|
||||
if raw_xml:
|
||||
# xmlns:hp="..." 패턴으로 실제 URI 추출
|
||||
for prefix in ['hp', 'ha', 'hh', 'hc']:
|
||||
pattern = rf'xmlns:{prefix}="([^"]+)"'
|
||||
match = re.search(pattern, raw_xml)
|
||||
if match:
|
||||
ns[prefix] = match.group(1)
|
||||
|
||||
return ns
|
||||
|
||||
|
||||
# ================================================================
|
||||
# XML 유틸리티 — 네임스페이스 불가지론적 탐색
|
||||
# ================================================================
|
||||
|
||||
def _local_tag(elem):
|
||||
"""'{namespace}localname' → 'localname'"""
|
||||
tag = elem.tag
|
||||
if '}' in tag:
|
||||
return tag.split('}', 1)[1]
|
||||
return tag
|
||||
|
||||
|
||||
def _get_attr(elem, attr_name):
|
||||
"""속성값 가져오기. 네임스페이스 유무 모두 시도."""
|
||||
# 직접 속성명
|
||||
val = elem.get(attr_name)
|
||||
if val is not None:
|
||||
return val
|
||||
|
||||
# 네임스페이스 접두사가 붙은 속성 시도
|
||||
for full_attr in elem.attrib:
|
||||
if full_attr.endswith(attr_name):
|
||||
return elem.attrib[full_attr]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_element(parent, local_name, ns):
|
||||
"""자식 중 로컬명이 일치하는 첫 번째 엘리먼트를 찾는다.
|
||||
|
||||
네임스페이스 prefix 시도 후, 실패하면 로컬명 직접 비교.
|
||||
"""
|
||||
# 1차: 네임스페이스 prefix로 탐색
|
||||
for prefix in ['hp', 'hh', 'hc', 'ha']:
|
||||
uri = ns.get(prefix, '')
|
||||
found = parent.find(f'{{{uri}}}{local_name}')
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
# 2차: 직계 자식 로컬명 비교
|
||||
for child in parent:
|
||||
if _local_tag(child) == local_name:
|
||||
return child
|
||||
|
||||
# 3차: 재귀 탐색 (1단계만)
|
||||
for child in parent:
|
||||
for grandchild in child:
|
||||
if _local_tag(grandchild) == local_name:
|
||||
return grandchild
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_all_elements(parent, local_name, ns):
|
||||
"""하위 전체에서 로컬명이 일치하는 모든 엘리먼트를 찾는다."""
|
||||
results = []
|
||||
|
||||
def _walk(elem):
|
||||
if _local_tag(elem) == local_name:
|
||||
results.append(elem)
|
||||
for child in elem:
|
||||
_walk(child)
|
||||
|
||||
_walk(parent)
|
||||
return results
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 편의 함수
|
||||
# ================================================================
|
||||
|
||||
def summarize(content_order):
|
||||
"""content_order 리스트를 사람이 읽기 쉬운 요약으로 변환"""
|
||||
lines = []
|
||||
for item in content_order:
|
||||
idx = item['index']
|
||||
t = item['type']
|
||||
|
||||
if t == 'paragraph':
|
||||
text_preview = item['text'][:50]
|
||||
if len(item['text']) > 50:
|
||||
text_preview += '...'
|
||||
lines.append(
|
||||
f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} "
|
||||
f"charPr={item.get('charPrIDRef', '-'):<4s} "
|
||||
f"\"{text_preview}\""
|
||||
)
|
||||
elif t == 'table':
|
||||
lines.append(
|
||||
f"[{idx:3d}] T table_idx={item['table_idx']} "
|
||||
f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})"
|
||||
)
|
||||
elif t == 'image':
|
||||
ref = item.get('binaryItemIDRef', '?')
|
||||
caption = item.get('text', '')[:30]
|
||||
lines.append(
|
||||
f"[{idx:3d}] I image_idx={item['image_idx']} "
|
||||
f"ref={ref} \"{caption}\""
|
||||
)
|
||||
elif t == 'empty':
|
||||
lines.append(f"[{idx:3d}] _ (empty)")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_stats(content_order):
|
||||
"""content_order 통계 반환"""
|
||||
type_map = {
|
||||
'paragraph': 'paragraphs',
|
||||
'table': 'tables',
|
||||
'image': 'images',
|
||||
'empty': 'empty',
|
||||
}
|
||||
stats = {
|
||||
'total': len(content_order),
|
||||
'paragraphs': 0,
|
||||
'tables': 0,
|
||||
'images': 0,
|
||||
'empty': 0,
|
||||
}
|
||||
for item in content_order:
|
||||
key = type_map.get(item['type'])
|
||||
if key:
|
||||
stats[key] += 1
|
||||
return stats
|
||||
82
03. Code/geulbeot_10th/handlers/template/tools/font.py
Normal file
82
03. Code/geulbeot_10th/handlers/template/tools/font.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§3 글꼴(FaceName) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:fontface lang="HANGUL" fontCnt="9">
|
||||
<hh:font id="0" face="돋움" type="TTF" isEmbedded="0">
|
||||
<hh:font id="1" face="맑은 고딕" type="TTF" isEmbedded="0">
|
||||
</hh:fontface>
|
||||
<hh:fontface lang="LATIN" fontCnt="9">
|
||||
<hh:font id="0" face="돋움" type="TTF" isEmbedded="0">
|
||||
</hh:fontface>
|
||||
|
||||
디폴트값 생성 안 함. 추출 실패 시 None 반환.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""§3 fontface에서 언어별 글꼴 정의 추출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"HANGUL": [{"id": 0, "face": "돋움", "type": "TTF"}, ...],
|
||||
"LATIN": [{"id": 0, "face": "돋움", "type": "TTF"}, ...],
|
||||
"HANJA": [...],
|
||||
...
|
||||
}
|
||||
또는 추출 실패 시 None
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# fontface 블록을 lang별로 추출
|
||||
fontface_blocks = re.findall(
|
||||
r'<hh:fontface\b[^>]*\blang="([^"]+)"[^>]*>(.*?)</hh:fontface>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
|
||||
if not fontface_blocks:
|
||||
return None
|
||||
|
||||
for lang, block_content in fontface_blocks:
|
||||
fonts = []
|
||||
font_matches = re.finditer(
|
||||
r'<hh:font\b[^>]*'
|
||||
r'\bid="(\d+)"[^>]*'
|
||||
r'\bface="([^"]+)"[^>]*'
|
||||
r'\btype="([^"]+)"',
|
||||
block_content
|
||||
)
|
||||
for fm in font_matches:
|
||||
fonts.append({
|
||||
"id": int(fm.group(1)),
|
||||
"face": fm.group(2),
|
||||
"type": fm.group(3),
|
||||
})
|
||||
|
||||
if fonts:
|
||||
result[lang] = fonts
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
"""header.xml 문자열을 가져온다."""
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
|
||||
if isinstance(raw_xml, str):
|
||||
return raw_xml
|
||||
|
||||
return None
|
||||
200
03. Code/geulbeot_10th/handlers/template/tools/header_footer.py
Normal file
200
03. Code/geulbeot_10th/handlers/template/tools/header_footer.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§8 머리말/꼬리말(HeaderFooter) 추출
|
||||
|
||||
HWPX 실제 태그 (section0.xml):
|
||||
<hp:headerFooter ...>
|
||||
<!-- 내용은 section XML 내 또는 별도 header/footer 영역 -->
|
||||
</hp:headerFooter>
|
||||
|
||||
머리말/꼬리말 안에 표가 있는 경우:
|
||||
- 표의 셀에 다중행 텍스트가 포함될 수 있음
|
||||
- 각 셀의 colSpan, rowSpan, width, borderFillIDRef 등 추출 필요
|
||||
|
||||
secPr 내 속성:
|
||||
<hp:visibility hideFirstHeader="0" hideFirstFooter="0" .../>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import hwpunit_to_mm
|
||||
|
||||
|
||||
def extract_header(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""머리말 구조 추출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"exists": True,
|
||||
"type": "table" | "text",
|
||||
"hidden": False,
|
||||
"table": { ... } | None, # 표가 있는 경우
|
||||
"texts": ["부서명", ...],
|
||||
}
|
||||
"""
|
||||
return _extract_hf(raw_xml, parsed, "header")
|
||||
|
||||
|
||||
def extract_footer(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""꼬리말 구조 추출."""
|
||||
return _extract_hf(raw_xml, parsed, "footer")
|
||||
|
||||
|
||||
def _extract_hf(raw_xml: dict, parsed: dict, hf_type: str) -> dict | None:
|
||||
"""header 또는 footer 추출 공통 로직"""
|
||||
# 1) parsed에서 직접 제공된 header/footer XML
|
||||
hf_xml = None
|
||||
if parsed:
|
||||
key = f"page_{hf_type}_xml"
|
||||
hf_xml = parsed.get(key, "")
|
||||
|
||||
# 2) section XML에서 headerFooter 블록 탐색
|
||||
section_xml = _get_section_xml(raw_xml, parsed)
|
||||
|
||||
if not hf_xml and section_xml:
|
||||
# headerFooter 태그에서 header/footer 구분
|
||||
hf_blocks = re.findall(
|
||||
r'<hp:headerFooter\b([^>]*)>(.*?)</hp:headerFooter>',
|
||||
section_xml, re.DOTALL
|
||||
)
|
||||
for attrs, inner in hf_blocks:
|
||||
# type 속성으로 구분 (HEADER / FOOTER)
|
||||
type_m = re.search(r'\btype="([^"]+)"', attrs)
|
||||
if type_m:
|
||||
if type_m.group(1).upper() == hf_type.upper():
|
||||
hf_xml = inner
|
||||
break
|
||||
|
||||
if not hf_xml or not hf_xml.strip():
|
||||
return None # 해당 머리말/꼬리말 없음
|
||||
|
||||
result = {"exists": True}
|
||||
|
||||
# hidden 여부
|
||||
if section_xml:
|
||||
hide_key = f"hideFirst{'Header' if hf_type == 'header' else 'Footer'}"
|
||||
hide_m = re.search(rf'\b{hide_key}="(\d+)"', section_xml)
|
||||
if hide_m:
|
||||
result["hidden"] = bool(int(hide_m.group(1)))
|
||||
|
||||
# 텍스트 추출
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', hf_xml)
|
||||
clean_texts = [t.strip() for t in texts if t.strip()]
|
||||
if clean_texts:
|
||||
result["texts"] = clean_texts
|
||||
|
||||
# 표 존재 여부
|
||||
tbl_match = re.search(
|
||||
r'<hp:tbl\b([^>]*)>(.*?)</hp:tbl>',
|
||||
hf_xml, re.DOTALL
|
||||
)
|
||||
if tbl_match:
|
||||
result["type"] = "table"
|
||||
result["table"] = _parse_hf_table(tbl_match.group(1), tbl_match.group(2))
|
||||
else:
|
||||
result["type"] = "text"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_hf_table(tbl_attrs: str, tbl_inner: str) -> dict:
|
||||
"""머리말/꼬리말 내 표 파싱"""
|
||||
table = {}
|
||||
|
||||
# rowCnt, colCnt
|
||||
for attr in ["rowCnt", "colCnt"]:
|
||||
m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs)
|
||||
if m:
|
||||
table[attr] = int(m.group(1))
|
||||
|
||||
# 열 너비
|
||||
wl = re.search(r'<hp:widthList>([^<]+)</hp:widthList>', tbl_inner)
|
||||
if wl:
|
||||
try:
|
||||
widths = [int(w) for w in wl.group(1).strip().split()]
|
||||
table["colWidths_hu"] = widths
|
||||
total = sum(widths) or 1
|
||||
table["colWidths_pct"] = [round(w / total * 100) for w in widths]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 행/셀
|
||||
rows = []
|
||||
tr_blocks = re.findall(r'<hp:tr\b[^>]*>(.*?)</hp:tr>', tbl_inner, re.DOTALL)
|
||||
for tr in tr_blocks:
|
||||
cells = []
|
||||
tc_blocks = re.finditer(
|
||||
r'<hp:tc\b([^>]*)>(.*?)</hp:tc>', tr, re.DOTALL
|
||||
)
|
||||
for tc in tc_blocks:
|
||||
cell = _parse_hf_cell(tc.group(1), tc.group(2))
|
||||
cells.append(cell)
|
||||
rows.append(cells)
|
||||
|
||||
if rows:
|
||||
table["rows"] = rows
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def _parse_hf_cell(tc_attrs: str, tc_inner: str) -> dict:
|
||||
"""머리말/꼬리말 셀 파싱"""
|
||||
cell = {}
|
||||
|
||||
# borderFillIDRef
|
||||
bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs)
|
||||
if bf:
|
||||
cell["borderFillIDRef"] = int(bf.group(1))
|
||||
|
||||
# cellAddr
|
||||
addr = re.search(
|
||||
r'<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"',
|
||||
tc_inner
|
||||
)
|
||||
if addr:
|
||||
cell["colAddr"] = int(addr.group(1))
|
||||
cell["rowAddr"] = int(addr.group(2))
|
||||
|
||||
# cellSpan
|
||||
span = re.search(r'<hp:cellSpan\b([^/]*)/?>', tc_inner)
|
||||
if span:
|
||||
cs = re.search(r'\bcolSpan="(\d+)"', span.group(1))
|
||||
rs = re.search(r'\browSpan="(\d+)"', span.group(1))
|
||||
if cs:
|
||||
cell["colSpan"] = int(cs.group(1))
|
||||
if rs:
|
||||
cell["rowSpan"] = int(rs.group(1))
|
||||
|
||||
# cellSz
|
||||
sz = re.search(r'<hp:cellSz\b([^/]*)/?>', tc_inner)
|
||||
if sz:
|
||||
w = re.search(r'\bwidth="(\d+)"', sz.group(1))
|
||||
if w:
|
||||
cell["width_hu"] = int(w.group(1))
|
||||
|
||||
# 셀 텍스트 (다중행)
|
||||
paras = re.findall(r'<hp:p\b[^>]*>(.*?)</hp:p>', tc_inner, re.DOTALL)
|
||||
lines = []
|
||||
for p in paras:
|
||||
p_texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p)
|
||||
line = " ".join(t.strip() for t in p_texts if t.strip())
|
||||
if line:
|
||||
lines.append(line)
|
||||
|
||||
if lines:
|
||||
cell["text"] = " ".join(lines)
|
||||
cell["lines"] = lines
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("section_xml"):
|
||||
return parsed["section_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "section" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
98
03. Code/geulbeot_10th/handlers/template/tools/image.py
Normal file
98
03. Code/geulbeot_10th/handlers/template/tools/image.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
이미지/그리기 객체(ShapeObject) 추출
|
||||
|
||||
HWPX 실제 태그 (section0.xml):
|
||||
<hp:pic id="..." zOrder="..." ...>
|
||||
<hp:offset x="0" y="0"/>
|
||||
<hp:orgSz width="..." height="..."/>
|
||||
<hp:curSz width="..." height="..."/>
|
||||
<hp:imgRect>
|
||||
<hp:pt x="..." y="..."/> <!-- 4개 꼭짓점 -->
|
||||
</hp:imgRect>
|
||||
<hp:imgClip .../>
|
||||
<hp:img binaryItemIDRef="image1.JPG" .../>
|
||||
</hp:pic>
|
||||
|
||||
또는 그리기 객체:
|
||||
<hp:container id="..." ...>
|
||||
<hp:offset x="..." y="..."/>
|
||||
...
|
||||
</hp:container>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import hwpunit_to_mm
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
|
||||
"""이미지/그리기 객체 추출.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"type": "image",
|
||||
"binaryItemRef": "image1.JPG",
|
||||
"width_hu": 28346, "height_hu": 14173,
|
||||
"width_mm": 100.0, "height_mm": 50.0,
|
||||
"offset": {"x": 0, "y": 0},
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
section_xml = _get_section_xml(raw_xml, parsed)
|
||||
if not section_xml:
|
||||
return None
|
||||
|
||||
result = []
|
||||
|
||||
# <hp:pic> 블록
|
||||
pic_blocks = re.finditer(
|
||||
r'<hp:pic\b([^>]*)>(.*?)</hp:pic>',
|
||||
section_xml, re.DOTALL
|
||||
)
|
||||
for pm in pic_blocks:
|
||||
pic_inner = pm.group(2)
|
||||
item = {"type": "image"}
|
||||
|
||||
# binaryItemRef
|
||||
img = re.search(r'<hp:img\b[^>]*\bbinaryItemIDRef="([^"]+)"', pic_inner)
|
||||
if img:
|
||||
item["binaryItemRef"] = img.group(1)
|
||||
|
||||
# curSz (현재 크기)
|
||||
csz = re.search(
|
||||
r'<hp:curSz\b[^>]*\bwidth="(\d+)"[^>]*\bheight="(\d+)"',
|
||||
pic_inner
|
||||
)
|
||||
if csz:
|
||||
w, h = int(csz.group(1)), int(csz.group(2))
|
||||
item["width_hu"] = w
|
||||
item["height_hu"] = h
|
||||
item["width_mm"] = round(hwpunit_to_mm(w), 1)
|
||||
item["height_mm"] = round(hwpunit_to_mm(h), 1)
|
||||
|
||||
# offset
|
||||
off = re.search(
|
||||
r'<hp:offset\b[^>]*\bx="(-?\d+)"[^>]*\by="(-?\d+)"',
|
||||
pic_inner
|
||||
)
|
||||
if off:
|
||||
item["offset"] = {"x": int(off.group(1)), "y": int(off.group(2))}
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("section_xml"):
|
||||
return parsed["section_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "section" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
136
03. Code/geulbeot_10th/handlers/template/tools/numbering.py
Normal file
136
03. Code/geulbeot_10th/handlers/template/tools/numbering.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
번호매기기(Numbering) / 글머리표(Bullet) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:numbering id="1" start="0">
|
||||
<hh:paraHead start="1" level="1" align="LEFT" useInstWidth="1"
|
||||
autoIndent="1" widthAdjust="0" textOffsetType="PERCENT"
|
||||
textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295"
|
||||
checkable="0">^1.</hh:paraHead>
|
||||
<hh:paraHead start="1" level="2" ... numFormat="HANGUL_SYLLABLE">^2.</hh:paraHead>
|
||||
</hh:numbering>
|
||||
|
||||
<hh:bullet id="1" char="-" useImage="0">
|
||||
<hh:paraHead level="0" align="LEFT" .../>
|
||||
</hh:bullet>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""번호매기기 + 글머리표 정의 추출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"numberings": [
|
||||
{
|
||||
"id": 1, "start": 0,
|
||||
"levels": [
|
||||
{"level": 1, "numFormat": "DIGIT", "pattern": "^1.",
|
||||
"align": "LEFT"},
|
||||
{"level": 2, "numFormat": "HANGUL_SYLLABLE", "pattern": "^2."},
|
||||
...
|
||||
]
|
||||
}
|
||||
],
|
||||
"bullets": [
|
||||
{"id": 1, "char": "-", "useImage": False}
|
||||
]
|
||||
}
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# ── 번호매기기 ──
|
||||
numbering_blocks = re.findall(
|
||||
r'<hh:numbering\b([^>]*)>(.*?)</hh:numbering>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
if numbering_blocks:
|
||||
nums = []
|
||||
for attrs, inner in numbering_blocks:
|
||||
num = {}
|
||||
id_m = re.search(r'\bid="(\d+)"', attrs)
|
||||
if id_m:
|
||||
num["id"] = int(id_m.group(1))
|
||||
start_m = re.search(r'\bstart="(\d+)"', attrs)
|
||||
if start_m:
|
||||
num["start"] = int(start_m.group(1))
|
||||
|
||||
# paraHead 레벨들
|
||||
levels = []
|
||||
heads = re.finditer(
|
||||
r'<hh:paraHead\b([^>]*)>([^<]*)</hh:paraHead>',
|
||||
inner
|
||||
)
|
||||
for h in heads:
|
||||
h_attrs = h.group(1)
|
||||
h_pattern = h.group(2).strip()
|
||||
level = {}
|
||||
|
||||
lv = re.search(r'\blevel="(\d+)"', h_attrs)
|
||||
if lv:
|
||||
level["level"] = int(lv.group(1))
|
||||
|
||||
fmt = re.search(r'\bnumFormat="([^"]+)"', h_attrs)
|
||||
if fmt:
|
||||
level["numFormat"] = fmt.group(1)
|
||||
|
||||
al = re.search(r'\balign="([^"]+)"', h_attrs)
|
||||
if al:
|
||||
level["align"] = al.group(1)
|
||||
|
||||
if h_pattern:
|
||||
level["pattern"] = h_pattern
|
||||
|
||||
if level:
|
||||
levels.append(level)
|
||||
|
||||
if levels:
|
||||
num["levels"] = levels
|
||||
nums.append(num)
|
||||
|
||||
if nums:
|
||||
result["numberings"] = nums
|
||||
|
||||
# ── 글머리표 ──
|
||||
bullet_blocks = re.findall(
|
||||
r'<hh:bullet\b([^>]*)>(.*?)</hh:bullet>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
if bullet_blocks:
|
||||
bullets = []
|
||||
for attrs, inner in bullet_blocks:
|
||||
bullet = {}
|
||||
id_m = re.search(r'\bid="(\d+)"', attrs)
|
||||
if id_m:
|
||||
bullet["id"] = int(id_m.group(1))
|
||||
char_m = re.search(r'\bchar="([^"]*)"', attrs)
|
||||
if char_m:
|
||||
bullet["char"] = char_m.group(1)
|
||||
img_m = re.search(r'\buseImage="(\d+)"', attrs)
|
||||
if img_m:
|
||||
bullet["useImage"] = bool(int(img_m.group(1)))
|
||||
bullets.append(bullet)
|
||||
|
||||
if bullets:
|
||||
result["bullets"] = bullets
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
110
03. Code/geulbeot_10th/handlers/template/tools/page_setup.py
Normal file
110
03. Code/geulbeot_10th/handlers/template/tools/page_setup.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§7 용지 설정 추출 (pagePr + margin)
|
||||
|
||||
HWPX 실제 태그:
|
||||
<hp:pagePr landscape="WIDELY" width="59528" height="84188" gutterType="LEFT_ONLY">
|
||||
<hp:margin header="4251" footer="4251" gutter="0"
|
||||
left="5669" right="5669" top="2834" bottom="2834"/>
|
||||
|
||||
디폴트값 생성 안 함. 추출 실패 시 None 반환.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import hwpunit_to_mm, mm_format, detect_paper_size
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""§7 pagePr + margin에서 용지/여백 정보 추출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"paper": {"name": "A4", "width_mm": 210.0, "height_mm": 297.0,
|
||||
"landscape": True/False},
|
||||
"margins": {"top": "10.0mm", "bottom": "10.0mm",
|
||||
"left": "20.0mm", "right": "20.0mm",
|
||||
"header": "15.0mm", "footer": "15.0mm",
|
||||
"gutter": "0.0mm"}
|
||||
}
|
||||
또는 추출 실패 시 None
|
||||
"""
|
||||
section_xml = _get_section_xml(raw_xml, parsed)
|
||||
if not section_xml:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# ── 용지 크기 ─────────────────────────────────
|
||||
page_match = re.search(
|
||||
r'<hp:pagePr\b[^>]*'
|
||||
r'\bwidth="(\d+)"[^>]*'
|
||||
r'\bheight="(\d+)"',
|
||||
section_xml
|
||||
)
|
||||
if not page_match:
|
||||
# 속성 순서가 다를 수 있음
|
||||
page_match = re.search(
|
||||
r'<hp:pagePr\b[^>]*'
|
||||
r'\bheight="(\d+)"[^>]*'
|
||||
r'\bwidth="(\d+)"',
|
||||
section_xml
|
||||
)
|
||||
if page_match:
|
||||
h_hu, w_hu = int(page_match.group(1)), int(page_match.group(2))
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
w_hu, h_hu = int(page_match.group(1)), int(page_match.group(2))
|
||||
|
||||
landscape_match = re.search(
|
||||
r'<hp:pagePr\b[^>]*\blandscape="([^"]+)"', section_xml
|
||||
)
|
||||
is_landscape = False
|
||||
if landscape_match:
|
||||
is_landscape = landscape_match.group(1) == "WIDELY"
|
||||
|
||||
paper_name = detect_paper_size(w_hu, h_hu)
|
||||
|
||||
result["paper"] = {
|
||||
"name": paper_name,
|
||||
"width_mm": round(hwpunit_to_mm(w_hu), 1),
|
||||
"height_mm": round(hwpunit_to_mm(h_hu), 1),
|
||||
"landscape": is_landscape,
|
||||
}
|
||||
|
||||
# ── 여백 ──────────────────────────────────────
|
||||
margin_match = re.search(r'<hp:margin\b([^/]*)/>', section_xml)
|
||||
if not margin_match:
|
||||
return result # 용지 크기는 있으나 여백은 없을 수 있음
|
||||
|
||||
attrs_str = margin_match.group(1)
|
||||
margins = {}
|
||||
for key in ["top", "bottom", "left", "right", "header", "footer", "gutter"]:
|
||||
m = re.search(rf'\b{key}="(\d+)"', attrs_str)
|
||||
if m:
|
||||
margins[key] = mm_format(int(m.group(1)))
|
||||
|
||||
if margins:
|
||||
result["margins"] = margins
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
"""section XML 문자열을 가져온다."""
|
||||
# parsed에서 직접 제공
|
||||
if parsed and parsed.get("section_xml"):
|
||||
return parsed["section_xml"]
|
||||
|
||||
# raw_xml dict에서 section 파일 찾기
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "section" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
|
||||
# raw_xml이 문자열이면 그대로
|
||||
if isinstance(raw_xml, str):
|
||||
return raw_xml
|
||||
|
||||
return None
|
||||
185
03. Code/geulbeot_10th/handlers/template/tools/para_style.py
Normal file
185
03. Code/geulbeot_10th/handlers/template/tools/para_style.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§5 문단 모양(ParaShape) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:paraPr id="0" tabPrIDRef="1" condense="0" ...>
|
||||
<hh:align horizontal="JUSTIFY" vertical="BASELINE"/>
|
||||
<hh:heading type="NONE" idRef="0" level="0"/>
|
||||
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD"
|
||||
widowOrphan="0" keepWithNext="0" keepLines="0"
|
||||
pageBreakBefore="0" lineWrap="BREAK"/>
|
||||
<hp:case ...>
|
||||
<hh:margin>
|
||||
<hc:intent value="-1310" unit="HWPUNIT"/>
|
||||
<hc:left value="0" unit="HWPUNIT"/>
|
||||
<hc:right value="0" unit="HWPUNIT"/>
|
||||
<hc:prev value="0" unit="HWPUNIT"/>
|
||||
<hc:next value="0" unit="HWPUNIT"/>
|
||||
</hh:margin>
|
||||
<hh:lineSpacing type="PERCENT" value="130" unit="HWPUNIT"/>
|
||||
</hp:case>
|
||||
<hh:border borderFillIDRef="2" .../>
|
||||
</hh:paraPr>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import hwpunit_to_mm
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
|
||||
"""§5 paraPr 전체 목록 추출.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"align": "JUSTIFY",
|
||||
"verticalAlign": "BASELINE",
|
||||
"heading": {"type": "NONE", "idRef": 0, "level": 0},
|
||||
"breakSetting": {
|
||||
"widowOrphan": False, "keepWithNext": False,
|
||||
"keepLines": False, "pageBreakBefore": False,
|
||||
"lineWrap": "BREAK",
|
||||
"breakLatinWord": "KEEP_WORD",
|
||||
"breakNonLatinWord": "KEEP_WORD"
|
||||
},
|
||||
"margin": {
|
||||
"indent_hu": -1310, "left_hu": 0, "right_hu": 0,
|
||||
"before_hu": 0, "after_hu": 0,
|
||||
},
|
||||
"lineSpacing": {"type": "PERCENT", "value": 130},
|
||||
"borderFillIDRef": 2,
|
||||
"tabPrIDRef": 1,
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
blocks = re.findall(
|
||||
r'<hh:paraPr\b([^>]*)>(.*?)</hh:paraPr>',
|
||||
header_xml, re.DOTALL
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for attrs_str, inner in blocks:
|
||||
item = {}
|
||||
|
||||
# id
|
||||
id_m = re.search(r'\bid="(\d+)"', attrs_str)
|
||||
if id_m:
|
||||
item["id"] = int(id_m.group(1))
|
||||
|
||||
# tabPrIDRef
|
||||
tab_m = re.search(r'\btabPrIDRef="(\d+)"', attrs_str)
|
||||
if tab_m:
|
||||
item["tabPrIDRef"] = int(tab_m.group(1))
|
||||
|
||||
# align
|
||||
al = re.search(r'<hh:align\b[^>]*\bhorizontal="([^"]+)"', inner)
|
||||
if al:
|
||||
item["align"] = al.group(1)
|
||||
|
||||
val = re.search(r'<hh:align\b[^>]*\bvertical="([^"]+)"', inner)
|
||||
if val:
|
||||
item["verticalAlign"] = val.group(1)
|
||||
|
||||
# heading
|
||||
hd = re.search(
|
||||
r'<hh:heading\b[^>]*\btype="([^"]+)"[^>]*'
|
||||
r'\bidRef="(\d+)"[^>]*\blevel="(\d+)"', inner
|
||||
)
|
||||
if hd:
|
||||
item["heading"] = {
|
||||
"type": hd.group(1),
|
||||
"idRef": int(hd.group(2)),
|
||||
"level": int(hd.group(3)),
|
||||
}
|
||||
|
||||
# breakSetting
|
||||
bs = re.search(r'<hh:breakSetting\b([^/]*)/?>', inner)
|
||||
if bs:
|
||||
bstr = bs.group(1)
|
||||
item["breakSetting"] = {
|
||||
"widowOrphan": _bool_attr(bstr, "widowOrphan"),
|
||||
"keepWithNext": _bool_attr(bstr, "keepWithNext"),
|
||||
"keepLines": _bool_attr(bstr, "keepLines"),
|
||||
"pageBreakBefore": _bool_attr(bstr, "pageBreakBefore"),
|
||||
"lineWrap": _str_attr(bstr, "lineWrap"),
|
||||
"breakLatinWord": _str_attr(bstr, "breakLatinWord"),
|
||||
"breakNonLatinWord": _str_attr(bstr, "breakNonLatinWord"),
|
||||
}
|
||||
|
||||
# margin (hp:case 블록 내 첫 번째 사용 — HwpUnitChar case 우선)
|
||||
case_block = re.search(
|
||||
r'<hp:case\b[^>]*required-namespace="[^"]*HwpUnitChar[^"]*"[^>]*>'
|
||||
r'(.*?)</hp:case>',
|
||||
inner, re.DOTALL
|
||||
)
|
||||
margin_src = case_block.group(1) if case_block else inner
|
||||
|
||||
margin = {}
|
||||
for tag, key in [
|
||||
("intent", "indent_hu"),
|
||||
("left", "left_hu"),
|
||||
("right", "right_hu"),
|
||||
("prev", "before_hu"),
|
||||
("next", "after_hu"),
|
||||
]:
|
||||
m = re.search(
|
||||
rf'<hc:{tag}\b[^>]*\bvalue="(-?\d+)"', margin_src
|
||||
)
|
||||
if m:
|
||||
margin[key] = int(m.group(1))
|
||||
|
||||
if margin:
|
||||
item["margin"] = margin
|
||||
|
||||
# lineSpacing
|
||||
ls = re.search(
|
||||
r'<hh:lineSpacing\b[^>]*\btype="([^"]+)"[^>]*\bvalue="(\d+)"',
|
||||
margin_src
|
||||
)
|
||||
if ls:
|
||||
item["lineSpacing"] = {
|
||||
"type": ls.group(1),
|
||||
"value": int(ls.group(2)),
|
||||
}
|
||||
|
||||
# borderFillIDRef
|
||||
bf = re.search(r'<hh:border\b[^>]*\bborderFillIDRef="(\d+)"', inner)
|
||||
if bf:
|
||||
item["borderFillIDRef"] = int(bf.group(1))
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _bool_attr(s: str, name: str) -> bool | None:
|
||||
m = re.search(rf'\b{name}="(\d+)"', s)
|
||||
return bool(int(m.group(1))) if m else None
|
||||
|
||||
|
||||
def _str_attr(s: str, name: str) -> str | None:
|
||||
m = re.search(rf'\b{name}="([^"]+)"', s)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
120
03. Code/geulbeot_10th/handlers/template/tools/section.py
Normal file
120
03. Code/geulbeot_10th/handlers/template/tools/section.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§9 구역 정의(Section) 추출
|
||||
|
||||
HWPX 실제 태그 (section0.xml):
|
||||
<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134"
|
||||
tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT"
|
||||
outlineShapeIDRef="1" ...>
|
||||
<hp:grid lineGrid="0" charGrid="0" .../>
|
||||
<hp:startNum pageStartsOn="BOTH" page="0" .../>
|
||||
<hp:visibility hideFirstHeader="0" hideFirstFooter="0" .../>
|
||||
<hp:pagePr landscape="WIDELY" width="59528" height="84188" ...>
|
||||
<hp:margin header="4251" footer="4251" left="5669" right="5669"
|
||||
top="2834" bottom="2834"/>
|
||||
<hp:pageNum pos="BOTTOM_CENTER" formatType="DIGIT" sideChar="-"/>
|
||||
</hp:secPr>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> dict | None:
|
||||
"""§9 구역 속성 추출.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"textDirection": "HORIZONTAL",
|
||||
"hideFirstHeader": False,
|
||||
"hideFirstFooter": False,
|
||||
"pageNum": {"pos": "BOTTOM_CENTER", "formatType": "DIGIT",
|
||||
"sideChar": "-"},
|
||||
"startNum": {"page": 0},
|
||||
"colDef": None,
|
||||
}
|
||||
"""
|
||||
section_xml = _get_section_xml(raw_xml, parsed)
|
||||
if not section_xml:
|
||||
return None
|
||||
|
||||
sec_match = re.search(
|
||||
r'<hp:secPr\b([^>]*)>(.*?)</hp:secPr>',
|
||||
section_xml, re.DOTALL
|
||||
)
|
||||
if not sec_match:
|
||||
return None
|
||||
|
||||
attrs_str = sec_match.group(1)
|
||||
inner = sec_match.group(2)
|
||||
|
||||
result = {}
|
||||
|
||||
# textDirection
|
||||
td = re.search(r'\btextDirection="([^"]+)"', attrs_str)
|
||||
if td:
|
||||
result["textDirection"] = td.group(1)
|
||||
|
||||
# visibility
|
||||
vis = re.search(r'<hp:visibility\b([^/]*)/?>', inner)
|
||||
if vis:
|
||||
v = vis.group(1)
|
||||
for attr in ["hideFirstHeader", "hideFirstFooter",
|
||||
"hideFirstMasterPage", "hideFirstPageNum",
|
||||
"hideFirstEmptyLine"]:
|
||||
m = re.search(rf'\b{attr}="(\d+)"', v)
|
||||
if m:
|
||||
result[attr] = bool(int(m.group(1)))
|
||||
|
||||
# startNum
|
||||
sn = re.search(r'<hp:startNum\b([^/]*)/?>', inner)
|
||||
if sn:
|
||||
sns = sn.group(1)
|
||||
start = {}
|
||||
pso = re.search(r'\bpageStartsOn="([^"]+)"', sns)
|
||||
if pso:
|
||||
start["pageStartsOn"] = pso.group(1)
|
||||
pg = re.search(r'\bpage="(\d+)"', sns)
|
||||
if pg:
|
||||
start["page"] = int(pg.group(1))
|
||||
if start:
|
||||
result["startNum"] = start
|
||||
|
||||
# pageNum
|
||||
pn = re.search(r'<hp:pageNum\b([^/]*)/?>', inner)
|
||||
if pn:
|
||||
pns = pn.group(1)
|
||||
pagenum = {}
|
||||
for attr in ["pos", "formatType", "sideChar"]:
|
||||
m = re.search(rf'\b{attr}="([^"]*)"', pns)
|
||||
if m:
|
||||
pagenum[attr] = m.group(1)
|
||||
if pagenum:
|
||||
result["pageNum"] = pagenum
|
||||
|
||||
# colDef (단 설정)
|
||||
cd = re.search(r'<hp:colDef\b([^>]*)>(.*?)</hp:colDef>', inner, re.DOTALL)
|
||||
if cd:
|
||||
cds = cd.group(1)
|
||||
coldef = {}
|
||||
cnt = re.search(r'\bcount="(\d+)"', cds)
|
||||
if cnt:
|
||||
coldef["count"] = int(cnt.group(1))
|
||||
layout = re.search(r'\blayout="([^"]+)"', cds)
|
||||
if layout:
|
||||
coldef["layout"] = layout.group(1)
|
||||
if coldef:
|
||||
result["colDef"] = coldef
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("section_xml"):
|
||||
return parsed["section_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "section" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
68
03. Code/geulbeot_10th/handlers/template/tools/style_def.py
Normal file
68
03. Code/geulbeot_10th/handlers/template/tools/style_def.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
스타일 정의(Style) 추출
|
||||
|
||||
HWPX 실제 태그 (header.xml):
|
||||
<hh:styles itemCnt="12">
|
||||
<hh:style id="0" type="PARA" name="바탕글" engName="Normal"
|
||||
paraPrIDRef="3" charPrIDRef="0" nextStyleIDRef="0"
|
||||
langID="1042" lockForm="0"/>
|
||||
<hh:style id="1" type="PARA" name="머리말" engName="Header"
|
||||
paraPrIDRef="2" charPrIDRef="3" nextStyleIDRef="1" .../>
|
||||
</hh:styles>
|
||||
|
||||
charPrIDRef → charPr(글자모양), paraPrIDRef → paraPr(문단모양) 연결.
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
|
||||
"""스타일 정의 추출.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"id": 0, "type": "PARA",
|
||||
"name": "바탕글", "engName": "Normal",
|
||||
"paraPrIDRef": 3, "charPrIDRef": 0,
|
||||
"nextStyleIDRef": 0,
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
header_xml = _get_header_xml(raw_xml, parsed)
|
||||
if not header_xml:
|
||||
return None
|
||||
|
||||
styles = re.findall(r'<hh:style\b([^/]*)/>', header_xml)
|
||||
if not styles:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for s in styles:
|
||||
item = {}
|
||||
for attr in ["id", "paraPrIDRef", "charPrIDRef", "nextStyleIDRef"]:
|
||||
m = re.search(rf'\b{attr}="(\d+)"', s)
|
||||
if m:
|
||||
item[attr] = int(m.group(1))
|
||||
|
||||
for attr in ["type", "name", "engName"]:
|
||||
m = re.search(rf'\b{attr}="([^"]*)"', s)
|
||||
if m:
|
||||
item[attr] = m.group(1)
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("header_xml"):
|
||||
return parsed["header_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "header" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
328
03. Code/geulbeot_10th/handlers/template/tools/table.py
Normal file
328
03. Code/geulbeot_10th/handlers/template/tools/table.py
Normal file
@@ -0,0 +1,328 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
§6 표(Table) 구조 추출
|
||||
|
||||
HWPX 실제 태그 (section0.xml):
|
||||
<hp:tbl id="..." rowCnt="5" colCnt="3" cellSpacing="0"
|
||||
repeatHeader="1" pageBreak="CELL" ...>
|
||||
<hp:colSz><hp:widthList>8504 8504 8504</hp:widthList></hp:colSz>
|
||||
또는 열 수에 맞는 hp:colSz 형태
|
||||
<hp:tr>
|
||||
<hp:tc name="" header="0" borderFillIDRef="5" ...>
|
||||
<hp:cellAddr colAddr="0" rowAddr="0"/>
|
||||
<hp:cellSpan colSpan="2" rowSpan="1"/>
|
||||
<hp:cellSz width="17008" height="2400"/>
|
||||
<hp:cellMargin left="510" right="510" top="142" bottom="142"/>
|
||||
<hp:subList>
|
||||
<hp:p ...><hp:run ...><hp:t>셀 텍스트</hp:t></hp:run></hp:p>
|
||||
</hp:subList>
|
||||
</hp:tc>
|
||||
</hp:tr>
|
||||
</hp:tbl>
|
||||
|
||||
디폴트값 생성 안 함.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from domain.hwpx.hwpx_utils import hwpunit_to_mm
|
||||
|
||||
|
||||
def extract(raw_xml: dict, parsed: dict = None) -> list | None:
|
||||
"""§6 모든 표 추출.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{
|
||||
"index": 0,
|
||||
"rowCnt": 5, "colCnt": 3,
|
||||
"repeatHeader": True,
|
||||
"pageBreak": "CELL",
|
||||
"colWidths_hu": [8504, 8504, 8504],
|
||||
"colWidths_pct": [33, 34, 33],
|
||||
"rows": [
|
||||
[ # row 0
|
||||
{
|
||||
"colAddr": 0, "rowAddr": 0,
|
||||
"colSpan": 2, "rowSpan": 1,
|
||||
"width_hu": 17008, "height_hu": 2400,
|
||||
"borderFillIDRef": 5,
|
||||
"cellMargin": {"left": 510, "right": 510,
|
||||
"top": 142, "bottom": 142},
|
||||
"text": "셀 텍스트",
|
||||
"lines": ["셀 텍스트"],
|
||||
},
|
||||
...
|
||||
],
|
||||
...
|
||||
],
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
section_xml = _get_section_xml(raw_xml, parsed)
|
||||
if not section_xml:
|
||||
return None
|
||||
|
||||
# tbl 블록 전체 추출
|
||||
tbl_blocks = _find_tbl_blocks(section_xml)
|
||||
if not tbl_blocks:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for idx, (tbl_attrs, tbl_inner) in enumerate(tbl_blocks):
|
||||
tbl = {"index": idx}
|
||||
|
||||
# 표 속성
|
||||
for attr in ["rowCnt", "colCnt"]:
|
||||
m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs)
|
||||
if m:
|
||||
tbl[attr] = int(m.group(1))
|
||||
|
||||
rh = re.search(r'\brepeatHeader="(\d+)"', tbl_attrs)
|
||||
if rh:
|
||||
tbl["repeatHeader"] = bool(int(rh.group(1)))
|
||||
|
||||
pb = re.search(r'\bpageBreak="([^"]+)"', tbl_attrs)
|
||||
if pb:
|
||||
tbl["pageBreak"] = pb.group(1)
|
||||
|
||||
# 행/셀 (열 너비보다 먼저 — 첫 행에서 열 너비 추출 가능)
|
||||
rows = _extract_rows(tbl_inner)
|
||||
if rows:
|
||||
tbl["rows"] = rows
|
||||
|
||||
# 열 너비
|
||||
col_widths = _extract_col_widths(tbl_inner)
|
||||
if not col_widths and rows:
|
||||
# colSz 없으면 행 데이터에서 추출 (colspan 고려)
|
||||
col_cnt = tbl.get("colCnt", 0)
|
||||
col_widths = _col_widths_from_rows(rows, col_cnt)
|
||||
if not col_widths:
|
||||
col_widths = _col_widths_from_first_row(rows[0])
|
||||
if col_widths:
|
||||
tbl["colWidths_hu"] = col_widths
|
||||
total = sum(col_widths) or 1
|
||||
tbl["colWidths_pct"] = [round(w / total * 100) for w in col_widths]
|
||||
|
||||
result.append(tbl)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _find_tbl_blocks(xml: str) -> list:
|
||||
"""중첩 표를 고려하여 최상위 tbl 블록 추출"""
|
||||
blocks = []
|
||||
start = 0
|
||||
while True:
|
||||
# <hp:tbl 시작 찾기
|
||||
m = re.search(r'<hp:tbl\b([^>]*)>', xml[start:])
|
||||
if not m:
|
||||
break
|
||||
|
||||
attrs = m.group(1)
|
||||
tag_start = start + m.start()
|
||||
content_start = start + m.end()
|
||||
|
||||
# 중첩 카운트로 닫는 태그 찾기
|
||||
depth = 1
|
||||
pos = content_start
|
||||
while depth > 0 and pos < len(xml):
|
||||
open_m = re.search(r'<hp:tbl\b', xml[pos:])
|
||||
close_m = re.search(r'</hp:tbl>', xml[pos:])
|
||||
|
||||
if close_m is None:
|
||||
break
|
||||
|
||||
if open_m and open_m.start() < close_m.start():
|
||||
depth += 1
|
||||
pos += open_m.end()
|
||||
else:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
inner = xml[content_start:pos + close_m.start()]
|
||||
blocks.append((attrs, inner))
|
||||
pos += close_m.end()
|
||||
|
||||
start = pos
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_col_widths(tbl_inner: str) -> list | None:
|
||||
"""열 너비 HWPUNIT 추출"""
|
||||
# 패턴 1: <hp:colSz><hp:widthList>8504 8504 8504</hp:widthList>
|
||||
wl = re.search(r'<hp:widthList>([^<]+)</hp:widthList>', tbl_inner)
|
||||
if wl:
|
||||
try:
|
||||
return [int(w) for w in wl.group(1).strip().split()]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 패턴 2: 개별 colSz 태그
|
||||
cols = re.findall(r'<hp:colSz\b[^>]*\bwidth="(\d+)"', tbl_inner)
|
||||
if cols:
|
||||
return [int(c) for c in cols]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_rows(tbl_inner: str) -> list:
|
||||
"""tr/tc 파싱하여 2D 셀 배열 반환"""
|
||||
rows = []
|
||||
|
||||
tr_blocks = re.findall(
|
||||
r'<hp:tr\b[^>]*>(.*?)</hp:tr>', tbl_inner, re.DOTALL
|
||||
)
|
||||
|
||||
for tr_inner in tr_blocks:
|
||||
cells = []
|
||||
tc_blocks = re.finditer(
|
||||
r'<hp:tc\b([^>]*)>(.*?)</hp:tc>', tr_inner, re.DOTALL
|
||||
)
|
||||
|
||||
for tc_match in tc_blocks:
|
||||
tc_attrs = tc_match.group(1)
|
||||
tc_inner = tc_match.group(2)
|
||||
cell = _parse_cell(tc_attrs, tc_inner)
|
||||
cells.append(cell)
|
||||
|
||||
rows.append(cells)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_cell(tc_attrs: str, tc_inner: str) -> dict:
|
||||
"""개별 셀 파싱"""
|
||||
cell = {}
|
||||
|
||||
# borderFillIDRef on tc tag
|
||||
bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs)
|
||||
if bf:
|
||||
cell["borderFillIDRef"] = int(bf.group(1))
|
||||
|
||||
# header flag
|
||||
hd = re.search(r'\bheader="(\d+)"', tc_attrs)
|
||||
if hd:
|
||||
cell["isHeader"] = bool(int(hd.group(1)))
|
||||
|
||||
# cellAddr
|
||||
addr = re.search(
|
||||
r'<hp:cellAddr\b[^>]*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"',
|
||||
tc_inner
|
||||
)
|
||||
if addr:
|
||||
cell["colAddr"] = int(addr.group(1))
|
||||
cell["rowAddr"] = int(addr.group(2))
|
||||
|
||||
# cellSpan
|
||||
span = re.search(r'<hp:cellSpan\b([^/]*)/?>', tc_inner)
|
||||
if span:
|
||||
cs = re.search(r'\bcolSpan="(\d+)"', span.group(1))
|
||||
rs = re.search(r'\browSpan="(\d+)"', span.group(1))
|
||||
if cs:
|
||||
cell["colSpan"] = int(cs.group(1))
|
||||
if rs:
|
||||
cell["rowSpan"] = int(rs.group(1))
|
||||
|
||||
# cellSz
|
||||
sz = re.search(r'<hp:cellSz\b([^/]*)/?>', tc_inner)
|
||||
if sz:
|
||||
w = re.search(r'\bwidth="(\d+)"', sz.group(1))
|
||||
h = re.search(r'\bheight="(\d+)"', sz.group(1))
|
||||
if w:
|
||||
cell["width_hu"] = int(w.group(1))
|
||||
if h:
|
||||
cell["height_hu"] = int(h.group(1))
|
||||
|
||||
# cellMargin
|
||||
cm = re.search(r'<hp:cellMargin\b([^/]*)/?>', tc_inner)
|
||||
if cm:
|
||||
margin = {}
|
||||
for side in ["left", "right", "top", "bottom"]:
|
||||
m = re.search(rf'\b{side}="(\d+)"', cm.group(1))
|
||||
if m:
|
||||
margin[side] = int(m.group(1))
|
||||
if margin:
|
||||
cell["cellMargin"] = margin
|
||||
|
||||
# 셀 텍스트
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', tc_inner)
|
||||
all_text = " ".join(t.strip() for t in texts if t.strip())
|
||||
if all_text:
|
||||
cell["text"] = all_text
|
||||
|
||||
# ★ v2: 셀 내 run의 charPrIDRef 추출 (스타일 연결용)
|
||||
run_cprs = re.findall(r'<hp:run\b[^>]*\bcharPrIDRef="(\d+)"', tc_inner)
|
||||
if run_cprs:
|
||||
cell["charPrIDRefs"] = [int(c) for c in run_cprs]
|
||||
cell["primaryCharPrIDRef"] = int(run_cprs[0])
|
||||
|
||||
# ★ v2: 셀 내 p의 paraPrIDRef, styleIDRef 추출
|
||||
para_pprs = re.findall(r'<hp:p\b[^>]*\bparaPrIDRef="(\d+)"', tc_inner)
|
||||
if para_pprs:
|
||||
cell["paraPrIDRefs"] = [int(p) for p in para_pprs]
|
||||
cell["primaryParaPrIDRef"] = int(para_pprs[0])
|
||||
|
||||
para_stys = re.findall(r'<hp:p\b[^>]*\bstyleIDRef="(\d+)"', tc_inner)
|
||||
if para_stys:
|
||||
cell["styleIDRefs"] = [int(s) for s in para_stys]
|
||||
|
||||
# 다중행 (p 태그 기준)
|
||||
paras = re.findall(r'<hp:p\b[^>]*>(.*?)</hp:p>', tc_inner, re.DOTALL)
|
||||
lines = []
|
||||
for p in paras:
|
||||
p_texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p)
|
||||
line = " ".join(t.strip() for t in p_texts if t.strip())
|
||||
if line:
|
||||
lines.append(line)
|
||||
if lines:
|
||||
cell["lines"] = lines
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
def _col_widths_from_first_row(first_row: list) -> list | None:
|
||||
"""첫 행 셀의 width_hu에서 열 너비 추출 (colSz 없을 때 대체)"""
|
||||
widths = []
|
||||
for cell in first_row:
|
||||
w = cell.get("width_hu")
|
||||
if w:
|
||||
widths.append(w)
|
||||
return widths if widths else None
|
||||
|
||||
|
||||
def _col_widths_from_rows(rows: list, col_cnt: int) -> list | None:
|
||||
"""★ v2: 모든 행을 순회하여 colspan=1인 행에서 정확한 열 너비 추출.
|
||||
|
||||
첫 행에 colspan이 있으면 열 너비가 부정확하므로,
|
||||
모든 열이 colspan=1인 행을 찾아 사용.
|
||||
"""
|
||||
if not rows or not col_cnt:
|
||||
return None
|
||||
|
||||
# colspan=1인 셀만 있는 행 찾기 (모든 열 존재)
|
||||
for row in rows:
|
||||
# 이 행의 모든 셀이 colspan=1이고, 셀 수 == col_cnt인지
|
||||
all_single = all(cell.get("colSpan", 1) == 1 for cell in row)
|
||||
if all_single and len(row) == col_cnt:
|
||||
widths = []
|
||||
for cell in sorted(row, key=lambda c: c.get("colAddr", 0)):
|
||||
w = cell.get("width_hu")
|
||||
if w:
|
||||
widths.append(w)
|
||||
if len(widths) == col_cnt:
|
||||
return widths
|
||||
|
||||
# 못 찾으면 첫 행 폴백
|
||||
return _col_widths_from_first_row(rows[0]) if rows else None
|
||||
|
||||
|
||||
def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None:
|
||||
if parsed and parsed.get("section_xml"):
|
||||
return parsed["section_xml"]
|
||||
if isinstance(raw_xml, dict):
|
||||
for name, content in raw_xml.items():
|
||||
if "section" in name.lower() and isinstance(content, str):
|
||||
return content
|
||||
return raw_xml if isinstance(raw_xml, str) else None
|
||||
Reference in New Issue
Block a user