📦 Initialize Geulbeot structure and merge Prompts & test projects

This commit is contained in:
2026-03-05 11:32:29 +09:00
commit 555a954458
687 changed files with 205247 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
def format_date(date_str, source):
try:
if source in ["국토일보", "한건신문"]:
# 기자 이름과 함께 있는 날짜 형식 처리
date_obj = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
if date_obj:
return date_obj.group(0)
elif source in ["엔지니어링데일리", "건설이코노미뉴스", "공학저널"]:
# 기자 이름과 함께 있는 날짜 형식 처리
date_obj = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
if date_obj:
return date_obj.group(0)
elif source == "연합

View File

@@ -0,0 +1,11 @@
def format_date(date_str: str, source: str) -> str:
"""날짜 형식을 YYYY-MM-DD 로 변환"""
try:
match = re.search(r'\d{4}-\d{2}-\d{2}', date_str)
if match:
return match.group(0)
if source == '연합뉴스':
return datetime.strptime(date_str, '%m-%d %H:%M').strftime('2024-%m-%d')
return date_str
except Exception:
return date_str

View File

@@ -0,0 +1,13 @@
def summarize_data_for(section: str):
texts = []
for path in sorted(os.listdir(DATA_DIR)):
with open(path, encoding="utf-8", errors="ignore") as f:
texts.append(f.read())
prompt = (
f"다음 로우데이터를 바탕으로 {section} 섹션에 들어갈 핵심 사실과 수치를 200~300자로 요약해주세요.\n\n"
+ "\n\n".join(texts)
)
return call_claude(prompt)
# ─── 4) 이미지 자동 매핑 ─────────────────────────

View File

@@ -0,0 +1,20 @@
def is_likely_unit(cell_val):
"""단위일 가능성 판별 (사용자 제안 로직)"""
if not cell_val:
return False
val = str(cell_val).strip()
# 1. 빈 값 또는 너무 긴 텍스트 (단위는 보통 6자 이내)
if not val or len(val) > 6:
return False
# 2. 순수 숫자는 제외
cleaned = val.replace('.', '').replace(',', '').replace('-', '').replace(' ', '')
if cleaned.isdigit():
return False
# 3. 수식은 제외
if val.startswith('='):
return False
# 4. 일반적인 계산 기호 및 정크 기호 제외

View File

@@ -0,0 +1,12 @@
def fetch_article_content(url: str, source: str) -> str:
"""단일 기사 본문 추출"""
try:
resp = requests.get(url, verify=False, timeout=10)
resp.encoding = 'utf-8'
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
paragraphs = soup.find_all('p')
content = ' '.join(clean_text(p.get_text()) for p in paragraphs)
content = content.replace('\n', ' ')
if not content.strip():
logging.warning(f'No content for

View File

@@ -0,0 +1,8 @@
def analyze_references():
files = sorted(os.listdir(REF_DIR))
sys = "당신은 보고서 전문가입니다. 아래 파일명들을 보고, 이 프로젝트에 어울리는 보고서 스타일과 목차 구조를 요약해 주세요."
usr = "파일 목록:\n" + "\n".join(files)
return call_gpt(sys, usr)
# ─── 2) 가이드라인에서 필수 섹션 추출 ───────────

View File

@@ -0,0 +1,13 @@
def run_global_reconstruction(input_file):
print("로그: 전체 시트 통합 데이터를 분석 중입니다...")
df = pd.read_excel(input_file)
# 1. 전역 주소록 생성: (시트명, 셀위치) -> 값
# 예: { ('A1', 'G105'): 30.901, ('철근집계', 'C47'): 159.263 }
global_map = {}
for _, row in df.iterrows():
global_map[(str(row['시트명']), str(row['셀위치']))] = row['현재값']
def trace_logic(formula, current_sheet):
if not isinstance(formula, str) or not formula.startswith("'="):
return formula

View File

@@ -0,0 +1,17 @@
def extract_all_contents(file_path):
print(f"로그: 파일을 읽는 중입니다 (전체 내용 모드)...")
# 수식과 값을 동시에 비교하기 위해 data_only=False로 로드
wb = openpyxl.load_workbook(file_path, data_only=False)
all_content_data = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
print(f"\n" + "="*60)
print(f"▶ 시트 탐색 중: [ {sheet_name} ]")
print("="*60)
# 시트의 모든 셀을 하나하나 검사
for row in ws.iter_rows():
for cell in row:
value = ce

View File

@@ -0,0 +1,18 @@
def fetch_articles(
base_url: str,
article_sel: str,
title_sel: str,
date_sel: str,
start_page: int,
end_page: int,
source: str,
url_prefix: str = '',
date_fmt_func=None
) -> list:
"""리스트 페이지 순회하며 메타데이터 및 본문 수집"""
results = []
for page in range(start_page, end_page + 1):
try:
page_url = f"{base_url}{page}"
resp = requests.get(page_url, verify=False, timeout=10)
soup = BeautifulSoup(resp.text, 'html.parser

View File

@@ -0,0 +1,11 @@
def get_item_id_with_lookback(ws, row, col, section_start_row):
"""멀티라인 대응 상향 번호 탐색 - 섹션 경계 존중"""
for r in range(row, section_start_row - 1, -1):
# 새로운 섹션을 만나면 탐색 중단
f_val_check = str(ws.cell(row=r, column=6).value or "").strip()
if r != row and re.match(r'^\(.*\)$|^\[.*\]$', f_val_check):
break
# F열에서 번호 탐색
if re.search(ID_MARKER_PATTERN, f_val_check):
return re.search(ID_MARKER_PATTERN, f_val_check).group()

View File

@@ -0,0 +1,14 @@
def collect_app_usage(days_back):
server = 'localhost'
log_type = 'Security'
hand = win32evtlog.OpenEventLog(server, log_type)
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
usage_records = []
cutoff_date = datetime.datetime.now() - datetime.timedelta(days=days_back)
events = True
while events:
events = win32evtlog.ReadEventLog(hand, flags, 0)
for ev_obj in events:
event_time = ev_obj.TimeGenerated

View File

@@ -0,0 +1,11 @@
def extract_must_have_sections():
texts = []
for path in sorted(os.listdir(GUIDELINE_DIR)):
with open(path, encoding="utf-8", errors="ignore") as f:
texts.append(f.read())
sys = "법령·지침 문서를 바탕으로, 보고서에 반드시 들어가야 할 섹션(목차)을 순서대로 나열해 주세요."
usr = "\n\n---\n\n".join(texts)
return call_gpt(sys, usr).splitlines()
# ─── 3) 로우데이터에서 섹션별 내용 뽑기 ───────────

View File

@@ -0,0 +1,16 @@
def pick_images_for(section: str):
names = sorted(os.listdir(IMAGE_DIR))
prompt = (
f"보고서 {section} 섹션에 적합한 이미지를 아래 목록에서 1~2개 추천해 파일명만 리턴하세요:\n"
+ "\n".join(names)
)
resp = call_gpt("당신은 디자인 어시스턴트입니다.", prompt)
picked = []
for line in resp.splitlines():
fn = line.strip()
if fn in names:
picked.append(fn)
return picked
# ─── 5) 디자인 템플릿 선택 ───────────────────────

View File

@@ -0,0 +1,13 @@
class SslAdapter(HTTPAdapter):
def init_poolmanager(self, *args, **kwargs):
ctx = ssl.create_default_context()
ctx.set_ciphers('DEFAULT:@SECLEVEL=1')
self.poolmanager = PoolManager(*args, ssl_context=ctx, **kwargs)
session = requests.Session()
session.mount('https://', SslAdapter())
headers = {'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'ko-KR,ko;q=0.9'}
# -------------------------------------------------
# 사이트별 함수 (대한경제 제외)
# -----------------------------------

View File

@@ -0,0 +1,7 @@
def get_detail_content(detail_url):
res = requests.get(detail_url)
soup = BeautifulSoup(res.text, 'html.parser')
div = soup.find('div', {'data-v-5cb2d9fe': True})
if div and div.find('h2'):
return div.find('h2').text.strip()
return "설명이 없습니다."

View File

@@ -0,0 +1,11 @@
def fetch_dnews_articles(base_url, start_page, end_page):
# Selenium WebDriver 설정
options = webdriver.ChromeOptions()
options.add_argument('--headless') # 브라우저가 뜨지 않게 설정
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
# ChromeDriver 경로 설정
chromedriver_path = 'D:/python_for crawling/webdriver/chromedriver-win64/chromedriver.exe' # ChromeDriver 경로 설정
service = ChromeService(executable_path=chromedriver_path)
driver = webdr

View File

@@ -0,0 +1,17 @@
def extract_raw_constants(file_path):
# 수식 자체가 아닌 입력된 값을 확인하기 위해 로드
print(f"로그: 파일을 읽는 중입니다...")
wb = openpyxl.load_workbook(file_path, data_only=False)
raw_data = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
print(f"\n" + "="*50)
print(f"▶ [ {sheet_name} ] 시트의 원천 데이터(상수) 추출 시작")
print("="*50)
for row in ws.iter_rows():
for cell in row:
value = cell.value
coord = cell.coordin

View File

@@ -0,0 +1,11 @@
def reconstruct_formula(formula, wb_v, sheet_name):
"""수식 내 셀 주소를 실제 값으로 치환 및 기호 가독화"""
if not formula or not str(formula).startswith('='): return str(formula)
ref_pattern = r"(?:'([^']+)'|([a-zA-Z0-9가-힣]+))?!([A-Z]+\d+)|([A-Z]+\d+)"
def replace_with_value(match):
s_name = match.group(1) or match.group(2) or sheet_name
coord = match.group(3) or match.group(4)
try:
val = wb_v[s_name][coord].value
if val is None: return "0"

View File

@@ -0,0 +1,14 @@
def extract_excel_logic(file_path):
# 1. 수식을 가져오기 위한 로드 (data_only=False)
print(f"로그: 파일을 읽는 중입니다 (수식 모드)...")
wb_formula = openpyxl.load_workbook(file_path, data_only=False)
# 2. 결과값을 가져오기 위한 로드 (data_only=True)
print(f"로그: 파일을 읽는 중입니다 (데이터 모드)...")
wb_value = openpyxl.load_workbook(file_path, data_only=True)
extraction_data = []
for sheet_name in wb_formula.sheetnames:
ws_f = wb_formula[sheet_name]
ws_v = wb_value[sheet_name]

View File

@@ -0,0 +1,11 @@
def choose_design_template():
samples = sorted(os.listdir(DESIGN_DIR))
prompt = (
"아래 디자인 샘플 파일들 중 이 보고서에 어울리는 상위 3안(1안,2안,3안)을 "
"순서대로 파일명만으로 알려주세요:\n" + "\n".join(samples)
)
lines = call_gpt("디자인 전문가입니다.", prompt).splitlines()
return [ln.strip() for ln in lines if ln.strip() in samples][:3]
# ─── PPT 생성 ────────────────────────────────────

View File

@@ -0,0 +1,13 @@
def clean_text(text):
replacements = {
' ': ' ', '‘': "'", '’': "'", '“': '"', '”': '"',
'&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'",
'&quot;' : "'", '&middot;': "'"
}
for entity, replacement in replacements.items():
text = text.replace(entity, replacement)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'\(엔지니어링데일리\).*?기자=', '', text) # (엔지니어링데일리) *** 기자= 패턴 삭제
text = re.sub(r'\[국토일보\s.*?

View File

@@ -0,0 +1,9 @@
def clean_text(text: str) -> str:
"""HTML 엔티티 및 불필요한 태그 제거"""
reps = {
'&nbsp;': ' ', '&lsquo;': "'", '&rsquo;': "'", '&ldquo;': '"', '&rdquo;': '"',
'&amp;': '&', '&lt;': '<', '&gt;': '>', '&#39;': "'", '&quot;': "'", '&middot;': "'"
}
for key, val in reps.items():
text = text.replace(key, val)
return re.sub(r'<[^>]+>', '', text).strip()

View File

@@ -0,0 +1,11 @@
def fetch_article_content(article_url, source):
try:
response = requests.get(article_url, verify=False, timeout=10) # SSL 인증서 검증 비활성화 및 타임아웃 설정
response.encoding = 'utf-8' # 인코딩 설정
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
paragraphs = soup.find_all('p')
content = ' '.join([clean_text(p.get_text()) for p in paragraphs])
# 텍스트 내의 엔터키를 스페이스로 대체
content = content.replace('\n', ' ')

View File

@@ -0,0 +1,14 @@
def get_category_and_content(detail_url):
res = requests.get(detail_url)
soup = BeautifulSoup(res.text, 'html.parser')
# 카테고리
category_tags = soup.select('ul.flex.flex-row.flex-wrap.gap-2 li a')
categories = [tag['href'].split('/')[-2] for tag in category_tags]
# 내용
content_div = soup.select_one('div.content-base.workflow-description.text-md')
if content_div:
content_text = content_div.get_text(separator=' ', strip=True)
else:
content_text =

View File

@@ -0,0 +1,14 @@
def build_ppt(sections, images_map, templates):
prs = Presentation()
prs.slide_width, prs.slide_height = Inches(8.27), Inches(11.69) # A4
# 커버 슬라이드
slide = prs.slides.add_slide(prs.slide_layouts[6])
tb = slide.shapes.add_textbox(Inches(1), Inches(2), Inches(6.27), Inches(2))
p = tb.text_frame.paragraphs[0]
p.text = "🚀 자동 보고서"
p.font.size = Pt(26); p.font.bold = True
# 본문 슬라이드
for sec in sections:
slide = prs.slides.add_slide(prs.slide_layouts[6]

View File

@@ -0,0 +1,15 @@
def find_unit_from_sum_cell(ws, sum_row, max_col):
"""
합계 셀 기준 단위 탐색
- 오른쪽 열 우선, 위쪽 방향 탐색
- 대분류 경계 무시 (합계 기준으로만 판단)
"""
# 오른쪽 열부터 왼쪽으로
for c in range(max_col, 0, -1):
# 합계 행부터 위쪽으로
for r in range(sum_row, 0, -1):
cell_val = ws.cell(row=r, column=c).value
if is_likely_unit(cell_val):
return str(cell_val).strip()
return ""

View File

@@ -0,0 +1,7 @@
<h3>3-2. 가계: 고금리 피크아웃, 하지만 체감 회복은 느리다</h3>
<p>기준금리는 정점을 지나 완만히 낮아지는 방향이지만, 과거 초저금리 시대와 비교하면 여전히 높은 수준이다.</p>
<ul>
<li>물가는 2022~2023년 고점에 비해 안정되었지만, 체감 물가는 여전히 높다.</li>
<li>임금 상승률은 개선되고 있지만, 금리와 물가를 감안한 실질소득 개선 속도는 빠르지 않다.</li>
<li>부동산은 급등기와 급락기를 지나 재조정 국면에 있으나, 지역·유형별 격차가 크다.</li>
</ul>

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>경제 진단 보고서</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&display=swap');

View File

@@ -0,0 +1,7 @@
<h3>2-4. 구조적 요인: 인구, 생산성, 부동산, 가계부채</h3>
<p>한국 경제의 장기 과제를 요약하면 인구, 생산성, 자산, 부채 네 가지 키워드로 정리할 수 있다.</p>
<p>첫째, 인구. 한국의 합계출산율은 0.7명 안팎으로 세계 최저 수준이다.</p>
<p>둘째, 생산성. 제조업 상위 기업들은 세계 최고 수준의 경쟁력을 유지하고 있지만, 중소기업, 서비스업, 지방 경제의 생산성은 상대적으로 정체되어 있다.</p>
<p>셋째, 부동산. 주택 가격은 일부 조정을 거쳤지만, 장기적으로 여전히 소득에 비해 높은 수준이다.</p>
<p>넷째, 가계부채. GDP 대비 가계부채 비율은 여전히 주요국 상위권이다.</p>
</section>

View File

@@ -0,0 +1,5 @@
<div class="shortcut-hint" id="shortcutHint">
<div><kbd>Ctrl</kbd>+<kbd>B</kbd> 굵게 | <kbd>Ctrl</kbd>+<kbd>I</kbd> 기울임 | <kbd>Ctrl</kbd>+<kbd>U</kbd> 밑줄</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd> 자간↑ | <kbd>Ctrl</kbd>+<kbd>-</kbd> 자간↓ | <kbd>Ctrl</kbd>+<kbd>S</kbd> 저장</div>
<div style="margin-top: 5px; color: #00C853;">💡 본문 <kbd>Enter</kbd> → 새 문단 | <kbd>Backspace</kbd> → 병합</div>
</div>

View File

@@ -0,0 +1,6 @@
<div class="b-figure"></div>
<div class="b-caption">[그림] [캡션]</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,12 @@
/* 표, 그림은 전체폭이며 잘리지 않게 */
.sheet .body-content table,
.sheet .body-content figure{
width: 100%;
}
.sheet .body-content table th{
background: var(--b-light) !important;
color: var(--b-accent) !important;
}
`;
doc.head.appendChild(style);
}

View File

@@ -0,0 +1,8 @@
<section id="global-overview">
<h2>1. 글로벌 경제: 위기는 피했지만, 활력도 제한적인 “완만한 성장”</h2>
<h3>1-1. 성장률: 3%대 초반의 ‘애매한 회복’</h3>
<p>국제통화기금(IMF)은 2025년 세계 경제 성장률을 약 3.0~3.2% 수준으로 전망한다. 2024년 3.3%에서 2025년 3.2%, 2026년 3.1%로 완만하게 둔화되는 그림이다. 선진국은 1.5% 내외, 신흥국은 4%를 조금 웃도는 수준으로 양극화된 성장 구조가 이어질 것으로 평가된다.</p>
<p>큰 틀에서 보면 팬데믹 이후 “심각한 글로벌 침체” 시나리오는 피했지만, 세계은행이 지적하듯 향후 몇 년간의 성장률은 글로벌 금융위기 이후 평균보다도 낮은, “장기 저성장 트렌드”에 가까운 모습을 보이고 있다.</p>
<h3>1-2. 물가와 금리: 인플레이션은 진정, 그러나 금리는 과거보다 높은 수준에서 고착</h3>
<

View File

@@ -0,0 +1,6 @@
<div class="b-footer">
<span>[날짜]</span>
<span>[호수]</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
/* A, B의 대략 색감만 아주 약하게 */
.preview-a4.a .accent{ height:2px; background:#006400; opacity:0.55; margin:4px 0 6px; }
.preview-a4.b .accent{ height:2px; background:#e53935; opacity:0.55; margin:4px 0 6px; }
</style>
</head>

View File

@@ -0,0 +1,9 @@
/* 대제목(H1)은 전체폭 */
.sheet .body-content .b-top h1,
.sheet .body-content .b-col h1{
color: var(--b-primary) !important;
border-bottom: 2px solid var(--b-primary) !important;
margin: 0 0 10px 0;
font-size: 18pt;
font-weight: 900;
}

View File

@@ -0,0 +1,3 @@
<div class="b-lead">
[리드문] [리드문] [리드문]
</div>

View File

@@ -0,0 +1,5 @@
<h3>1-3. 리스크 요인: 무역갈등, 지정학, 고부채</h3>
<p>글로벌 전망에서 반복적으로 등장하는 키워드는 “정책 불확실성”이다. IMF는 2025년 4·10월 보고서에서, 관세 인상과 공급망 재편, 지정학적 긴장 고조가 향후 성장률을 추가로 깎아먹을 수 있는 하방 리스크라고 지적한다.</p>
<p>두 번째 리스크는 고부채다. 코로나 위기 대응 과정에서 확대한 정부지출과 이후의 고금리 환경이 결합되면서 많은 국가의 재정 상태가 빠르게 악화되었다.</p>
<p>마지막으로, 디지털 전환과 에너지 전환(탈탄소화)은 장기적으로는 성장 잠재력을 키우는 요인이지만, 단기적으로는 막대한 투자 비용과 산업 구조조정을 수반한다.</p>
</section>

View File

@@ -0,0 +1,11 @@
<script>
let isEditable = false;
let iframe = null;
let currentFileName = "report.html";
let currentReportTitle = "Report";
let activeBlock = null;
const historyStack = [];
const redoStack = [];
const MAX_HISTORY = 50;
let sourceHtml = ""; // 마지막으로 로드한 원본 HTML
let droppedAssets = new Map(); // 드롭된 부가 파일들(이미지 등) 이름 -> blob URL

View File

@@ -0,0 +1,3 @@
<div class="toast" id="toast">메시지</div>
<div class="loading" id="loading"><div class="spinner"></div><div>처리 중...</div></div>
<input type="file" id="fileInput" accept=".html,.htm" onchange="handleFile(event)">

View File

@@ -0,0 +1,10 @@
<!-- 목차 -->
<div class="preview-a4">
<div class="pad">
<div class="h1">[목차]</div>
<div class="toc-l1">1. [대목차]</div>
<div class="toc-l2">1.1 [중목차]</div>
<div class="toc-l3">1.1.1 [소목차]</div>
<div class="toc-l3">1.1.2 [소목차]</div>
<div class="toc-l1">2. [대목차]</div>
<div class="toc-

View File

@@ -0,0 +1,10 @@
<body>
<div class="app">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">📝 문서 편집기 <span class="logo-sub">Word Style v2</span></div>
<button class="btn" onclick="openFile()">📂 파일 열기</button>
<button class="btn" onclick="openPasteModal()" style="border-color:#555;">📋 코드 붙여넣기</button>
</div>
<div class="sidebar-content">
<div style="padding: 8px; font-size: 11px; co

View File

@@ -0,0 +1,3 @@
<h3>2-2. 물가와 금리: 2% 초반 물가, 완화 기조에서 다시 “신중 모드”로</h3>
<p>한국의 물가는 2022~2023년 고물가 국면을 지나 2024년 이후 뚜렷한 안정세를 보였고, 2025년에는 2% 안팎에서 움직일 것이라는 전망이 우세하다.</p>
<p>한동안 완화 기조로 전환하던 한국은행은 최근 원화 약세와 다시 높아지는 물가 압력을 이유로 기준금리를 2.50% 수준에서 동결하고, “추가 인하에 매우 신중한 입장”으로 한 걸음 물러섰다.</p>

View File

@@ -0,0 +1,6 @@
function cloneMoveChildrenToList(doc, parentEl) {
if (!parentEl) return [];
const arr = [];
Array.from(parentEl.children).forEach(ch => arr.push(ch));
return arr;
}

View File

@@ -0,0 +1,7 @@
<div class="format-bar" id="formatBar">
<select class="format-select" id="fontFamily" onchange="applyFontFamily(this.value); restoreSelection();">
<option value="Noto Sans KR">본고딕</option>
<option value="Malgun Gothic">맑은 고딕</option>
<option value="serif">명조체</option>
</select>
<input type="number" class="format-select" id="fontSizeInput" value="12" min="6" max="72" style="widt

View File

@@ -0,0 +1,5 @@
<!-- 본문 1장(다단 인지) -->
<div class="preview-a4 b">
<div class="pad">
<div class="b-section">[대제목]</div>
<div class="b-subhead">[소제목]</div>

View File

@@ -0,0 +1,11 @@
<!-- 본문 -->
<div class="preview-a4">
<div class="pad">
<div class="h1">1. [대제목]</div>
<div class="h2">1.1 [중제목]</div>
<div class="p">[본문]</div>
<div class="p muted">[본문]</div>
<div class="h2">1.2 [중제목]</div>
<div class="p">[본문]</div>
<div class="p muted">[본문]</div>

View File

@@ -0,0 +1,13 @@
/* B 본문 레이아웃 */
.sheet .body-content .b-top{
margin: 0 0 10px 0;
}
.sheet .body-content .b-columns{
display: flex;
gap: 18px;
align-items: flex-start;
}
.sheet .body-content .b-col{
flex: 1;
min-width: 0;
}

View File

@@ -0,0 +1,8 @@
<div class="b-cols">
<div class="b-col">
<p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p>
</div>
<div class="b-col">
<p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p><p>[본문]</p>
</div>
</div>

View File

@@ -0,0 +1,12 @@
/* 중, 소제목은 단 내부 */
.sheet .body-content .b-col h2{
color: var(--b-accent) !important;
border-left: 5px solid var(--b-primary) !important;
margin: 10px 0 6px 0;
font-size: 12pt;
font-weight: 900;
}
.sheet .body-content .b-col h3{
color: var(--b-primary) !important;
margin: 8px 0 4px 0;

View File

@@ -0,0 +1,3 @@
<h3>2-3. 수출과 산업: 반도체와 자동차가 버티는 가운데, 내수는 여전히 약한 고리</h3>
<p>2025년 한국 수출은 반도체와 자동차가 이끌고 있다. 특히 메모리 반도체 가격과 수요가 회복되면서, 11월 기준으로 반도체 수출은 전년 동기 대비 20%를 넘는 증가율을 보이고 있고, 자동차 역시 미국·유럽·신흥국에서 견조한 수요를 유지하고 있다.</p>
<p>내수 측면에서는 고금리 여파와 실질소득 둔화, 부동산 시장 조정으로 소비 심리가 완전히 회복되지 못했다. 한국은행 소비자동향지수(CCSI)는 팬데믹 직후보다는 높지만, 과거 확장 국면에 비하면 여전히 조심스러운 수준이다.</p>

View File

@@ -0,0 +1,6 @@
<div class="panel-body">
<div class="panel-section-title">양식 목록</div>
<div class="template-list" id="templateList"></div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,9 @@
<div class="viewer">
<div id="viewerInner" style="height:100%; width:100%; display:flex; justify-content:center; transform-origin: top center;"></div>
</div>
</main>
<aside class="right-panel" id="templatePanel">
<div class="panel-header">
<div class="panel-title">양식 선택</div>
<button class="panel-add-btn" type="button" onclick="openTemplateAdd()">양식 추가</button>
</div>

View File

@@ -0,0 +1,3 @@
function openTemplateAdd() {
toast("양식 추가는 다음 단계에서 연결합니다");
}

View File

@@ -0,0 +1,12 @@
// Type A에서 검증된 제목+본문 그룹핑
function groupHeadingWithParagraph(nodes, index) {
const node = nodes[index];
if(!node) return { group: [], consumed: 0 };
const next = nodes[index + 1];
if(!node.tagName) return { group: [node], consumed: 1 };
const tag = node.tagName.toUpperCase();
if((tag === 'H2' || tag === 'H3') && next && next.tagName && next.tagName.toUpperCase() === 'P') {
return { group: [node

View File

@@ -0,0 +1,9 @@
/* 요약 박스 스타일 */
header p:last-of-type {
background-color: #f8f9fa;
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
font-weight: 500;
color: #555;
}

View File

@@ -0,0 +1,9 @@
<!-- 요약 -->
<div class="preview-a4">
<div class="pad">
<div class="h1">[요약]</div>
<div class="p">[요약 내용 1]</div>
<div class="p">[요약 내용 2]</div>
<div class="p">[요약 내용 3]</div>
</div>
</div>

View File

@@ -0,0 +1,9 @@
</head>
<body>
<div class="a4-wrapper">
<header>
<h1>요즘 경제, 어디까지 왔나: 2025년 말 글로벌·한국 경제 진단</h1>
<p>작성일: 2025년 11월 27일</p>
<p>요약: 고물가 쇼크와 급격한 금리 인상 국면이 지나가고 있지만, 세계 경제는 저성장·고부채·지정학 리스크라는 세 가지 부담을 안고 완만한 성장세에 머물고 있다. 한국 경제 역시 1%대 저성장과 수출 의존 구조, 인구·부동산·가계부채 문제라는 고질적 구조적 과제를 동시에 마주하고 있다.</p>
</header>

View File

@@ -0,0 +1,6 @@
/* --- 인쇄 모드 (PDF 변환 시 적용) 핵심 코드 --- */
@media print {
@page {
size: A4;
margin: 0; /* 브라우저 여백 제거하고 body padding으로 제어 */
}

View File

@@ -0,0 +1,9 @@
<main class="main">
<div class="toolbar">
<div class="status-badge" id="editStatusBadge">
<div class="dot"></div> <span id="editStatusText">읽기 전용</span>
</div>
<div class="toolbar-divider"></div>
<button class="toolbar-btn" id="btnEdit" onclick="toggleEditMode()">✏️ 편집</button>
<button class="toolbar-btn" title="실행 취소" onclick="performUndo()">↩️</button>
<button

View File

@@ -0,0 +1,3 @@
<footer>
<p>자료 출처: IMF World Economic Outlook(2025년 4·10월판), World Bank Global Economic Prospects(2025년 6월), OECD Economic Outlook(2025년 6월 및 9월 업데이트), 한국은행, KDI, 국내 주요 언론 경제면 정리.</p>
</footer>

View File

@@ -0,0 +1,9 @@
:root {
--font-main: 'Noto Sans KR', sans-serif;
--page-width: 210mm;
--page-height: 297mm;
--margin-top: 25mm;
--margin-bottom: 25mm;
--margin-side: 25mm;
--primary-color: #003366; /* 전문적인 네이비 컬러 */
}

View File

@@ -0,0 +1,4 @@
<h3>3-4. 정리: “위기는 완화, 과제는 심화”된 국면</h3>
<p>요약하면, 2025년 말 세계와 한국 경제는 “급한 불은 껐지만, 구조적 과제는 더욱 분명해진 상태”라고 정리할 수 있다.</p>
<p>지금의 경제 상황은 “모든 것이 나쁘다”기보다는, “잘하는 영역과 못하는 영역의 차이가 점점 더 크게 벌어지는 시대”에 가깝다.</p>
</section>

View File

@@ -0,0 +1,3 @@
<div class="b-title">
[제목] <span class="red">[강조]</span> [제목]
</div>

View File

@@ -0,0 +1,10 @@
h2 {
font-size: 16pt;
color: #0056b3;
margin-top: 25px;
margin-bottom: 15px;
border-left: 5px solid #0056b3;
padding-left: 10px;
break-after: avoid; /* 제목 뒤에서 페이지 넘김 방지 */
break-inside: avoid;
}

View File

@@ -0,0 +1,4 @@
h2, h3 {
break-after: avoid; /* 제목만 덩그러니 남는 것 방지 */
page-break-after: avoid;
}

View File

@@ -0,0 +1,13 @@
/* 줄바꿈 시 다음줄 들여쓰기(넘버링 정렬) */
.sheet .body-content .b-top h1,
.sheet .body-content .b-col h1{
padding-left: 24px;
text-indent: -24px;
}
.sheet .body-content .b-col h2{
padding-left: 20px;
text-indent: -20px;
}
.sheet .body-content .b-col h3{
padding-left: 18px;
text-in

View File

@@ -0,0 +1,9 @@
<section id="implications">
<h2>3. 지금 경제 상황이 의미하는 것: 기업과 가계, 투자자의 관점</h2>
<h3>3-1. 기업: “고성장 시대”가 아니라 “정교한 선택의 시대”</h3>
<p>세계와 한국 모두 고성장 국면은 아니다. 대신, 성장의 편차와 업종 간 차별화가 중요한 시대다.</p>
<ul>
<li>디지털 전환과 자동화를 통해 인력·자본 효율성을 높이고, 비용 구조를 경직적에서 유연한 형태로 바꾸는 것</li>
<li>해외 시장과 공급망 다변화를 통해 특정 국가·산업 의존도를 줄이는 것</li>
<li>R&D와 데이터 활용을 통해 제품·서비스 차별화, 고부가가치화를 추구하는 것</li>
</ul>

View File

@@ -0,0 +1,9 @@
/* --- 타이포그래피 설정 --- */
h1 {
font-size: 24pt;
color: var(--primary-color);
border-bottom: 3px solid var(--primary-color);
padding-bottom: 10px;
margin-bottom: 30px;
break-after: avoid; /* 제목 뒤에서 페이지 넘김 방지 */
}

View File

@@ -0,0 +1,7 @@
<h3>3-3. 투자자: “불확실성 관리”가 기본값이 된 시장</h3>
<p>주식, 채권, 부동산, 대체투자 모두에서 공통적으로 나타나는 특징은 “중간 정도의 성장과 반복되는 정책 변수”다.</p>
<ul>
<li>국가·통화·자산군 분산을 통한 리스크 헤지</li>
<li>특정 테마(예: AI, 친환경, 헬스케어)에 대한 집중 투자 여부를, 수익·현금흐름·규제 환경을 종합적으로 보며 판단하는 것</li>
<li>단기 금리·환율 이벤트에 휘둘리기보다는, 3~5년 이상의 중기적 시계에서 정책과 구조 변화의 방향성을 읽는 것</li>
</ul>

View File

@@ -0,0 +1,9 @@
<div class="drop-overlay" id="dropOverlay"><div class="drop-box">📄 파일을 놓으세요</div></div>
<div class="modal-overlay" id="pasteModal">
<div class="modal-box">
<div class="modal-header">📋 HTML 코드 붙여넣기</div>
<textarea class="modal-textarea" id="pasteInput" placeholder="HTML 코드를 여기에 붙여넣으세요..."></textarea>
<div class="modal-footer">
<button class="btn" style="width:auto;" onclick="closePasteModal()">취소</button>
<bu

View File

@@ -0,0 +1,5 @@
function applyTemplate(templateId) {
if (!iframe) {
toast("파일을 먼저 열어주세요");
return;
}

View File

@@ -0,0 +1,9 @@
p, ul, li {
orphans: 2; /* 페이지 끝에 한 줄만 남는 것 방지 */
widows: 2; /* 다음 페이지에 한 줄만 넘어가는 것 방지 */
}
/* 문단이 너무 작게 잘리는 것을 방지 */
p {
break-inside: auto;
}

View File

@@ -0,0 +1,4 @@
/* 페이지 분할 알고리즘 */
section {
break-inside: auto; /* 섹션 내부에서 페이지 나눔 허용 */
}

View File

@@ -0,0 +1,12 @@
function buildPreviewA() {
return `
<!-- 표지 -->
<div class="preview-a4">
<div class="pad">
<div class="cover-top">[날짜]<br>[작성자]</div>
<div class="cover-center">
<div class="cover-title">[제목]</div>
<div class="cover-sub">[부제]</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
const TEMPLATE_REGISTRY = [
{
id: "A",
name: "A type",
desc: "표지 목차 요약 본문 구조",
preview: () => buildPreviewA(),
apply: (doc) => applyTemplateA(doc),
},
{
id: "B",
name: "B type",
desc: "다단 본문 중심 구조",
preview: () => buildPreviewB(),
apply: (doc) => applyTemplateB(doc),
},
];

View File

@@ -0,0 +1,7 @@
function buildPreviewB() {
return `
<!-- 표지 1장 -->
<div class="preview-a4 b">
<div class="pad">
<div class="b-tag">[특집]</div>
<div class="b-pill">[브랜드]</div>

View File

@@ -0,0 +1,6 @@
/* 표지나 특정 섹션 강제 넘김이 필요하면 사용 */
.page-break {
break-before: page;
}
}
</style>

View File

@@ -0,0 +1,8 @@
/* 푸터 (출처) 스타일 */
footer {
margin-top: 30px;
border-top: 1px solid #eee;
padding-top: 10px;
font-size: 9pt;
color: #888;
}

View File

@@ -0,0 +1,5 @@
<section id="korea-economy">
<h2>2. 한국 경제: 1%대 성장과 수출 회복 사이에서 균형 찾기</h2>
<h3>2-1. 성장률: 2025년 1% 안팎, 2026년 2%대 회복 전망</h3>
<p>OECD는 한국의 2025년 실질 GDP 성장률을 약 1.0% 수준으로, 2026년에는 2.2%까지 완만하게 회복될 것으로 전망한다. 세계 평균 성장률(3%대 초반)과 비교하면 상당히 낮은 수준으로, 저성장 구조가 점점 고착화되고 있다는 평가가 많다.</p>
<p>한국은행과 국내 연구기관(KDI 등) 역시 비슷한 그림을 제시한다. 대외 환경이 크게 악화되지는 않았지만, 수출과 설비투자는 제한적 회복에 그치고, 소비와 건설투자가 경제를 강하게 끌어올릴 만큼의 모멘텀을 보여주지 못한다는 판단이다.</p>

View File

@@ -0,0 +1,10 @@
body {
font-family: var(--font-main);
background-color: #f5f5f5; /* 화면 확인용 회색 배경 */
margin: 0;
padding: 20px;
color: #333;
line-height: 1.6;
word-break: keep-all; /* 단어 단위 줄바꿈 (가독성 핵심) */
text-align: justify;
}

View File

@@ -0,0 +1,11 @@
/* 화면에서 볼 때 A4처럼 보이게 하는 컨테이너 */
.a4-wrapper {
width: var(--page-width);
min-height: var(--page-height);
background: white;
margin: 0 auto;
padding: var(--margin-top) var(--margin-side) var(--margin-bottom) var(--margin-side);
box-shadow: 0 0 15px rgba(0,0,0,0.1);
box-sizing: border-box;
position: relative;
}

View File

@@ -0,0 +1 @@
}

View File

@@ -0,0 +1,5 @@
response = client.chat.completions.create(
gpt-4.1-2025-04-14,
messages=[{role: user, content: f다음을 한국어로 번역해줘:\n{text}}]
)
return response.choices[0].message.content.strip()

View File

@@ -0,0 +1,9 @@
resp = openai.ChatCompletion.create(
model=model,
messages=[
{role:system, content: system_prompt},
{role:user, content: user_prompt}
],
temperature=0.7
)
return resp.choices[0].message.content.strip()

View File

@@ -0,0 +1,15 @@
response = client.chat.completions.create(
gpt-4.1-2025-04-14,
messages=[{role: user, content: f아래 내용을 50자 내외로 요약해줘:\n{text}}]
)
return response.choices[0].message.content.strip()
for category in categories:
driver.get(base_url.format(category))
time.sleep(2)
# Load
while True:
try:
load_more = driver.find_element(By.XPATH, //button[contains(text(),'Load more templates')])

View File

@@ -0,0 +1,7 @@
# 글벗 API Keys
# 이 파일을 .env로 복사한 뒤 실제 키값을 입력하세요
# cp .env.sample .env
CLAUDE_API_KEY=여기에_키값_입력
GEMINI_API_KEY=여기에_키값_입력
GPT_API_KEY=여기에_키값_입력

11
03. Code/geulbeot_10th/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# API Keys - Gitea에 올리지 않기!
.env
api_keys.json
# Python
__pycache__/
*.pyc
venv/
# Output
output/

View File

@@ -0,0 +1,909 @@
너는 다음 분야의 전문가이다: 토목 일반.
다음의 도메인 지식을 기반으로, 사실에 근거하여 전문적이고 정확한 내용을 작성하라.
추측이나 창작은 금지하며, 제공된 근거 자료의 원문을 최대한 보존하라.
[도메인 전문 지식]
[토목 일반]
도레미파솔라시도
============================================================
[보고서 작성 가이드]
다음 가이드를 참고하여 보고서의 목차 구성과 문체를 결정하라.
이 문서 묶음은 건설/토목 분야의 측량과 디지털 전환(DX)에 초점을 둔 자료로, 드론(UAV) 사진측량, GIS, 지형·지반(terrain/geotech) 정보 모델의 구축·활용을 다룬다.
시공 단계별 측량 절차와 성과품, 기준점 체계, 드론 운용 학습자료, 내부 솔루션(GAIA, GIS Solutions, Terrain Information Model, Map v2.0, GSIM 등)과 워크플로가 포함된다.
핵심 키워드: 측량, 드론/UAV, 사진측량, GIS, 디지털 트윈, 지형지반 모델, GAIA, GSIM, 수치지도 2.0, 배수·유역 분석, 성과품·절차.
너는 건설/토목 측량·드론·GIS·지형지반 모델링 분야의 기술 교육콘텐츠 기획자이자, 자사 솔루션 홍보를 위한 기술 전문가이다.
나는 이 문서 묶음을 근거로 측량에 대한 기본 개념부터 동향, 건설사업의 디지털 전환(DX)을 위한 측량 변화와 절차, 드론 운용·사진측량 워크플로, GIS/지형지반 모델의 구축·활용과 함께 사례를 정확히 이해하고 적용하고 싶다.
우리는 이 자료를 기반으로 CEL(기술발신력 강화) 콘텐츠를 제작하고자 한다.
목표는 다음과 같다:
1) 분야에 대한 개념과 관련 기초 개념, 용어에 대한 정의를 명확하게 설명한다.
2) 기존 방식에 대한 한계와 분야별 디지털 전환(DX)에 대한 변화, 요구사항 등에 대하여 명확히 설명한다.
3) 기존 시장 솔루션들의 방향성과 함께 한계를 최대한 객관적으로 제시한다
4) 이러한 문제를 해결하는 방향과 솔루션을 제시하고, 이를 만족하는 자사 솔루션(GAIA, GSIM, Map v2.0 등)의 기술적 강점과 차별점을 자연스럽게 부각한다
5) 적용된 사례를 이미지와 함게 이해하기 쉽게 정리한다
과업수행 절차와 성과품 기준, 데이터 품질·정합성, 시스템 구성(예: GAIA, GSIM, Map v2.0 등)과 관련된 문제를 분석하여 정리한다.
너의 답변은 문서 기반 사실에 한정하고 추측을 금지하며, 가능한 경우 문서명/페이지/그림 참조 등 근거를 명시하라.
자료의 공백이나 모호함이 있으면 필요한 가정을 분리해 표시하거나 추가 질문으로 명확히 하라; 외부 일반지식은 참고로만 제시하고 출처 구분을 유지하라.
콘텐츠는 내부 직원 교육 및 외부 고객/파트너 대상 기술 세미나에 활용될 예정이므로, 전문성과 신뢰성을 유지하되 이해하기 쉬운 스토리 흐름으로 구성하라.
이후 청킹, 요약, 용어정의, RAG 검색·인용, 비교표 작성, 분석·보고서 작성, 체크리스트·절차서 도출 등 다양한 작업을 네가 주도적으로 구조화해 지원하라.
==================================================
건설·토목 측량 DX 실무지침: 드론/UAV·GIS·지형/지반 모델 기반 전주기 표준과 품질관리
1. DX 개요와 기본 개념·기준점 체계
1.1 측량 DX 프레임과 기초 용어
1.1.1 측량 DX 발전 단계
- Digitization→Digitalization→DX 정의·사례 | #DX진화 #정책기조 | [인사이트형] | 03 p.6267 근거 문구 수집, 단계-산출물 매트릭스 표 작성
- UAV/3D Mesh/DSM/LiDAR 전환 | #UAV #3D모델 | [기술형] | 03 p.6268에서 제품유형·데이터모델 비교표와 예시 이미지
1.1.2 핵심 용어·원리 정리
- GNSS(RTK/VRS/Static)·TS·LiDAR | #측량센싱 | [기술형] | 03 p.6465,68 용어정의·정확도·용도 표 구성
- GSD/DSM/DEM/DTM/TIN·맵핑 vs 모델 | #데이터모델 | [비교형] | 03 p.68 정의/산출물/활용 비교표와 주석
1.1.3 수치지형도 2.0 vs 정밀도로지도(HD Map)
- 형식·정확도·객체 차이 | #수치지도2.0 #HDMap | [비교형] | 수치지도2.0(SHP 구성) vs HD Map(±0.25m) 비교표(파일·속성·정확도)
- SOC 활용 한계·보완 | #활용성 #한계 | [인사이트형] | 정밀도로지도 외측 결손·역설계 필요 사례 정리(매뉴얼 2023.07)
1.2 기준점 체계와 국가 수직망 정정
1.2.1 기준점 현황·재구축 필요성
- 설계기준점 상태 통계 | #기준점점검 | [인사이트형] | 1·2·4공구 정상/망실 수량표·지도 핀맵 작성
- 수직망 정정(Z 39~63mm) 영향 | #수직망정정 | [기술형] | 고시 2023-3084 변화량 표·적용 체크리스트(01/05/08 인용)
1.2.2 행정·규정·품질 기준
- 공공측량 준용규정·검사기준 | #준용규정 | [절차형] | 서산–명천 문서 내 준용규정 항목 추출, 준수 체크리스트 표
- 성과품 품질·미수령 항목 | #품질관리 | [인사이트형] | 01/05/08 미수령 목록 대조표(원본 Pile·정사영상·망조정 등)
==================================================
🏛️ A4 보고서 퍼블리싱 마스터 가이드 (v82.0 Intelligent Flow)
당신은 **'지능형 퍼블리싱 아키텍트'**입니다. 원본의 **[스타일 독소]**를 제거하고, A4 규격에 맞춰 콘텐츠를 재조립하되, 단순 나열이 아닌 **[최적화된 배치]**를 수행하십시오.
텍스트는 **[복사기]**처럼 있는 그대로 보존하고, 레이아웃은 **[강박증]** 수준으로 맞추십시오.
🚨 0. 최우선 절대 원칙 (Data Integrity)
복사기 모드: 원본 텍스트를 절대 요약, 생략(...), 수정하지 마십시오. 무조건 전부 출력하십시오.
데이터 무결성: 표의 수치, 본문의 문장은 토씨 하나 바꾸지 않고 보존합니다.
🚨 1. 핵심 렌더링 원칙 (The 6 Commandments)
Deep Sanitization (심층 세탁): 모든 class, style을 삭제하되, 차트/그림 내부의 제목 텍스트는 캡션과 중복되므로 제거하십시오.
H1 Only Break: 오직 대목차(H1) 태그에서만 무조건 페이지를 나눕니다.
Orphan Control (고아 방지): 중목차(H2), 소목차(H3)가 페이지 하단에 홀로 남을 경우, 통째로 다음 페이지로 넘기십시오.
Smart Fit (지능형 맞춤): 표나 그림이 페이지를 넘어가는데 그 양이 적다면(15% 이내), 최대 85%까지 축소하여 현재 페이지에 넣으십시오.
Gap Filling (공백 채우기): 그림이 다음 장으로 넘어가 현재 페이지 하단에 큰 공백이 생긴다면, 뒤에 있는 텍스트 문단을 당겨와 그 빈공간을 채우십시오.
Visual Standard:
여백: 상하좌우 20mm를 시각적으로 고정하십시오.
캡션: 모든 그림/표의 제목은 하단 중앙 정렬하십시오.
🛠️ 제작 가이드 (Technical Specs)
아래 코드는 렌더링 엔진입니다. 이 구조를 기반으로 사용자 데이터를 raw-container에 주입하여 출력하십시오.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>A4 Report v83.0 Template</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary: #006400;
--accent: #228B22;
--light-green: #E8F5E9;
--bg: #525659;
}
body { margin: 0; background: var(--bg); font-family: 'Noto Sans KR', sans-serif; }
/* [A4 용지 규격] */
.sheet {
width: 210mm; height: 297mm;
background: white; margin: 20px auto;
position: relative; overflow: hidden; box-sizing: border-box;
box-shadow: 0 0 15px rgba(0,0,0,0.1);
}
@media print {
.sheet { margin: 0; break-after: page; box-shadow: none; }
body { background: white; }
}
/* [헤더/푸터: 여백 20mm 영역 내 배치] */
.page-header {
position: absolute; top: 10mm; left: 20mm; right: 20mm;
font-size: 9pt; color: #000000; font-weight: bold;
text-align: right; border-bottom: none !important; padding-bottom: 5px;
}
.page-footer {
position: absolute; bottom: 10mm; left: 20mm; right: 20mm;
display: flex; justify-content: space-between; align-items: flex-end;
font-size: 9pt; color: #555; border-top: 1px solid #eee; padding-top: 5px;
}
/* [본문 영역: 상하좌우 20mm 고정] */
.body-content {
position: absolute;
top: 20mm; left: 20mm; right: 20mm;
bottom: auto; /* 높이는 JS가 제어 */
}
/* [타이포그래피] */
h1, h2, h3 {
white-space: nowrap; overflow: hidden; word-break: keep-all; color: var(--primary);
margin: 0; padding: 0;
}
h1 {
font-size: 20pt; /* H2와 동일하게 변경 (기존 24pt -> 18pt) */
font-weight: 900;
color: var(--primary);
border-bottom: 2px solid var(--primary);
margin-bottom: 20px;
margin-top: 0;
}
h2 {
font-size: 18pt;
border-left: 5px solid var(--accent);
padding-left: 10px;
margin-top: 30px;
margin-bottom: 10px;
color: #03581dff;
}
h3 { font-size: 14pt; margin-top: 20px; margin-bottom: 5px; color: var(--accent); font-weight: 700; }
p, li { font-size: 12pt !important; line-height: 1.6 !important; text-align: justify; word-break: keep-all; margin-bottom: 5px; }
/* [목차 스타일 수정: lvl-1 강조 및 간격 추가] */
.toc-item { line-height: 1.8; list-style: none; border-bottom: 1px dotted #eee; }
.toc-lvl-1 {
color: #006400; /* 녹색 */
font-weight: 900; /* 볼드 */
font-size: 13.5pt; /* lvl-2(10.5pt)보다 3pt 크게 */
margin-top: 15px; /* 위쪽 간격 */
margin-bottom: 5px; /* 아래쪽 3pt 정도의 간격 */
border-bottom: 2px solid #ccc;
}
.toc-lvl-2 { font-size: 10.5pt; color: #333; margin-left: 20px; font-weight: normal; }
.toc-lvl-3 { font-size: 10.5pt; color: #666; margin-left: 40px; }
/* [표/이미지 스타일] */
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
font-size: 9.5pt;
table-layout: auto;
border-top: 2px solid var(--primary);
}
th, td {
border: 1px solid #ddd;
padding: 6px 5px;
text-align: center;
vertical-align: middle;
/* ▼▼▼ [핵심 수정] 단어 단위 줄바꿈 적용 ▼▼▼ */
word-break: keep-all; /* 한글 단어 중간 끊김 방지 (필수) */
word-wrap: break-word; /* 아주 긴 영단어는 줄바꿈 허용 (안전장치) */
}
th {
background: var(--light-green);
color: var(--primary);
font-weight: 900;
white-space: nowrap; /* 제목 셀은 무조건 한 줄 유지 */
letter-spacing: -0.05em;
font-size: 9pt;
}
/* [캡션 및 그림 스타일] */
figure { display: block; margin: 20px auto; text-align: center; width: 100%; }
img, svg { max-width: 95% !important; height: auto !important; display: block; margin: 0 auto; border: 1px solid #eee; }
figcaption {
display: block; text-align: center; margin-top: 10px;
font-size: 9.5pt; color: #666; font-weight: 600;
}
.atomic-block { break-inside: avoid; page-break-inside: avoid; }
#raw-container { display: none; }
/* [하이라이트 박스 표준] */
.highlight-box {
background-color: rgb(226, 236, 226);
border: 1px solid #2a2c2aff;
padding: 5px; margin: 1.5px 1.5px 2px 0px; border-radius: 3px;
/* 여기 있는 font-size는 li 태그 때문에 무시됩니다. 아래 코드로 제어하세요. */
color: #333;
}
.highlight-box li,
.highlight-box p {
font-size: 11pt !important; /* 글자 크기 (원하는 대로 수정: 예 9pt, 10pt) */
line-height: 1.2; /* 줄 간격 (숫자가 클수록 넓어짐: 예 1.4, 1.6) */
letter-spacing: -0.6px; /* 자간 (음수면 좁아짐: 예 -0.5px) */
margin-bottom: 3px; /* 항목 간 간격 */
color: #1a1919ff; /* 글자 색상 */
}
.highlight-box h3, .highlight-box strong, .highlight-box b {
font-size: 12pt !important; color: rgba(2, 37, 2, 1) !important;
font-weight: bold; margin: 0; display: block; margin-bottom: 5px;
}
/* 수정 4 목차 스타일 : 대제목 녹색+크게, 그룹 단위 묶음 */
.toc-group {
margin-bottom: 12px; /* 기존 간격 유지 */
break-inside: avoid;
page-break-inside: avoid;
}
/* [수정] 점(Bullet) 제거를 위한 핵심 코드 */
.toc-lvl-1, .toc-lvl-2, .toc-lvl-3 {
list-style: none !important;
}
.toc-item {
line-height: 1.8;
list-style: none; /* 안전장치 */
border-bottom: 1px dotted #f3e1e1ff; /* 기존 점선 스타일 유지 */
}
.toc-lvl-1 {
color: #006400; /* 기존 녹색 유지 */
font-weight: 900;
font-size: 13.5pt; /* 기존 폰트 크기 유지 */
margin-top: 15px; /* 기존 상단 여백 유지 */
margin-bottom: 5px; /* 기존 하단 여백 유지 */
border-bottom: 2px solid #ccc;
}
.toc-lvl-2 {
font-size: 10.5pt;
color: #333;
margin-left: 20px; /* 기존 들여쓰기 유지 */
font-weight: normal;
}
.toc-lvl-3 {
font-size: 10.5pt;
color: #666;
margin-left: 40px; /* 기존 들여쓰기 유지 */
}
/* [대목차 내부 스타일 보존] */
.toc-lvl-1 .toc-number,
.toc-lvl-1 .toc-text {
font-weight: 900;
font-size: 1.2em;
color: #006400;
}
.toc-lvl-1 .toc-number {
float: left;
margin-right: 14px; /* 기존 간격 유지 */
}
.toc-lvl-1 .toc-text {
display: block;
overflow: hidden;
}
/* [소목차 내부 스타일 보존] */
.toc-lvl-2 .toc-number, .toc-lvl-3 .toc-number {
font-weight: bold;
color: #2c5282;
margin-right: 11px; /* 기존 간격 유지 */
}
.toc-lvl-2 .toc-text, .toc-lvl-3 .toc-text {
color: #4a5568;
font-size: 1em;
}
/* [요약 페이지 전용 스타일 미세 조정] */
.squeeze {
line-height: 1.35 !important;
letter-spacing: -0.5px !important;
margin-bottom: 2px !important;
}
.squeeze-title {
margin-bottom: 5px !important;
padding-bottom: 2px !important;
}
/* 요약 페이지 안의 모든 P 태그에 대해 자간/행간을 좁힘 */
#box-summary p,
#box-summary li {
font-size: 10pt !important; /* 본문보다 0.5pt~1pt 정도 작게 */
line-height: 1.45 !important; /* 줄 간격을 조금 더 촘촘하게 (기존 1.6) */
letter-spacing: -0.04em !important; /* 자간을 미세하게 좁힘 */
margin-bottom: 3px !important; /* 문단 간 격을 줄임 */
text-align: justify; /* 양쪽 정렬 유지 */
}
/* 요약 페이지 제목 아래 간격도 조금 줄임 */
#box-summary h1 {
margin-bottom: 10px !important;
padding-bottom: 5px !important;
}
</style>
</head>
<body>
<div id="raw-container">
<div id="box-cover"></div>
<div id="box-toc"></div>
<div id="box-summary"></div>
<div id="box-content"></div>
</div>
<template id="page-template">
<div class="sheet">
<div class="page-header"></div>
<div class="body-content"></div>
<div class="page-footer">
<span class="rpt-title"></span>
<span class="pg-num"></span>
</div>
</div>
</template>
<script>
window.addEventListener("load", async () => {
await document.fonts.ready; // 웹폰트 로딩 대기 (필수)
// [Config] 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px
const CONFIG = { maxHeight: 970 };
const rawContainer = document.getElementById('raw-container');
if (rawContainer) {
rawContainer.innerHTML = rawContainer.innerHTML.replace(
/(<rect[^>]*?)\s+y="[^"]*"\s+([^>]*?y="[^"]*")/gi,
"$1 $2"
);
}
const raw = {
cover: document.getElementById('box-cover'),
toc: document.getElementById('box-toc'),
cover: document.getElementById('box-cover'),
toc: document.getElementById('box-toc'),
summary: document.getElementById('box-summary'),
content: document.getElementById('box-content')
};
let globalPage = 1;
let reportTitle = raw.cover.querySelector('h1')?.innerText || "Report";
function cleanH1Text(text) {
if (!text) return "";
const parts = text.split("-");
return parts[0].trim(); // 첫 번째 부분만 남기고 나머지는 버림
}
// [0] Sanitizer & Pre-processing (Integrity Preserved Version)
function detox(node) {
if (node.nodeType !== 1) return;
// [Safety Check 1] SVG 내부는 절대 건드리지 않음 (차트 깨짐 방지)
if (node.closest('svg')) return;
// [Logic A] 클래스 속성 확인 및 변수 할당
let cls = "";
if (node.hasAttribute('class')) {
cls = node.getAttribute('class');
}
// [Logic B] 하이라이트 박스 감지 및 변환 (조건 정밀화)
// 조건: 1. bg-, border-, box 중 하나라도 포함되어야 함
// 2. 단, title-box(제목박스), toc-(목차), cover-(표지)는 절대 아니어야 함
if ( (cls.includes('bg-') || cls.includes('border-') || cls.includes('box')) &&
!cls.includes('title-box') &&
!cls.includes('toc-') &&
!cls.includes('cover-') &&
!cls.includes('highlight-box') ) { // 이미 변환된 놈도 건드리지 않음
// 1. 표준 클래스로 강제 교체
node.setAttribute('class', 'highlight-box atomic-block');
// 2. 박스 내부 제목 스타일 초기화 (기존 스타일과의 충돌 방지)
const internalHeads = node.querySelectorAll('h3, h4, strong, b');
internalHeads.forEach(head => {
head.removeAttribute('style');
head.removeAttribute('class');
});
// 3. 인라인 스타일 삭제 (Tailwind inline style 등 제거)
node.removeAttribute('style');
// [중요] 여기서 return하면 안됨! 아래 공통 로직(표 테두리 등)도 타야 함.
// 대신, class는 이미 세팅했으므로 class 삭제 로직만 건너뛰게 플래그 변경
cls = 'highlight-box atomic-block';
}
// [Logic C] 일반 요소 세탁 (화이트리스트 유지)
// 목차, 표지, 제목박스, 그리고 방금 변환된 하이라이트 박스는 살려둠
if (node.hasAttribute('class')) {
// 위에서 cls 변수가 갱신되었을 수 있으므로 다시 확인하지 않고 기존 조건 활용
if (!cls.includes('toc-') &&
!cls.includes('cover-') &&
!cls.includes('highlight-') &&
!cls.includes('title-box') &&
!cls.includes('atomic-block')) {
node.removeAttribute('class');
}
}
// [Logic D] 공통 정리 (인라인 스타일 삭제)
// 단, 이미 변환된 박스는 위에서 지웠으니 중복 실행되어도 상관없음
node.removeAttribute('style');
// [Logic E] 표 테두리 강제 적용
if (node.tagName === 'TABLE') node.border = "1";
// [Logic F] 캡션 중복 텍스트 숨김 처리
if (node.tagName === 'FIGURE') {
const internalTitles = node.querySelectorAll('h3, h4, .chart-title');
internalTitles.forEach(t => t.style.display = 'none');
}
}
function getFlatNodes(element) {
// [1] 목차(TOC) 처리 로직 (제목 생성 + 완벽한 그룹화)
if(element.id === 'box-toc') {
// 1. 스타일 초기화
element.querySelectorAll('*').forEach(el => detox(el));
// 2. 레벨 분석 (위의 formatTOC 실행)
formatTOC(element);
const tocNodes = [];
// [수정] 원본에 H1이 없으면 '목차' 타이틀 강제 생성
let title = element.querySelector('h1');
if (!title) {
title = document.createElement('h1');
title.innerText = "목차";
// 디자인 통일성을 위해 스타일 적용은 CSS에 맡김
}
tocNodes.push(title.cloneNode(true));
// 3. 그룹화 로직 (Flattened List -> Grouped Divs)
// 중첩이 엉망인 원본 무시하고, 모든 li를 긁어모음
const allLis = element.querySelectorAll('li');
let currentGroup = null;
allLis.forEach(li => {
const isLevel1 = li.classList.contains('toc-lvl-1');
// 대목차(Level 1)가 나오면 새로운 그룹 시작
if (isLevel1) {
// 이전 그룹이 있으면 저장
if (currentGroup) tocNodes.push(currentGroup);
// 새 그룹 박스 생성
currentGroup = document.createElement('div');
currentGroup.className = 'toc-group atomic-block';
// 내부 UL 생성 (들여쓰기 구조용)
const ulWrapper = document.createElement('ul');
ulWrapper.style.margin = "0";
ulWrapper.style.padding = "0";
currentGroup.appendChild(ulWrapper);
}
// 안전장치: 첫 시작이 소목차라 그룹이 없으면 하나 만듦
if (!currentGroup) {
currentGroup = document.createElement('div');
currentGroup.className = 'toc-group atomic-block';
const ulWrapper = document.createElement('ul');
ulWrapper.style.margin = "0";
ulWrapper.style.padding = "0";
currentGroup.appendChild(ulWrapper);
}
// 현재 그룹의 ul에 li 추가
currentGroup.querySelector('ul').appendChild(li.cloneNode(true));
});
// 마지막 그룹 저장
if (currentGroup) tocNodes.push(currentGroup);
return tocNodes;
}
// [2] 본문(Body) 처리 로직 (기존 박스 보존 로직 유지)
let nodes = [];
Array.from(element.children).forEach(child => {
detox(child);
if (child.classList.contains('highlight-box')) {
child.querySelectorAll('h3, h4, strong, b').forEach(head => {
head.removeAttribute('style');
head.removeAttribute('class');
});
nodes.push(child.cloneNode(true));
}
else if(['DIV','SECTION','ARTICLE','MAIN'].includes(child.tagName)) {
nodes = nodes.concat(getFlatNodes(child));
}
else if (['UL','OL'].includes(child.tagName)) {
Array.from(child.children).forEach((li, idx) => {
detox(li);
const w = document.createElement(child.tagName);
w.style.margin="0"; w.style.paddingLeft="20px";
if(child.tagName==='OL') w.start=idx+1;
const cloneLi = li.cloneNode(true);
cloneLi.querySelectorAll('*').forEach(el => detox(el));
w.appendChild(cloneLi);
nodes.push(w);
});
} else {
const clone = child.cloneNode(true);
detox(clone);
clone.querySelectorAll('*').forEach(el => detox(el));
nodes.push(clone);
}
});
return nodes;
}
// [Final Optimized Engine] Place -> Squeeze -> Check -> Split
// 목적: 배치 즉시 자간을 줄여 2글자 고아를 방지하고, 공간을 확보하여 페이지 밀림을 막음
function renderFlow(sectionType, sourceNodes) {
if (!sourceNodes.length) return;
let currentHeaderTitle = sectionType === 'toc' ? "목차" : (sectionType === 'summary' ? "요약" : reportTitle);
let page = createPage(sectionType, currentHeaderTitle);
let body = page.querySelector('.body-content');
// 원본 노드 보존을 위해 큐에 담기
let queue = [...sourceNodes];
while (queue.length > 0) {
let node = queue.shift();
let clone = node.cloneNode(true);
// [태그 판별]
let isH1 = clone.tagName === 'H1';
let isHeading = ['H2', 'H3'].includes(clone.tagName);
// LI도 텍스트로 취급하여 분할 대상에 포함
let isText = ['P', 'LI'].includes(clone.tagName) && !clone.classList.contains('atomic-block');
let isAtomic = ['TABLE', 'FIGURE', 'IMG', 'SVG'].includes(clone.tagName) ||
clone.querySelector('table, img, svg') ||
clone.classList.contains('atomic-block');
// [전처리] H1 텍스트 정제 ("-" 뒤 제거)
if (isH1 && clone.innerText.includes('-')) {
clone.innerText = clone.innerText.split('-')[0].trim();
}
// [Rule 1] H1 처리 (무조건 새 페이지)
if (isH1 && (sectionType === 'body' || sectionType === 'summary')) {
currentHeaderTitle = clone.innerText;
if (body.children.length > 0) {
page = createPage(sectionType, currentHeaderTitle);
body = page.querySelector('.body-content');
} else {
page.querySelector('.page-header').innerText = currentHeaderTitle;
}
}
// [Rule 2] Orphan Control (제목이 페이지 끝에 걸리는 것 방지)
if (isHeading) {
const spaceLeft = CONFIG.maxHeight - body.scrollHeight;
if (spaceLeft < 90) {
page = createPage(sectionType, currentHeaderTitle);
body = page.querySelector('.body-content');
}
}
// ▼▼▼ [Step 1: 일단 배치 (Place)] ▼▼▼
body.appendChild(clone);
// ▼▼▼ [Step 2: 자간 최적화 (Squeeze Logic)] ▼▼▼
// 배치 직후, 자간을 줄여서 줄바꿈을 없앨 수 있는지 확인
// 대상: 10글자 이상인 텍스트 노드
if (isText && clone.innerText.length > 10) {
const originalHeight = clone.offsetHeight;
// 1. 강력하게 줄여봄
clone.style.letterSpacing = "-1.0px";
// 2. 높이가 줄어들었는가? (줄바꿈이 사라짐 = Orphan 해결)
if (clone.offsetHeight < originalHeight) {
// 성공! 너무 빽빽하지 않게 -0.8px로 안착
clone.style.letterSpacing = "-0.8px";
} else {
// 실패! 줄여도 줄이 안 바뀌면 가독성을 위해 원상복구
clone.style.letterSpacing = "";
}
}
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
// [Rule 3] 넘침 감지 (Overflow Check)
if (body.scrollHeight > CONFIG.maxHeight) {
// 3-1. 텍스트 분할 (Split) - LI 태그 포함
if (isText) {
body.removeChild(clone); // 일단 제거
let textContent = node.innerText;
let tempP = node.cloneNode(false); // 태그 속성 유지 (li면 li, p면 p)
tempP.innerText = "";
// 위에서 결정된 최적 자간 스타일 유지
if (clone.style.letterSpacing) tempP.style.letterSpacing = clone.style.letterSpacing;
body.appendChild(tempP);
const words = textContent.split(' ');
let currentText = "";
for (let i = 0; i < words.length; i++) {
let word = words[i];
let prevText = currentText;
currentText += (currentText ? " " : "") + word;
tempP.innerText = currentText;
// 단어 하나 추가했더니 넘쳤는가?
if (body.scrollHeight > CONFIG.maxHeight) {
// 직전 상태(안 넘치는 상태)로 복구
tempP.innerText = prevText;
// [디자인 보정] 잘린 문단의 마지막 줄 양쪽 정렬
tempP.style.textAlign = "justify";
tempP.style.textAlignLast = "justify";
// 남은 단어들을 다시 합쳐서 대기열 맨 앞으로
let remainingText = words.slice(i).join(' ');
let remainingNode = node.cloneNode(false);
remainingNode.innerText = remainingText;
queue.unshift(remainingNode);
// 새 페이지 생성
page = createPage(sectionType, currentHeaderTitle);
body = page.querySelector('.body-content');
// [중요] 새 페이지 갔으면 압축 플래그/스타일 초기화
// 새 페이지에서는 다시 넉넉하게 시작해야 함
body.style.lineHeight = "";
body.style.letterSpacing = "";
break; // for문 탈출
}
}
}
// 3-2. 표, 그림, 박스인 경우 -> 통째로 다음 장으로 이동
else {
body.removeChild(clone); // 일단 뺌
// [Gap Filling] 빈 공간 채우기
let spaceLeft = CONFIG.maxHeight - body.scrollHeight;
// 공간이 50px 이상 있고, 앞에 글자가 이미 있을 때만 채우기 시도
if (body.children.length > 0 && spaceLeft > 50 && queue.length > 0) {
while(queue.length > 0) {
let candidate = queue[0];
if (['H1','H2','H3'].includes(candidate.tagName) ||
candidate.classList.contains('atomic-block') ||
candidate.querySelector('img, table')) break;
let filler = candidate.cloneNode(true);
// 가져올 때도 최적화(Squeeze) 시도
if(['P','LI'].includes(filler.tagName) && filler.innerText.length > 10) {
const hBefore = filler.offsetHeight; // (가상)
filler.style.letterSpacing = "-1.0px";
// 실제 DOM에 붙여봐야 높이를 알 수 있으므로 일단 적용
}
body.appendChild(filler);
if (body.scrollHeight <= CONFIG.maxHeight) {
// 들어갔으면 확정하고 대기열 제거
// 최적화 스타일 유지 (-1.0px -> -0.8px 조정 등은 생략해도 무방하나 디테일 원하면 추가 가능)
if(filler.style.letterSpacing === "-1.0px") filler.style.letterSpacing = "-0.8px";
queue.shift();
} else {
body.removeChild(filler);
break;
}
}
}
// 2. 이미지 배치 (수정된 핵심 로직)
// [버그 수정] 현재 페이지가 비어있지 않을 때만 새 페이지 생성!
if (body.children.length > 0) {
page = createPage(sectionType, currentHeaderTitle);
body = page.querySelector('.body-content');
}
// 이미지를 붙임
body.appendChild(clone);
// [Smart Fit] 넘치면 축소 (기존 유지)
if (isAtomic && body.scrollHeight > CONFIG.maxHeight) {
const currentH = clone.offsetHeight;
const overflow = body.scrollHeight - CONFIG.maxHeight;
body.removeChild(clone);
if (overflow > 0 && overflow < (currentH * 0.15)) {
clone.style.transform = "scale(0.85)";
clone.style.transformOrigin = "top center";
clone.style.marginBottom = `-${currentH * 0.15}px`;
body.appendChild(clone);
} else {
body.appendChild(clone); // 너무 크면 그냥 둠
}
}
}
}
}
}
function createPage(type, headerTitle) {
const tpl = document.getElementById('page-template');
const clone = tpl.content.cloneNode(true);
const sheet = clone.querySelector('.sheet');
if (type === 'cover') {
sheet.innerHTML = "";
const title = raw.cover.querySelector('h1')?.innerText || "Report";
const sub = raw.cover.querySelector('h2')?.innerText || "";
const pTags = raw.cover.querySelectorAll('p');
const infos = pTags.length > 0 ? Array.from(pTags).map(p => p.innerText).join(" / ") : "";
// [표지 스타일] 테두리 제거 및 중앙 정렬
sheet.innerHTML = `
<div style="position:absolute; top:20mm; right:20mm; text-align:right; font-size:11pt; color:#666;">${infos}</div>
<div style="display:flex; flex-direction:column; justify-content:center; align-items:center; height:100%; text-align:center; width:100%;">
<div style="width:85%;">
<div style="font-size:32pt; font-weight:900; color:var(--primary); line-height:1.2; margin-bottom:30px; word-break:keep-all;">${title}</div>
<div style="font-size:20pt; font-weight:300; color:#444; word-break:keep-all;">${sub}</div>
</div>
</div>`;
} else {
// ... (나머지 페이지 생성 로직 기존 유지) ...
clone.querySelector('.page-header').innerText = headerTitle;
clone.querySelector('.rpt-title').innerText = reportTitle;
if (type !== 'toc') clone.querySelector('.pg-num').innerText = `- ${globalPage++} -`;
else clone.querySelector('.pg-num').innerText = "";
}
document.body.appendChild(sheet);
return sheet;
}
createPage('cover');
if(raw.toc) renderFlow('toc', getFlatNodes(raw.toc));
// [요약 페이지 지능형 맞춤 로직 (Smart Squeeze)]
const summaryNodes = getFlatNodes(raw.summary);
// 1. 가상 공간에 미리 렌더링하여 높이 측정
const tempBox = document.createElement('div');
tempBox.style.width = "210mm";
tempBox.style.position = "absolute";
tempBox.style.visibility = "hidden";
tempBox.id = 'box-summary'; // CSS 적용
document.body.appendChild(tempBox);
// 노드 복제하여 주입
summaryNodes.forEach(node => tempBox.appendChild(node.cloneNode(true)));
// 2. 높이 분석 (Smart Runt Control)
const totalHeight = tempBox.scrollHeight;
const pageHeight = CONFIG.maxHeight; // 1페이지 가용 높이 (약 970px)
const lastPart = totalHeight % pageHeight;
// [조건] 넘친 양이 100px 미만일 때 압축
if (totalHeight > pageHeight && lastPart > 0 && lastPart < 180) {
summaryNodes.forEach(node => {
if(node.nodeType === 1) {
node.classList.add('squeeze');
if(node.tagName === 'H1') node.classList.add('squeeze-title');
// [추가] P, LI 태그에 더 강력한 인라인 스타일 강제 주입 (폰트 축소 포함)
if(node.tagName === 'P' || node.tagName === 'LI') {
node.style.fontSize = "9.5pt";
node.style.lineHeight = "1.4";
node.style.letterSpacing = "-0.8px";
}
}
});
}
// 뒷정리
document.body.removeChild(tempBox);
// 3. 렌더링 실행
renderFlow('summary', summaryNodes);
// ▼▼▼ [기존 유지] 본문 렌더링 및 마무리 작업 ▼▼▼
renderFlow('body', getFlatNodes(raw.content));
// 긴 제목 자동 축소 (기존 기능 유지)
document.querySelectorAll('.sheet h1, .sheet h2').forEach(el => {
let fs = 100;
while(el.scrollWidth > el.clientWidth && fs > 50) { el.style.fontSize = (--fs)+"%"; }
});
// ▼▼▼▼▼ [수정된 핵심 로직: 통합 자간 조정] ▼▼▼▼▼
// 변경점 1: 'li' 태그 포함
// 변경점 2: 표, 그림 내부 텍스트 제외
// 변경점 3: 글자수 제한 완화 (10자 이상이면 검사)
const allTextNodes = document.querySelectorAll('.sheet .body-content p, .sheet .body-content li');
allTextNodes.forEach(el => {
// 1. [제외 대상] 표(table), 그림(figure), 차트 내부는 건드리지 않음
if (el.closest('table') || el.closest('figure') || el.closest('.chart')) return;
// 2. [최소 길이] 10자 미만은 무시
if (el.innerText.trim().length < 10) return;
// 3. [테스트]
const originH = el.offsetHeight;
const originSpacing = el.style.letterSpacing;
el.style.fontSize = "12pt";
// 강력하게 당겨봄
el.style.letterSpacing = "-1.4px";
const newH = el.offsetHeight;
// 성공(높이 줄어듦) 시
if (newH < originH) {
el.style.letterSpacing = "-1.0px"; // 적당히 안착
}
else {
el.style.letterSpacing = originSpacing; // 원상복구
}
});
// ▲▲▲▲▲ [수정 끝] ▲▲▲▲▲
// 제목 자동 축소 (중복 실행 방지를 위해 제거해도 되지만, 안전하게 둠)
document.querySelectorAll('.sheet h1, .sheet h2').forEach(el => {
let fs = 100;
while(el.scrollWidth > el.clientWidth && fs > 50) { el.style.fontSize = (--fs)+"%"; }
});
const pages = document.querySelectorAll('.sheet'); // .page 대신 .sheet로 수정하여 정확도 높임
if (pages.length >= 2) {
const lastSheet = pages[pages.length - 1];
const prevSheet = pages[pages.length - 2];
// 커버나 목차가 아닐때만 진행
if(lastSheet.querySelector('.rpt-title')) {
const lastBody = lastSheet.querySelector('.body-content');
const prevBody = prevSheet.querySelector('.body-content');
// 마지막 페이지 내용이 3줄(약 150px) 이하인가?
if (lastBody.scrollHeight < 150 && lastBody.innerText.trim().length > 0) {
prevBody.style.lineHeight = "1.3"; // 앞 페이지 압축
prevBody.style.paddingBottom = "0px";
const contentToMove = Array.from(lastBody.children);
contentToMove.forEach(child => prevBody.appendChild(child.cloneNode(true)));
if (prevBody.scrollHeight <= CONFIG.maxHeight + 5) {
lastSheet.remove(); // 성공 시 마지막 장 삭제
} else {
// 실패 시 원상 복구
for(let i=0; i<contentToMove.length; i++) prevBody.lastElementChild.remove();
prevBody.style.lineHeight = "";
}
}
}
}
// 원본 데이터 삭제
const rawContainer = document.getElementById('raw-container');
if(rawContainer) rawContainer.remove();
});
</script>
</body>
</html>
⚠️ [최종 경고 - 출력 직전 필수 확인]
1. 원본의 모든 텍스트가 100% 포함되었는가?
2. "..." 또는 요약된 문장이 없는가?
3. 생략된 문단이 단 하나도 없는가?
위 3가지 중 하나라도 위반 시, 출력을 중단하고 처음부터 다시 작성하십시오.
원본 텍스트 글자 수와 출력 텍스트 글자 수가 동일해야 합니다.

View File

@@ -0,0 +1 @@
web: gunicorn app:app

View File

@@ -0,0 +1,453 @@
# 글벗 (Geulbeot) v10.0
**백엔드 재구조화 + 프론트 모듈화 + 도메인 지식 시스템 + 데모 모드**
다양한 형식의 자료(PDF·HWP·이미지·Excel 등)를 입력하면, AI가 RAG 파이프라인으로 분석한 뒤
선택한 문서 유형(기획서·보고서·발표자료 등)에 맞는 표준 HTML 문서를 자동 생성합니다.
생성된 문서는 웹 편집기에서 수정하고, HTML / PDF / HWP로 출력합니다.
v10에서는 코드베이스를 전면 재구조화했습니다.
백엔드는 handlers를 doc·template 서브패키지로 분리하고,
프론트엔드는 3,700줄짜리 index.html을 781줄로 축소하며 JS 9개 모듈로 분리했습니다.
토목 14개 세부분야 도메인 지식 시스템과 시연용 데모 모드를 추가했습니다.
---
## 🏗 아키텍처 (Architecture)
### 핵심 흐름
```
자료 입력 (파일/폴더)
도메인 지식 선택 (v10 신규) ─── 토목 14개 분야 + DX + 보고서 가이드
작성 방식 선택 ─── 형식만 변경 / 내용 재구성 / 신규 작성
RAG 파이프라인 (9단계) ─── 공통 처리 + 도메인 프롬프트
문서 유형 선택
├─ 기획서 (기본)
├─ 보고서 (기본)
├─ 발표자료 (기본)
└─ 사용자 등록 (HWPX 분석 → 자동 등록)
글벗 표준 HTML 생성 ◀── 템플릿 스타일 + 시맨틱 맵 참조
웹 편집기 (수기 편집 / AI 편집)
출력 (HTML / PDF / HWP)
```
### 1. Backend (Python Flask)
- **Language**: Python 3.13
- **Web Framework**: Flask 3.0 — 웹 서버 엔진, API 라우팅
- **AI**:
- Claude API (Anthropic) — 기획서 생성, AI 편집, 문서 유형 맥락 분석
- OpenAI API — RAG 임베딩, 인덱싱, 텍스트 추출
- Gemini API — 보고서 콘텐츠·HTML 생성
- **Features**:
- 자료 입력 → 9단계 RAG 파이프라인 + 도메인 프롬프트
- 문서 유형별 생성: 기획서 (Claude), 보고서 (Gemini), 사용자 정의 유형
- AI 편집: 전체 수정 (`/refine`), 부분 수정 (`/refine-selection`)
- 문서 유형 분석·등록: HWPX → 12종 도구 추출 → 시맨틱 매핑 → 스타일 생성 → 유형 CRUD
- 도메인 지식 관리 (v10 신규): 토목 14분야 + DX + 보고서 가이드
- HWP/PDF 변환
### 2. Frontend (v10 모듈화)
- **index.html**: 3,763줄 → **781줄** — HTML 셸만 유지
- **main.css**: 1,825줄 — 인라인 CSS 전부 외부 분리
- **JS 9개 모듈**:
| 모듈 | 줄 수 | 역할 |
|------|-------|------|
| editor.js | 1,208 | 웹 WYSIWYG 편집기 |
| doc_type.js | 587 | 문서 유형 선택·CRUD |
| generator.js | 483 | 기획서·보고서 생성 호출 |
| demo_mode.js | 370 | 시연용 데모 모드 |
| domain_selector.js | 287 | 도메인 지식 선택 모달 |
| template.js | 188 | 템플릿 관리 UI |
| ai_edit.js | 142 | AI 편집 (전체·부분) |
| modals.js | 134 | 공통 모달 컴포넌트 |
| ui.js | 91 | UI 유틸리티 |
| export.js | 71 | HTML/PDF/HWP 다운로드 |
### 3. 백엔드 패키지 구조 (v10 재구조화)
```
handlers/
├── briefing/ 기획서 생성
├── report/ 보고서 생성
├── doc/ ★ v10 — 문서 유형 서브패키지
│ ├── doc_type_analyzer.py AI 맥락·구조 분석
│ ├── content_analyzer.py placeholder 분석
│ └── custom_doc_type.py 사용자 유형 문서 생성
└── template/ ★ v10 — 템플릿 서브패키지
├── processor.py 기본 관리
├── doc_template_analyzer.py 12종 도구 오케스트레이터
├── semantic_mapper.py 요소 의미 판별
├── style_generator.py CSS 생성
├── template_manager.py CRUD + template.html 조립
└── tools/ HWPX 추출 도구 12종
```
### 4. 도메인 지식 시스템 (v10 신규)
- **domain_api.py** (456줄): 도메인 지식 관리 API + 파이프라인 래퍼
- **domain_config.json**: 카테고리 구조 정의 (계층형 선택)
- **토목 14개 세부분야**: 측량·해석·교량·터널·도로·구조·지반·시공·공정원가·품질환경·안전·통신·BIM·기획
- **DX (디지털 전환)**: 스마트 건설, AI/IoT
- **보고서 가이드**: 현안보고서 구조, 작성 가이드
- **도메인 선택 UI**: 체크박스 모달 → 선택된 .txt 합쳐서 RAG 파이프라인에 도메인 프롬프트로 전달
### 5. 데모 모드 (v10 신규)
- **demo_mode.js**: `DEMO_MODE = true` 시 실제 API 호출 없이 샘플 문서 표시
- **샘플 HTML 4종**: 기획서 1p·2p, 보고서, 발표자료
- 시연·발표용 — 목차 애니메이션 + 가짜 생성 프로세스
### 6. 주요 시나리오 (Core Scenarios)
1. **기획서 생성**: RAG 분석 후 Claude API가 글벗 표준 HTML 생성
2. **보고서 생성**: RAG 파이프라인 → Gemini API가 다페이지 HTML 보고서 생성
3. **사용자 정의 문서 생성**: 등록된 유형의 template.html 기반 정리·재구성
4. **문서 유형 등록**: HWPX 업로드 → 자동 분석 → 유형 CRUD
5. **도메인 지식 적용 (v10 신규)**: 분야 선택 → RAG 파이프라인에 전문 용어·기준 주입
6. **데모 시연 (v10 신규)**: API 없이 샘플 문서로 전체 워크플로우 시연
7. **AI 편집 / HWP 내보내기**
### 프로세스 플로우
#### RAG 파이프라인 (공통)
```mermaid
flowchart TD
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiGpt fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A[/"📂 자료 입력 (파일/폴더)"/]:::process
B["step1: 파일 변환\n모든 형식 → PDF 통일"]:::process
C["step2: 텍스트·이미지 추출\n⚡ GPT API"]:::aiGpt
D{"분량 판단\n5,000자 기준"}:::decision
E["step3: 도메인 분석"]:::process
F["step4: 의미 단위 청킹"]:::process
G["step5: RAG 임베딩 ⚡ GPT"]:::aiGpt
H["step6: 코퍼스 생성"]:::process
I["step7: FAISS 인덱싱 + 목차 ⚡ GPT"]:::aiGpt
J(["📋 분석 완료 → 문서 유형 선택"]):::startEnd
A --> B --> C --> D
D -->|"≥ 5,000자"| E --> F --> G --> H --> I
D -->|"< 5,000자"| I
I --> J
```
#### 전체 워크플로우 (v10 시점)
```mermaid
flowchart TD
classDef decision fill:#fffde7,stroke:#f9a825,stroke-width:2px,color:#333
classDef aiClaude fill:#fff3cd,stroke:#d97706,stroke-width:2px,color:#856404
classDef aiGemini fill:#d6eaf8,stroke:#4285f4,stroke-width:2px,color:#1a4d8f
classDef editStyle fill:#fff3e0,stroke:#ef6c00,stroke-width:1.5px,color:#e65100
classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
classDef planned fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#999
classDef newModule fill:#e0f2f1,stroke:#00695c,stroke-width:2px,color:#004d40
classDef uiNew fill:#e8eaf6,stroke:#3949ab,stroke-width:2px,color:#1a237e
classDef domainStyle fill:#fce4ec,stroke:#c62828,stroke-width:2px,color:#b71c1c
A(["📂 자료 입력"]):::startEnd
DOM["🏗️ 도메인 지식 선택\n토목 14분야 + DX\n(v10 신규)"]:::domainStyle
W{"작성 방식 선택"}:::uiNew
W1["📄 형식만 변경"]:::uiNew
W2["🔄 내용 재구성"]:::uiNew
W3["✨ 신규 작성"]:::uiNew
R["RAG 파이프라인\n9단계 + 도메인 프롬프트"]:::startEnd
B{"문서 유형 선택"}:::decision
C["기획서 생성\n⚡ Claude API"]:::aiClaude
D["보고서 생성\n⚡ Gemini API"]:::aiGemini
E["발표자료\n예정"]:::planned
U["사용자 정의 유형\ntemplate.html 기반"]:::newModule
T["📋 템플릿 + 시맨틱 맵"]:::newModule
G["글벗 표준 HTML"]:::startEnd
H{"편집 방식"}:::decision
I["웹 편집기\n수기 편집"]:::editStyle
J["AI 편집\n전체·부분 수정\n⚡ Claude API"]:::aiClaude
K{"출력 형식"}:::decision
L["HTML / PDF"]:::exportStyle
M["HWP 변환\n하이브리드"]:::exportStyle
N["PPT\n예정"]:::planned
O(["✅ 최종 산출물"]):::startEnd
A --> DOM --> W
W --> W1 & W2 & W3
W1 & W2 & W3 --> R
DOM -.->|"도메인 프롬프트"| R
R --> B
B -->|"기획서"| C --> G
B -->|"보고서"| D --> G
B -->|"발표자료"| E -.-> G
B -->|"사용자 유형"| U --> G
T -.->|"스타일·구조 참조"| U
G --> H
H -->|"수기"| I --> K
H -->|"AI"| J --> K
K -->|"웹/인쇄"| L --> O
K -->|"HWP"| M --> O
K -->|"PPT"| N -.-> O
```
#### 문서 유형 등록
```mermaid
flowchart TD
classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d
classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100
classDef aiNode fill:#d4edda,stroke:#10a37f,stroke-width:2px,color:#155724
classDef dataStore fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px
A(["📄 HWPX 업로드"]):::startEnd
B["DocTemplateAnalyzer\n12종 tools 코드 추출"]:::newModule
C["SemanticMapper\n요소 의미 판별\n헤더표/푸터표/제목블록/데이터표"]:::newModule
D["StyleGenerator\n추출값 → CSS 생성\ncharPr·paraPr·폰트 매핑"]:::newModule
E["ContentAnalyzer\nplaceholder 의미·유형\ncontent_prompt.json"]:::newModule
F["DocTypeAnalyzer\n⚡ AI 맥락·구조 분석\nconfig.json"]:::aiNode
G["TemplateManager\ntemplate.html 조립"]:::newModule
H[("📋 templates/user/\ntemplates/{tpl_id}/\ndoc_types/{type_id}/")]:::dataStore
A --> B --> C --> D --> E
B --> F
C & D & E & F --> G --> H
```
---
## 🔄 v9 → v10 변경사항
| 영역 | v9 | v10 |
|------|------|------|
| handlers 구조 | 평탄 (루트에 7개 모듈) | **handlers/doc/ + handlers/template/** 서브패키지로 분리 |
| 프론트 index.html | 3,763줄 (인라인 CSS·JS) | **781줄** — HTML 셸만 유지 |
| CSS | 인라인 | **static/css/main.css** (1,825줄) 외부 분리 |
| JS | editor.js 단일 | **9개 모듈** 분리 (doc_type·generator·demo_mode 등) |
| 도메인 지식 | 없음 | **domain_api.py** + domain_config.json + 토목 14분야 txt |
| 도메인 선택 UI | 없음 | **domain_selector.js** — 체크박스 모달 |
| 데모 모드 | 없음 | **demo_mode.js** + 샘플 HTML 4종 |
| 보고서 가이드 | 없음 | **domain/report_guide/** — 현안보고서 구조·작성법 |
| 레거시 정리 | prompts/ 잔존 | **prompts/ 삭제** |
---
## 🗺 상태 및 로드맵 (Status & Roadmap)
- **Phase 1**: RAG 파이프라인 — 9단계 파이프라인, 도메인 분석, 분량 자동 판단 (🔧 기본 구현)
- **Phase 2**: 문서 생성 — 기획서·보고서·사용자 정의 유형 AI 생성 (🔧 기본 구현)
- **Phase 3**: 출력 — HTML/PDF 다운로드, HWP 변환 (🔧 기본 구현)
- **Phase 4**: HWP/HWPX/HTML 매핑 — 스타일 분석·HWPX 생성·스타일 주입·표 주입 (🔧 기본 구현)
- **Phase 5**: 문서 유형 분석·등록 — HWPX → 12종 도구 추출 → 시맨틱 매핑 → 유형 CRUD (🔧 기본 구현)
- **Phase 6**: HWPX 템플릿 관리 — template_manager, content_order, 독립 저장 (🔧 기본 구현)
- **Phase 7**: UI 고도화 — 프론트 모듈화, 데모 모드, 도메인 선택기 (🔧 기본 구현 · 현재 버전)
- **Phase 8**: 백엔드 재구조화 — handlers 서브패키지 분리, 레거시 정리 (🔧 기본 구현 · 현재 버전)
---
## 🚀 시작하기 (Getting Started)
### 사전 요구사항
- Python 3.10+
- Claude API 키 (Anthropic) — 기획서 생성, AI 편집, 문서 유형 분석
- OpenAI API 키 — RAG 파이프라인
- Gemini API 키 — 보고서 콘텐츠·HTML 생성
- pyhwpx — HWP 변환 시 (Windows + 한글 프로그램 필수)
### 환경 설정
```bash
git clone http://[Gitea주소]/kei/geulbeot-v10.git
cd geulbeot-v10
python -m venv venv
venv\Scripts\activate # Windows
pip install -r requirements.txt
cp .env.sample .env
# .env 파일을 열어 실제 API 키 입력
```
### .env 작성
```env
CLAUDE_API_KEY=sk-ant-your-key-here # 기획서 생성, AI 편집, 유형 분석
GPT_API_KEY=sk-proj-your-key-here # RAG 파이프라인
GEMINI_API_KEY=AIzaSy-your-key-here # 보고서 콘텐츠 생성
```
### 실행
```bash
python app.py
# → http://localhost:5000 접속
```
### 데모 모드
API 키 없이 시연하려면 `static/js/demo_mode.js`에서 `DEMO_MODE = true` 확인 후 실행.
샘플 문서(기획서 2종 + 보고서 + 발표자료)가 자동 표시됩니다.
---
## 📂 프로젝트 구조
```
geulbeot_10th/
├── app.py # Flask 웹 서버 — API 라우팅
├── api_config.py # .env 환경변수 로더
├── domain_api.py # ★ v10 — 도메인 지식 관리 API
├── domain_config.json # ★ v10 — 도메인 카테고리 구조
├── domain/ # 도메인 지식
│ ├── hwpx/ # HWPX 명세서 + 유틸
│ ├── civil/ # ★ v10 — 토목 분야
│ │ ├── general.txt # 토목 일반
│ │ ├── dx.txt # DX (디지털 전환)
│ │ └── specialties/ # 14개 세부분야
│ │ ├── survey.txt · road.txt · bridge.txt · tunnel.txt
│ │ ├── structure.txt · geotechnical.txt · construction.txt
│ │ ├── schedule_cost.txt · quality_env.txt · safety.txt
│ │ ├── communication.txt · bim.txt · planning.txt
│ │ └── anlysis.txt
│ └── report_guide/ # ★ v10 — 보고서 작성 가이드
├── handlers/ # 비즈니스 로직 (★ v10 재구조화)
│ ├── common.py
│ ├── briefing/ # 기획서 처리
│ ├── report/ # 보고서 처리
│ ├── doc/ # ★ v10 서브패키지 — 문서 유형
│ │ ├── doc_type_analyzer.py
│ │ ├── content_analyzer.py
│ │ └── custom_doc_type.py
│ └── template/ # ★ v10 서브패키지 — 템플릿
│ ├── processor.py · template_manager.py
│ ├── doc_template_analyzer.py · semantic_mapper.py
│ ├── style_generator.py
│ └── tools/ # HWPX 추출 도구 12종
├── converters/ # 변환 엔진
│ ├── pipeline/ # 9단계 RAG 파이프라인
│ └── (style_analyzer, hwpx_*, html_to_hwp*)
├── templates/
│ ├── default/doc_types/ # 기본 유형 (briefing·report·presentation)
│ ├── user/ # 사용자 등록 데이터
│ └── index.html # ★ v10 — 781줄 (HTML 셸만)
├── static/ # ★ v10 프론트 모듈화
│ ├── css/
│ │ ├── main.css # 1,825줄 (인라인 CSS 분리)
│ │ └── editor.css # 편집기 스타일
│ ├── js/
│ │ ├── editor.js # WYSIWYG 편집기
│ │ ├── doc_type.js # 문서 유형 선택·CRUD
│ │ ├── generator.js # 문서 생성 호출
│ │ ├── demo_mode.js # 시연용 데모
│ │ ├── domain_selector.js # 도메인 지식 선택
│ │ ├── template.js # 템플릿 관리
│ │ ├── ai_edit.js # AI 편집
│ │ ├── modals.js # 공통 모달
│ │ ├── ui.js # UI 유틸리티
│ │ └── export.js # 다운로드
│ └── result/ # ★ v10 — 데모 샘플 HTML 4종
├── .env / .env.sample
├── .gitignore
├── Procfile
└── README.md
```
---
## 🎨 글벗 표준 HTML 양식
| 항목 | 사양 |
|------|------|
| 용지 | A4 인쇄 최적화 (210mm × 297mm) |
| 폰트 | Noto Sans KR (Google Fonts) |
| 색상 | Navy 계열 (#1a365d 기본) |
| 구성 | page-header → lead-box → section → data-table → bottom-box → page-footer |
| 인쇄 | `@media print` 대응, `break-after: page` 페이지 분리 |
---
## ⚠️ 알려진 제한사항
- API 키 분산: 파이프라인 각 step에 개별 정의 (공통화 미완)
- HWP 변환: Windows + pyhwpx + 한글 프로그램 필수
- 발표자료: config.json만 존재, 실제 생성 미구현
- 도메인 지식: 토목 분야만 구축 (타 분야 확장 가능)
- 도메인 → RAG 연동: 선택된 도메인 프롬프트 주입 경로 완성 중
---
## 📊 코드 규모
| 영역 | 줄 수 |
|------|-------|
| Python 전체 | 19,402 (+462) |
| 프론트엔드 (JS + CSS + HTML) | 6,463 (+1,196) |
| 도메인 지식 (txt) | 1,225 |
| **합계** | **~27,100** |
---
## 📝 버전 이력
| 버전 | 핵심 변경 |
|------|----------|
| v1 | Flask + Claude API 기획서 생성기 |
| v2 | 웹 편집기 추가 |
| v3 | 9단계 RAG 파이프라인 + HWP 변환 |
| v4 | 코드 모듈화 (handlers 패키지) + 스타일 분석기·HWPX 생성기 |
| v5 | HWPX 스타일 주입 + 표 열 너비 정밀 변환 |
| v6 | HWPX 템플릿 분석·저장·관리 |
| v7 | UI 고도화 — 작성 방식·문서 유형·템플릿 관리 UI |
| v8 | 문서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플릿 고도화 |
| v9 | 표 매칭 안정화 + 인라인 아이콘 감지 + 프론트 외부 참조 |
| **v10** | **백엔드 재구조화 + 프론트 모듈화 + 도메인 지식 + 데모 모드** |
---
## 📝 라이선스
Private — GPD 내부 사용

View File

@@ -0,0 +1,30 @@
"""API 키 관리 - .env 파일에서 읽기"""
import os
from pathlib import Path
def load_api_keys():
"""프로젝트 폴더의 .env에서 API 키 로딩"""
# python-dotenv 있으면 사용
try:
from dotenv import load_dotenv
env_path = Path(__file__).resolve().parent / '.env'
load_dotenv(env_path)
except ImportError:
# python-dotenv 없으면 수동 파싱
env_path = Path(__file__).resolve().parent / '.env'
if env_path.exists():
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
os.environ.setdefault(key.strip(), value.strip())
return {
'CLAUDE_API_KEY': os.getenv('CLAUDE_API_KEY', ''),
'GPT_API_KEY': os.getenv('GPT_API_KEY', ''),
'GEMINI_API_KEY': os.getenv('GEMINI_API_KEY', ''),
'PERPLEXITY_API_KEY': os.getenv('PERPLEXITY_API_KEY', ''),
}
API_KEYS = load_api_keys()

View File

@@ -0,0 +1,684 @@
# -*- coding: utf-8 -*-
"""
글벗 Light v2.0
Flask 라우팅 + 공통 기능
"""
import os
import io
import tempfile
import json
import shutil
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response, session, send_file
import queue
import threading
from handlers.template.template_manager import TemplateManager
from pathlib import Path
from domain_api import register_domain_routes
# 문서 유형별 프로세서
from handlers.template import TemplateProcessor
from handlers.briefing import BriefingProcessor
from handlers.report import ReportProcessor
from handlers.doc.custom_doc_type import CustomDocTypeProcessor
from handlers.doc.doc_type_analyzer import DocTypeAnalyzer
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
register_domain_routes(app)
# processors 딕셔너리에 추가
template_mgr = TemplateManager()
processors = {
'briefing': BriefingProcessor(),
'report': ReportProcessor(),
'template': TemplateProcessor(),
'custom': CustomDocTypeProcessor()
}
DOC_TYPES_DEFAULT = Path('templates/default/doc_types')
DOC_TYPES_USER = Path('templates/user/doc_types')
# ============== 메인 페이지 ==============
@app.route('/')
def index():
"""메인 페이지"""
return render_template('index.html')
@app.route('/api/doc-types', methods=['GET'])
def get_doc_types():
"""문서 유형 목록 조회"""
try:
doc_types = []
# default 폴더 스캔
if DOC_TYPES_DEFAULT.exists():
for folder in DOC_TYPES_DEFAULT.iterdir():
if folder.is_dir():
config_file = folder / 'config.json'
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
doc_types.append(json.load(f))
# user 폴더 스캔
if DOC_TYPES_USER.exists():
for folder in DOC_TYPES_USER.iterdir():
if folder.is_dir():
config_file = folder / 'config.json'
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
doc_types.append(json.load(f))
# order → isDefault 순 정렬
doc_types.sort(key=lambda x: (x.get('order', 999), not x.get('isDefault', False)))
return jsonify(doc_types)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/api/doc-types', methods=['POST'])
def add_doc_type():
"""문서 유형 추가 (분석 결과 저장)"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'JSON 데이터가 필요합니다'}), 400
# user 폴더 생성
DOC_TYPES_USER.mkdir(parents=True, exist_ok=True)
type_id = data.get('id')
if not type_id:
import time
type_id = f"user_{int(time.time())}"
data['id'] = type_id
folder_path = DOC_TYPES_USER / type_id
folder_path.mkdir(parents=True, exist_ok=True)
# config.json 저장
with open(folder_path / 'config.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return jsonify(data)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/api/doc-types/<type_id>', methods=['DELETE'])
def delete_doc_type(type_id):
"""문서 유형 삭제"""
try:
folder_path = DOC_TYPES_USER / type_id
if not folder_path.exists():
return jsonify({'error': '문서 유형을 찾을 수 없습니다'}), 404
shutil.rmtree(folder_path)
return jsonify({'success': True, 'deleted': type_id})
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 생성 API ==============
@app.route('/generate', methods=['POST'])
def generate():
"""문서 생성 API"""
try:
content = ""
if 'file' in request.files and request.files['file'].filename:
file = request.files['file']
content = file.read().decode('utf-8')
elif 'content' in request.form:
content = request.form.get('content', '')
doc_type = request.form.get('doc_type', 'briefing')
if doc_type.startswith('user_'):
options = {
'instruction': request.form.get('instruction', '')
}
result = processors['custom'].generate(content, doc_type, options)
else:
options = {
'page_option': request.form.get('page_option', '1'),
'department': request.form.get('department', ''),
'instruction': request.form.get('instruction', '')
}
processor = processors.get(doc_type, processors['briefing'])
result = processor.generate(content, options)
if 'error' in result:
return jsonify(result), 400 if 'trace' not in result else 500
return jsonify(result)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/generate-report', methods=['POST'])
def generate_report():
"""보고서 생성 API"""
try:
data = request.get_json() or {}
content = data.get('content', '')
options = {
'folder_path': data.get('folder_path', ''),
'cover': data.get('cover', False),
'toc': data.get('toc', False),
'divider': data.get('divider', False),
'instruction': data.get('instruction', ''),
'template_id': data.get('template_id')
}
result = processors['report'].generate(content, options)
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 수정 API ==============
@app.route('/refine', methods=['POST'])
def refine():
"""피드백 반영 API"""
try:
feedback = request.json.get('feedback', '')
current_html = request.json.get('current_html', '') or session.get('current_html', '')
original_html = session.get('original_html', '')
doc_type = request.json.get('doc_type', 'briefing')
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine(feedback, current_html, original_html)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/refine-selection', methods=['POST'])
def refine_selection():
"""선택 부분 수정 API"""
try:
data = request.json
current_html = data.get('current_html', '')
selected_text = data.get('selected_text', '')
user_request = data.get('request', '')
doc_type = data.get('doc_type', 'briefing')
processor = processors.get(doc_type, processors['briefing'])
result = processor.refine_selection(current_html, selected_text, user_request)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============== 다운로드 API ==============
@app.route('/download/html', methods=['POST'])
def download_html():
"""HTML 파일 다운로드"""
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.html'
return Response(
html_content,
mimetype='text/html',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
@app.route('/download/pdf', methods=['POST'])
def download_pdf():
"""PDF 파일 다운로드"""
try:
from weasyprint import HTML
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
pdf_buffer = io.BytesIO()
HTML(string=html_content).write_pdf(pdf_buffer)
pdf_buffer.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.pdf'
return Response(
pdf_buffer.getvalue(),
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
except ImportError:
return jsonify({'error': 'PDF 변환 미지원'}), 501
except Exception as e:
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
# ============== 기타 API ==============
@app.route('/assets/<path:filename>')
def serve_assets(filename):
"""로컬 assets 폴더 서빙"""
assets_dir = r"D:\for python\geulbeot-light\geulbeot-light\output\assets"
return send_file(os.path.join(assets_dir, filename))
@app.route('/hwp-script')
def hwp_script():
"""HWP 변환 스크립트 안내"""
return render_template('hwp_guide.html')
@app.route('/health')
def health():
"""헬스 체크"""
return jsonify({'status': 'healthy', 'version': '2.0.0'})
@app.route('/export-hwp', methods=['POST'])
def export_hwp():
"""HWP 변환 (스타일 그루핑 지원)"""
try:
data = request.get_json()
html_content = data.get('html', '')
doc_type = data.get('doc_type', 'briefing')
use_style_grouping = data.get('style_grouping', False) # 새 옵션
if not html_content:
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
temp_dir = tempfile.gettempdir()
html_path = os.path.join(temp_dir, 'geulbeot_temp.html')
hwp_path = os.path.join(temp_dir, 'geulbeot_output.hwp')
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html_content)
# 변환기 선택
if doc_type == 'briefing':
from converters.html_to_hwp_briefing import HtmlToHwpConverter
else:
from converters.html_to_hwp import HtmlToHwpConverter
converter = HtmlToHwpConverter(visible=False)
# 스타일 그루핑 사용 여부
if use_style_grouping:
final_path = converter.convert_with_styles(html_path, hwp_path)
# HWPX 파일 전송
return send_file(
final_path,
as_attachment=True,
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwpx',
mimetype='application/vnd.hancom.hwpx'
)
else:
converter.convert(html_path, hwp_path)
return send_file(
hwp_path,
as_attachment=True,
download_name=f'report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.hwp',
mimetype='application/x-hwp'
)
except ImportError as e:
return jsonify({'error': f'pyhwpx 필요: {str(e)}'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
# 기존 add_doc_type 대체 또는 수정
@app.route('/api/doc-types/analyze', methods=['POST'])
def analyze_doc_type():
"""문서 유형 분석 API"""
if 'file' not in request.files:
return jsonify({"error": "파일이 필요합니다"}), 400
file = request.files['file']
doc_name = request.form.get('name', '새 문서 유형')
# 임시 저장
import tempfile
temp_path = os.path.join(tempfile.gettempdir(), file.filename)
file.save(temp_path)
try:
analyzer = DocTypeAnalyzer()
result = analyzer.analyze(temp_path, doc_name)
return jsonify({
"success": True,
"config": result["config"],
"summary": {
"pageCount": result["structure"]["pageCount"],
"sections": len(result["toc"]),
"style": result["style"]
}
})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
os.remove(temp_path)
@app.route('/analyze-styles', methods=['POST'])
def analyze_styles():
"""HTML 스타일 분석 미리보기"""
try:
data = request.get_json()
html_content = data.get('html', '')
if not html_content:
return jsonify({'error': 'HTML 내용이 없습니다'}), 400
from converters.style_analyzer import StyleAnalyzer
from converters.hwp_style_mapping import ROLE_TO_STYLE_NAME
analyzer = StyleAnalyzer()
elements = analyzer.analyze(html_content)
# 요약 정보
summary = analyzer.get_role_summary()
# 상세 정보 (처음 50개만)
details = []
for elem in elements[:50]:
details.append({
'role': elem.role,
'hwp_style': ROLE_TO_STYLE_NAME.get(elem.role, '바탕글'),
'text': elem.text[:50] + ('...' if len(elem.text) > 50 else ''),
'section': elem.section
})
return jsonify({
'total_elements': len(elements),
'summary': summary,
'details': details
})
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
@app.route('/templates', methods=['GET'])
def get_templates():
"""저장된 템플릿 목록 조회"""
try:
templates = template_mgr.list_templates()
return jsonify(templates)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates', methods=['GET'])
def get_templates_api():
"""템플릿 목록 조회 (API 경로)"""
try:
templates = template_mgr.list_templates()
return jsonify(templates)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/analyze-template', methods=['POST'])
def analyze_template():
"""템플릿 추출 및 저장 (doc_template_analyzer → template_manager)"""
try:
if 'file' not in request.files:
return jsonify({'error': '파일이 없습니다'}), 400
file = request.files['file']
name = request.form.get('name', '').strip()
if not name:
return jsonify({'error': '템플릿 이름을 입력해주세요'}), 400
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
# 임시 저장 → HWPX 파싱 → 템플릿 추출
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
try:
# v3 파서 재사용 (HWPX → parsed dict)
from handlers.doc.doc_type_analyzer import DocTypeAnalyzer
parser = DocTypeAnalyzer()
parsed = parser._parse_hwpx(temp_path)
# template_manager로 추출+저장
result = template_mgr.extract_and_save(
parsed, name,
source_file=file.filename
)
return jsonify(result)
finally:
try:
os.remove(temp_path)
except:
pass
except Exception as e:
import traceback
return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500
# ============== 문서 유형 분석 SSE API ==============
@app.route('/api/doc-types/analyze-stream', methods=['POST'])
def analyze_doc_type_stream():
"""
문서 유형 분석 (SSE 스트리밍)
실시간으로 각 단계의 진행 상황을 전달
"""
import tempfile
# 파일 및 데이터 검증
if 'file' not in request.files:
return jsonify({'error': '파일이 없습니다'}), 400
file = request.files['file']
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
return jsonify({'error': '문서 유형 이름을 입력해주세요'}), 400
if not file.filename:
return jsonify({'error': '파일을 선택해주세요'}), 400
# 임시 파일 저장
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, file.filename)
file.save(temp_path)
# 메시지 큐 생성
message_queue = queue.Queue()
analysis_result = {"data": None, "error": None}
def progress_callback(step_id, status, message):
"""진행 상황 콜백 - 메시지 큐에 추가"""
message_queue.put({
"type": "progress",
"step": step_id,
"status": status,
"message": message
})
def run_analysis():
"""분석 실행 (별도 스레드)"""
try:
analyzer = DocTypeAnalyzer(progress_callback=progress_callback)
result = analyzer.analyze(temp_path, name, description)
# 저장
save_path = analyzer.save_doc_type(result["config"], result.get("template", "") )
analysis_result["data"] = {
"success": True,
"config": result["config"],
"layout": result.get("layout", {}),
"context": result.get("context", {}),
"structure": result.get("structure", {}),
"template_generated": bool(result.get("template_id") or result.get("template")),
"template_id": result.get("template_id"), # ★ 추가
"saved_path": save_path
}
except Exception as e:
import traceback
analysis_result["error"] = {
"message": str(e),
"trace": traceback.format_exc()
}
finally:
# 완료 신호
message_queue.put({"type": "complete"})
# 임시 파일 삭제
try:
os.remove(temp_path)
except:
pass
def generate_events():
"""SSE 이벤트 생성기"""
# 분석 시작
analysis_thread = threading.Thread(target=run_analysis)
analysis_thread.start()
# 이벤트 스트리밍
while True:
try:
msg = message_queue.get(timeout=60) # 60초 타임아웃
if msg["type"] == "complete":
# 분석 완료
if analysis_result["error"]:
yield f"data: {json.dumps({'type': 'error', 'error': analysis_result['error']}, ensure_ascii=False)}\n\n"
else:
yield f"data: {json.dumps({'type': 'result', 'data': analysis_result['data']}, ensure_ascii=False)}\n\n"
break
else:
# 진행 상황
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
except queue.Empty:
# 타임아웃
yield f"data: {json.dumps({'type': 'error', 'error': {'message': '분석 시간 초과'}}, ensure_ascii=False)}\n\n"
break
return Response(
generate_events(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
@app.route('/delete-template/<template_id>', methods=['DELETE'])
def delete_template(template_id):
"""템플릿 삭제 (레거시 호환)"""
try:
result = template_mgr.delete_template(template_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates/<tpl_id>', methods=['GET'])
def get_template(tpl_id):
"""특정 템플릿 조회"""
try:
result = template_mgr.load_template(tpl_id)
if 'error' in result:
return jsonify(result), 404
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/templates/<tpl_id>', methods=['DELETE'])
def delete_template_new(tpl_id):
"""템플릿 삭제"""
try:
result = template_mgr.delete_template(tpl_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/doc-types/<type_id>/template', methods=['PUT'])
def change_doc_type_template(type_id):
"""문서 유형의 템플릿 교체"""
try:
data = request.get_json()
new_tpl_id = data.get('template_id')
if not new_tpl_id:
return jsonify({'error': 'template_id가 필요합니다'}), 400
result = template_mgr.change_template(type_id, new_tpl_id)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/doc-types/<type_id>/template', methods=['GET'])
def get_doc_type_template(type_id):
"""문서 유형에 연결된 템플릿 조회"""
try:
result = template_mgr.get_template_for_doctype(type_id)
if 'error' in result:
return jsonify(result), 404
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(host='0.0.0.0', port=port, debug=debug)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
# -*- coding: utf-8 -*-
"""
HTML → HWP 변환기 (기획서 전용)
✅ 머리말/꼬리말: 보고서 방식 적용 (페이지 번호 포함)
✅ lead-box, section, data-table, strategy-grid, qa-grid, bottom-box 지원
✅ process-container (단계별 프로세스) 지원
✅ badge 스타일 텍스트 변환
✅ Navy 색상 테마
pip install pyhwpx beautifulsoup4
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup
import os
class Config:
"""페이지 설정"""
PAGE_WIDTH = 210
PAGE_HEIGHT = 297
MARGIN_LEFT = 20
MARGIN_RIGHT = 20
MARGIN_TOP = 20
MARGIN_BOTTOM = 15
HEADER_LEN = 10
FOOTER_LEN = 10
CONTENT_WIDTH = 170
class HtmlToHwpConverter:
"""HTML → HWP 변환기 (기획서 전용)"""
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.cfg = Config()
self.colors = {}
self.is_first_h1 = True
# ─────────────────────────────────────────────────────────
# 초기화 및 유틸리티
# ─────────────────────────────────────────────────────────
def _init_colors(self):
"""색상 팔레트 초기화 (Navy 계열)"""
self.colors = {
'primary-navy': self.hwp.RGBColor(26, 54, 93), # #1a365d
'secondary-navy': self.hwp.RGBColor(44, 82, 130), # #2c5282
'accent-navy': self.hwp.RGBColor(49, 130, 206), # #3182ce
'dark-gray': self.hwp.RGBColor(45, 55, 72), # #2d3748
'medium-gray': self.hwp.RGBColor(74, 85, 104), # #4a5568
'light-gray': self.hwp.RGBColor(226, 232, 240), # #e2e8f0
'bg-light': self.hwp.RGBColor(247, 250, 252), # #f7fafc
'border-color': self.hwp.RGBColor(203, 213, 224), # #cbd5e0
'badge-safe': self.hwp.RGBColor(30, 111, 63), # #1e6f3f
'badge-caution': self.hwp.RGBColor(154, 91, 19), # #9a5b13
'badge-risk': self.hwp.RGBColor(161, 43, 43), # #a12b2b
'white': self.hwp.RGBColor(255, 255, 255),
'black': self.hwp.RGBColor(0, 0, 0),
}
def _mm(self, mm):
"""밀리미터를 HWP 단위로 변환"""
return self.hwp.MiliToHwpUnit(mm)
def _pt(self, pt):
"""포인트를 HWP 단위로 변환"""
return self.hwp.PointToHwpUnit(pt)
def _rgb(self, hex_color):
"""HEX 색상을 RGB로 변환"""
c = hex_color.lstrip('#')
return self.hwp.RGBColor(int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)) if len(c) >= 6 else self.hwp.RGBColor(0, 0, 0)
def _font(self, size=10, color='black', bold=False):
"""폰트 설정 (색상 이름 사용)"""
self.hwp.set_font(
FaceName='맑은 고딕',
Height=size,
Bold=bold,
TextColor=self.colors.get(color, self.colors['black'])
)
def _set_font(self, size=11, bold=False, hex_color='#000000'):
"""폰트 설정 (HEX 색상 사용)"""
self.hwp.set_font(
FaceName='맑은 고딕',
Height=size,
Bold=bold,
TextColor=self._rgb(hex_color)
)
def _align(self, align):
"""정렬 설정"""
actions = {
'left': 'ParagraphShapeAlignLeft',
'center': 'ParagraphShapeAlignCenter',
'right': 'ParagraphShapeAlignRight',
'justify': 'ParagraphShapeAlignJustify',
}
if align in actions:
self.hwp.HAction.Run(actions[align])
def _para(self, text='', size=10, color='black', bold=False, align='left'):
"""문단 삽입"""
self._align(align)
self._font(size, color, bold)
if text:
self.hwp.insert_text(text)
self.hwp.BreakPara()
def _exit_table(self):
"""표 편집 모드 종료"""
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
self.hwp.BreakPara()
def _setup_page(self):
"""페이지 설정"""
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
s = self.hwp.HParameterSet.HSecDef
s.PageDef.LeftMargin = self._mm(self.cfg.MARGIN_LEFT)
s.PageDef.RightMargin = self._mm(self.cfg.MARGIN_RIGHT)
s.PageDef.TopMargin = self._mm(self.cfg.MARGIN_TOP)
s.PageDef.BottomMargin = self._mm(self.cfg.MARGIN_BOTTOM)
s.PageDef.HeaderLen = self._mm(self.cfg.HEADER_LEN)
s.PageDef.FooterLen = self._mm(self.cfg.FOOTER_LEN)
self.hwp.HAction.Execute("PageSetup", s.HSet)
print(f"[설정] 여백: 좌우 {self.cfg.MARGIN_LEFT}mm, 상 {self.cfg.MARGIN_TOP}mm, 하 {self.cfg.MARGIN_BOTTOM}mm")
except Exception as e:
print(f"[경고] 페이지 설정 실패: {e}")
# ─────────────────────────────────────────────────────────
# 머리말 / 꼬리말 (보고서 방식)
# ─────────────────────────────────────────────────────────
def _create_header(self, right_text=""):
"""머리말 생성 (우측 정렬)"""
print(f" → 머리말 생성: {right_text if right_text else '(초기화)'}")
try:
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#4a5568')
if right_text:
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 머리말: {e}")
def _create_footer(self, left_text=""):
"""꼬리말 생성 (좌측 텍스트 + 우측 페이지 번호)"""
print(f" → 꼬리말: {left_text}")
# 1. 꼬리말 열기
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
# 2. 좌측 정렬 + 제목 8pt
self.hwp.HAction.Run("ParagraphShapeAlignLeft")
self._set_font(8, False, '#4a5568')
self.hwp.insert_text(left_text)
# 3. 꼬리말 닫기
self.hwp.HAction.Run("CloseEx")
# 4. 쪽번호 (우측 하단)
self.hwp.HAction.GetDefault("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
self.hwp.HParameterSet.HPageNumPos.DrawPos = self.hwp.PageNumPosition("BottomRight")
self.hwp.HAction.Execute("PageNumPos", self.hwp.HParameterSet.HPageNumPos.HSet)
def _new_section_with_header(self, header_text):
"""새 구역 생성 후 머리말 설정"""
print(f" → 새 구역 머리말: {header_text}")
try:
self.hwp.HAction.Run("BreakSection")
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HAction.Run("SelectAll")
self.hwp.HAction.Run("Delete")
self.hwp.HAction.Run("ParagraphShapeAlignRight")
self._set_font(9, False, '#4a5568')
self.hwp.insert_text(header_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f" [경고] 구역 머리말: {e}")
# ─────────────────────────────────────────────────────────
# 셀 배경색 설정
# ─────────────────────────────────────────────────────────
def _set_cell_bg(self, color_name):
"""셀 배경색 설정 (색상 이름)"""
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
pset = self.hwp.HParameterSet.HCellBorderFill
pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0)
pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white'])
pset.FillAttr.WindowsBrush = 1
self.hwp.HAction.Execute("CellBorderFill", pset.HSet)
# ─────────────────────────────────────────────────────────
# HTML 요소 변환 (기획서 전용)
# ─────────────────────────────────────────────────────────
def _convert_lead_box(self, elem):
"""lead-box 변환 (핵심 기조 박스)"""
content = elem.find("div")
if not content:
return
text = content.get_text(strip=True)
text = ' '.join(text.split())
print(f" → lead-box")
self.hwp.create_table(1, 1, treat_as_char=True)
self._set_cell_bg('bg-light')
self._font(11.5, 'dark-gray', False)
self.hwp.insert_text(text)
self._exit_table()
def _convert_strategy_grid(self, elem):
"""strategy-grid 변환 (2x2 전략 박스)"""
items = elem.find_all(class_="strategy-item")
if not items:
return
print(f" → strategy-grid: {len(items)} items")
self.hwp.create_table(2, 2, treat_as_char=True)
for i, item in enumerate(items[:4]):
if i > 0:
self.hwp.HAction.Run("MoveRight")
self._set_cell_bg('bg-light')
title = item.find(class_="strategy-title")
if title:
self._font(10, 'primary-navy', True)
self.hwp.insert_text(title.get_text(strip=True))
self.hwp.BreakPara()
p = item.find("p")
if p:
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(p.get_text(strip=True))
self._exit_table()
def _convert_process_container(self, elem):
"""process-container 변환 (단계별 프로세스)"""
steps = elem.find_all(class_="process-step")
if not steps:
return
print(f" → process-container: {len(steps)} steps")
rows = len(steps)
self.hwp.create_table(rows, 2, treat_as_char=True)
for i, step in enumerate(steps):
if i > 0:
self.hwp.HAction.Run("MoveRight")
# 번호 셀
num = step.find(class_="step-num")
self._set_cell_bg('primary-navy')
self._font(10, 'white', True)
self._align('center')
if num:
self.hwp.insert_text(num.get_text(strip=True))
self.hwp.HAction.Run("MoveRight")
# 내용 셀
content = step.find(class_="step-content")
self._set_cell_bg('bg-light')
self._font(10.5, 'dark-gray', False)
self._align('left')
if content:
self.hwp.insert_text(content.get_text(strip=True))
self._exit_table()
def _convert_data_table(self, table):
"""data-table 변환 (badge 포함)"""
data = []
thead = table.find("thead")
if thead:
ths = thead.find_all("th")
data.append([th.get_text(strip=True) for th in ths])
tbody = table.find("tbody")
if tbody:
for tr in tbody.find_all("tr"):
row = []
for td in tr.find_all("td"):
badge = td.find(class_="badge")
if badge:
badge_class = ' '.join(badge.get('class', []))
badge_text = badge.get_text(strip=True)
if 'badge-safe' in badge_class:
row.append(f"[✓ {badge_text}]")
elif 'badge-caution' in badge_class:
row.append(f"[△ {badge_text}]")
elif 'badge-risk' in badge_class:
row.append(f"[✗ {badge_text}]")
else:
row.append(f"[{badge_text}]")
else:
row.append(td.get_text(strip=True))
data.append(row)
if not data:
return
rows = len(data)
cols = len(data[0]) if data else 0
print(f" → data-table: {rows}×{cols}")
self.hwp.create_table(rows, cols, treat_as_char=True)
for row_idx, row in enumerate(data):
for col_idx, cell_text in enumerate(row):
is_header = (row_idx == 0)
is_first_col = (col_idx == 0 and not is_header)
is_safe = '[✓' in str(cell_text)
is_caution = '[△' in str(cell_text)
is_risk = '[✗' in str(cell_text)
if is_header:
self._set_cell_bg('primary-navy')
self._font(9, 'white', True)
elif is_first_col:
self._set_cell_bg('bg-light')
self._font(9.5, 'primary-navy', True)
elif is_safe:
self._font(9.5, 'badge-safe', True)
elif is_caution:
self._font(9.5, 'badge-caution', True)
elif is_risk:
self._font(9.5, 'badge-risk', True)
else:
self._font(9.5, 'dark-gray', False)
self._align('center')
self.hwp.insert_text(str(cell_text))
if not (row_idx == rows - 1 and col_idx == cols - 1):
self.hwp.HAction.Run("MoveRight")
self._exit_table()
def _convert_qa_grid(self, elem):
"""qa-grid 변환 (Q&A 2단 박스)"""
items = elem.find_all(class_="qa-item")
if not items:
return
print(f" → qa-grid: {len(items)} items")
self.hwp.create_table(1, 2, treat_as_char=True)
for i, item in enumerate(items[:2]):
if i > 0:
self.hwp.HAction.Run("MoveRight")
self._set_cell_bg('bg-light')
text = item.get_text(strip=True)
strong = item.find("strong")
if strong:
q_text = strong.get_text(strip=True)
a_text = text.replace(q_text, '').strip()
self._font(9.5, 'primary-navy', True)
self.hwp.insert_text(q_text)
self.hwp.BreakPara()
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(a_text)
else:
self._font(9.5, 'dark-gray', False)
self.hwp.insert_text(text)
self._exit_table()
def _convert_bottom_box(self, elem):
"""bottom-box 변환 (핵심 결론 박스)"""
left = elem.find(class_="bottom-left")
right = elem.find(class_="bottom-right")
if not left or not right:
return
left_text = ' '.join(left.get_text().split())
right_text = right.get_text(strip=True)
print(f" → bottom-box")
self.hwp.create_table(1, 2, treat_as_char=True)
# 좌측 (Navy 배경)
self._set_cell_bg('primary-navy')
self._font(10.5, 'white', True)
self._align('center')
self.hwp.insert_text(left_text)
self.hwp.HAction.Run("MoveRight")
# 우측 (연한 배경)
self._set_cell_bg('bg-light')
self._font(10.5, 'primary-navy', True)
self._align('center')
self.hwp.insert_text(right_text)
self._exit_table()
def _convert_section(self, section):
"""section 변환"""
title = section.find(class_="section-title")
if title:
self._para("" + title.get_text(strip=True), 12, 'primary-navy', True)
strategy_grid = section.find(class_="strategy-grid")
if strategy_grid:
self._convert_strategy_grid(strategy_grid)
process = section.find(class_="process-container")
if process:
self._convert_process_container(process)
table = section.find("table", class_="data-table")
if table:
self._convert_data_table(table)
ul = section.find("ul")
if ul:
for li in ul.find_all("li", recursive=False):
keyword = li.find(class_="keyword")
if keyword:
kw_text = keyword.get_text(strip=True)
full = li.get_text(strip=True)
rest = full.replace(kw_text, '', 1).strip()
self._font(10.5, 'primary-navy', True)
self.hwp.insert_text("" + kw_text + " ")
self._font(10.5, 'dark-gray', False)
self.hwp.insert_text(rest)
self.hwp.BreakPara()
else:
self._para("" + li.get_text(strip=True), 10.5, 'dark-gray')
qa_grid = section.find(class_="qa-grid")
if qa_grid:
self._convert_qa_grid(qa_grid)
self._para()
def _convert_sheet(self, sheet, is_first_page=False, footer_title=""):
"""한 페이지(sheet) 변환"""
# 첫 페이지에서만 머리말/꼬리말 설정
if is_first_page:
# 머리말: page-header에서 텍스트 추출
header = sheet.find(class_="page-header")
if header:
left = header.find(class_="header-left")
right = header.find(class_="header-right")
# 우측 텍스트 사용 (부서명 등)
header_text = right.get_text(strip=True) if right else ""
if header_text:
self._create_header(header_text)
# 꼬리말: 제목 + 페이지번호
self._create_footer(footer_title)
# 대제목
title = sheet.find(class_="header-title")
if title:
title_text = title.get_text(strip=True)
if '[첨부]' in title_text:
self._para(title_text, 15, 'primary-navy', True, 'left')
self._font(10, 'secondary-navy', False)
self._align('left')
self.hwp.insert_text("" * 60)
self.hwp.BreakPara()
else:
self._para(title_text, 23, 'primary-navy', True, 'center')
self._font(10, 'secondary-navy', False)
self._align('center')
self.hwp.insert_text("" * 45)
self.hwp.BreakPara()
self._para()
# 리드 박스
lead_box = sheet.find(class_="lead-box")
if lead_box:
self._convert_lead_box(lead_box)
self._para()
# 섹션들
for section in sheet.find_all(class_="section"):
self._convert_section(section)
# 하단 박스
bottom_box = sheet.find(class_="bottom-box")
if bottom_box:
self._para()
self._convert_bottom_box(bottom_box)
# ─────────────────────────────────────────────────────────
# 메인 변환 함수
# ─────────────────────────────────────────────────────────
def convert(self, html_path, output_path):
"""HTML → HWP 변환 실행"""
print("=" * 60)
print("HTML → HWP 변환기 (기획서 전용)")
print(" ✓ 머리말/꼬리말: 보고서 방식")
print(" ✓ Navy 테마, 기획서 요소")
print("=" * 60)
print(f"\n[입력] {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
# 제목 추출 (꼬리말용)
title_tag = soup.find('title')
if title_tag:
full_title = title_tag.get_text(strip=True)
footer_title = full_title.split(':')[0].strip()
else:
footer_title = ""
self.hwp.FileNew()
self._init_colors()
self._setup_page()
# 페이지별 변환
sheets = soup.find_all(class_="sheet")
total = len(sheets)
print(f"[변환] 총 {total} 페이지\n")
for i, sheet in enumerate(sheets, 1):
print(f"[{i}/{total}] 페이지 처리 중...")
self._convert_sheet(sheet, is_first_page=(i == 1), footer_title=footer_title)
if i < total:
self.hwp.HAction.Run("BreakPage")
# 저장
self.hwp.SaveAs(output_path)
print(f"\n✅ 저장 완료: {output_path}")
def close(self):
"""HWP 종료"""
try:
self.hwp.Quit()
except:
pass
def main():
"""메인 실행"""
html_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.html"
output_path = r"D:\for python\geulbeot-light\geulbeot-light\output\briefing.hwp"
print("=" * 60)
print("HTML → HWP 변환기 (기획서)")
print("=" * 60)
print()
try:
converter = HtmlToHwpConverter(visible=True)
converter.convert(html_path, output_path)
print("\n" + "=" * 60)
print("✅ 변환 완료!")
print("=" * 60)
input("\nEnter를 누르면 HWP가 닫힙니다...")
converter.close()
except FileNotFoundError:
print(f"\n[에러] 파일을 찾을 수 없습니다: {html_path}")
print("경로를 확인해주세요.")
except Exception as e:
print(f"\n[에러] {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,434 @@
# -*- coding: utf-8 -*-
"""
HWP 스타일 매핑 모듈 v2.0
HTML 역할(Role) → HWP 스타일 매핑
✅ v2.0 변경사항:
- pyhwpx API에 맞게 apply_to_hwp() 재작성
- CharShape/ParaShape 직접 설정 방식
- 역할 → 개요 스타일 매핑
"""
from dataclasses import dataclass
from typing import Dict, Optional
from enum import Enum
class HwpStyleType(Enum):
"""HWP 스타일 유형"""
PARAGRAPH = "paragraph"
CHARACTER = "character"
@dataclass
class HwpStyle:
"""HWP 스타일 정의"""
id: int
name: str
type: HwpStyleType
font_size: float
font_bold: bool = False
font_color: str = "000000"
align: str = "justify"
line_spacing: float = 160
space_before: float = 0
space_after: float = 0
indent_left: float = 0
indent_first: float = 0
bg_color: Optional[str] = None
# =============================================================================
# 기본 스타일 템플릿
# =============================================================================
DEFAULT_STYLES: Dict[str, HwpStyle] = {
# 표지
"COVER_TITLE": HwpStyle(
id=100, name="표지제목", type=HwpStyleType.PARAGRAPH,
font_size=32, font_bold=True, align="center",
space_before=20, space_after=10, font_color="1a365d"
),
"COVER_SUBTITLE": HwpStyle(
id=101, name="표지부제", type=HwpStyleType.PARAGRAPH,
font_size=18, font_bold=False, align="center",
font_color="555555"
),
"COVER_INFO": HwpStyle(
id=102, name="표지정보", type=HwpStyleType.PARAGRAPH,
font_size=12, align="center", font_color="666666"
),
# 목차
"TOC_H1": HwpStyle(
id=110, name="목차1수준", type=HwpStyleType.PARAGRAPH,
font_size=12, font_bold=True, indent_left=0
),
"TOC_H2": HwpStyle(
id=111, name="목차2수준", type=HwpStyleType.PARAGRAPH,
font_size=11, indent_left=20
),
"TOC_H3": HwpStyle(
id=112, name="목차3수준", type=HwpStyleType.PARAGRAPH,
font_size=10, indent_left=40, font_color="666666"
),
# 제목 계층 (개요 1~7 매핑)
"H1": HwpStyle(
id=1, name="개요 1", type=HwpStyleType.PARAGRAPH,
font_size=20, font_bold=True, align="left",
space_before=30, space_after=15, font_color="1a365d"
),
"H2": HwpStyle(
id=2, name="개요 2", type=HwpStyleType.PARAGRAPH,
font_size=16, font_bold=True, align="left",
space_before=20, space_after=10, font_color="2c5282"
),
"H3": HwpStyle(
id=3, name="개요 3", type=HwpStyleType.PARAGRAPH,
font_size=14, font_bold=True, align="left",
space_before=15, space_after=8, font_color="2b6cb0"
),
"H4": HwpStyle(
id=4, name="개요 4", type=HwpStyleType.PARAGRAPH,
font_size=12, font_bold=True, align="left",
space_before=10, space_after=5, indent_left=10
),
"H5": HwpStyle(
id=5, name="개요 5", type=HwpStyleType.PARAGRAPH,
font_size=11, font_bold=True, align="left",
space_before=8, space_after=4, indent_left=20
),
"H6": HwpStyle(
id=6, name="개요 6", type=HwpStyleType.PARAGRAPH,
font_size=11, font_bold=False, align="left",
indent_left=30
),
"H7": HwpStyle(
id=7, name="개요 7", type=HwpStyleType.PARAGRAPH,
font_size=10.5, font_bold=False, align="left",
indent_left=40
),
# 본문
"BODY": HwpStyle(
id=20, name="바탕글", type=HwpStyleType.PARAGRAPH,
font_size=11, align="justify",
line_spacing=180, indent_first=10
),
"LIST_ITEM": HwpStyle(
id=8, name="개요 8", type=HwpStyleType.PARAGRAPH,
font_size=11, align="left",
indent_left=15, line_spacing=160
),
"HIGHLIGHT_BOX": HwpStyle(
id=21, name="강조박스", type=HwpStyleType.PARAGRAPH,
font_size=10.5, align="left",
bg_color="f7fafc", indent_left=10, indent_first=0
),
# 표
"TABLE": HwpStyle(
id=30, name="", type=HwpStyleType.PARAGRAPH,
font_size=10, align="center"
),
"TH": HwpStyle(
id=11, name="표제목", type=HwpStyleType.PARAGRAPH,
font_size=10, font_bold=True, align="center",
bg_color="e2e8f0"
),
"TD": HwpStyle(
id=31, name="표내용", type=HwpStyleType.PARAGRAPH,
font_size=10, align="left"
),
"TABLE_CAPTION": HwpStyle(
id=19, name="표캡션", type=HwpStyleType.PARAGRAPH,
font_size=10, font_bold=True, align="center",
space_before=5, space_after=3
),
# 그림
"FIGURE": HwpStyle(
id=32, name="그림", type=HwpStyleType.PARAGRAPH,
font_size=10, align="center"
),
"FIGURE_CAPTION": HwpStyle(
id=18, name="그림캡션", type=HwpStyleType.PARAGRAPH,
font_size=9.5, align="center",
font_color="666666", space_before=5
),
# 기타
"UNKNOWN": HwpStyle(
id=0, name="바탕글", type=HwpStyleType.PARAGRAPH,
font_size=10, align="left"
),
}
# 역할 → 개요 번호 매핑 (StyleShortcut 용)
ROLE_TO_OUTLINE_NUM = {
"H1": 1,
"H2": 2,
"H3": 3,
"H4": 4,
"H5": 5,
"H6": 6,
"H7": 7,
"LIST_ITEM": 8,
"BODY": 0, # 바탕글
"COVER_TITLE": 0,
"COVER_SUBTITLE": 0,
"COVER_INFO": 0,
}
# 역할 → HWP 스타일 이름 매핑
ROLE_TO_STYLE_NAME = {
"H1": "개요 1",
"H2": "개요 2",
"H3": "개요 3",
"H4": "개요 4",
"H5": "개요 5",
"H6": "개요 6",
"H7": "개요 7",
"LIST_ITEM": "개요 8",
"BODY": "바탕글",
"COVER_TITLE": "표지제목",
"COVER_SUBTITLE": "표지부제",
"TH": "표제목",
"TD": "표내용",
"TABLE_CAPTION": "표캡션",
"FIGURE_CAPTION": "그림캡션",
"UNKNOWN": "바탕글",
}
class HwpStyleMapper:
"""HTML 역할 → HWP 스타일 매퍼"""
def __init__(self, custom_styles: Optional[Dict[str, HwpStyle]] = None):
self.styles = DEFAULT_STYLES.copy()
if custom_styles:
self.styles.update(custom_styles)
def get_style(self, role: str) -> HwpStyle:
return self.styles.get(role, self.styles["UNKNOWN"])
def get_style_id(self, role: str) -> int:
return self.get_style(role).id
def get_all_styles(self) -> Dict[str, HwpStyle]:
return self.styles
class HwpStyGenerator:
"""
HTML 스타일 → HWP 스타일 적용기
pyhwpx API를 사용하여:
1. 역할별 스타일 정보 저장
2. 텍스트 삽입 시 CharShape/ParaShape 직접 적용
3. 개요 스타일 번호 매핑 반환
"""
def __init__(self):
self.styles: Dict[str, HwpStyle] = {}
self.hwp = None
def update_from_html(self, html_styles: Dict[str, Dict]):
"""HTML에서 추출한 스타일로 업데이트"""
for role, style_dict in html_styles.items():
if role in DEFAULT_STYLES:
base = DEFAULT_STYLES[role]
# color 처리 - # 제거
color = style_dict.get('color', base.font_color)
if isinstance(color, str):
color = color.lstrip('#')
self.styles[role] = HwpStyle(
id=base.id,
name=base.name,
type=base.type,
font_size=style_dict.get('font_size', base.font_size),
font_bold=style_dict.get('bold', base.font_bold),
font_color=color,
align=style_dict.get('align', base.align),
line_spacing=style_dict.get('line_spacing', base.line_spacing),
space_before=style_dict.get('space_before', base.space_before),
space_after=style_dict.get('space_after', base.space_after),
indent_left=style_dict.get('indent_left', base.indent_left),
indent_first=style_dict.get('indent_first', base.indent_first),
bg_color=style_dict.get('bg_color', base.bg_color),
)
else:
# 기본 스타일 사용
self.styles[role] = DEFAULT_STYLES.get('UNKNOWN')
# 누락된 역할은 기본값으로 채움
for role in DEFAULT_STYLES:
if role not in self.styles:
self.styles[role] = DEFAULT_STYLES[role]
def apply_to_hwp(self, hwp) -> Dict[str, HwpStyle]:
"""역할 → HwpStyle 매핑 반환"""
self.hwp = hwp
# 🚫 스타일 생성 비활성화 (API 문제)
# for role, style in self.styles.items():
# self._create_or_update_style(hwp, role, style)
if not self.styles:
self.styles = DEFAULT_STYLES.copy()
print(f" ✅ 스타일 매핑 완료: {len(self.styles)}")
return self.styles
def _create_or_update_style(self, hwp, role: str, style: HwpStyle):
"""HWP에 스타일 생성 또는 수정"""
try:
# 1. 스타일 편집 모드
hwp.HAction.GetDefault("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
hwp.HParameterSet.HStyle.StyleName = style.name
# 2. 글자 모양
color_hex = style.font_color.lstrip('#')
if len(color_hex) == 6:
r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16)
text_color = hwp.RGBColor(r, g, b)
else:
text_color = hwp.RGBColor(0, 0, 0)
hwp.HParameterSet.HStyle.CharShape.Height = hwp.PointToHwpUnit(style.font_size)
hwp.HParameterSet.HStyle.CharShape.Bold = style.font_bold
hwp.HParameterSet.HStyle.CharShape.TextColor = text_color
# 3. 문단 모양
align_map = {'left': 0, 'center': 1, 'right': 2, 'justify': 3}
hwp.HParameterSet.HStyle.ParaShape.Align = align_map.get(style.align, 3)
hwp.HParameterSet.HStyle.ParaShape.LineSpacing = int(style.line_spacing)
hwp.HParameterSet.HStyle.ParaShape.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
hwp.HParameterSet.HStyle.ParaShape.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
# 4. 실행
hwp.HAction.Execute("ModifyStyle", hwp.HParameterSet.HStyle.HSet)
print(f" ✓ 스타일 '{style.name}' 정의됨")
except Exception as e:
print(f" [경고] 스타일 '{style.name}' 생성 실패: {e}")
def get_style(self, role: str) -> HwpStyle:
"""역할에 해당하는 스타일 반환"""
return self.styles.get(role, DEFAULT_STYLES.get('UNKNOWN'))
def apply_char_shape(self, hwp, role: str):
"""현재 선택 영역에 글자 모양 적용"""
style = self.get_style(role)
try:
# RGB 색상 변환
color_hex = style.font_color.lstrip('#') if style.font_color else '000000'
if len(color_hex) == 6:
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
text_color = hwp.RGBColor(r, g, b)
else:
text_color = hwp.RGBColor(0, 0, 0)
# 글자 모양 설정
hwp.HAction.GetDefault("CharShape", hwp.HParameterSet.HCharShape.HSet)
hwp.HParameterSet.HCharShape.Height = hwp.PointToHwpUnit(style.font_size)
hwp.HParameterSet.HCharShape.Bold = style.font_bold
hwp.HParameterSet.HCharShape.TextColor = text_color
hwp.HAction.Execute("CharShape", hwp.HParameterSet.HCharShape.HSet)
except Exception as e:
print(f" [경고] 글자 모양 적용 실패 ({role}): {e}")
def apply_para_shape(self, hwp, role: str):
"""현재 문단에 문단 모양 적용"""
style = self.get_style(role)
try:
# 정렬
align_actions = {
'left': "ParagraphShapeAlignLeft",
'center': "ParagraphShapeAlignCenter",
'right': "ParagraphShapeAlignRight",
'justify': "ParagraphShapeAlignJustify"
}
if style.align in align_actions:
hwp.HAction.Run(align_actions[style.align])
# 문단 모양 상세 설정
hwp.HAction.GetDefault("ParagraphShape", hwp.HParameterSet.HParaShape.HSet)
p = hwp.HParameterSet.HParaShape
p.LineSpaceType = 0 # 퍼센트
p.LineSpacing = int(style.line_spacing)
p.LeftMargin = hwp.MiliToHwpUnit(style.indent_left)
p.IndentMargin = hwp.MiliToHwpUnit(style.indent_first)
p.SpaceBeforePara = hwp.PointToHwpUnit(style.space_before)
p.SpaceAfterPara = hwp.PointToHwpUnit(style.space_after)
hwp.HAction.Execute("ParagraphShape", p.HSet)
except Exception as e:
print(f" [경고] 문단 모양 적용 실패 ({role}): {e}")
def apply_style(self, hwp, role: str):
"""역할에 맞는 전체 스타일 적용 (글자 + 문단)"""
self.apply_char_shape(hwp, role)
self.apply_para_shape(hwp, role)
def export_sty(self, hwp, output_path: str) -> bool:
"""스타일 파일 내보내기 (현재 미지원)"""
print(f" [알림] .sty 내보내기는 현재 미지원")
return False
# =============================================================================
# 번호 제거 유틸리티
# =============================================================================
import re
NUMBERING_PATTERNS = {
'H1': re.compile(r'^(\d+)\.\s*'), # "1. " → ""
'H2': re.compile(r'^(\d+)\.(\d+)\s*'), # "1.1 " → ""
'H3': re.compile(r'^(\d+)\.(\d+)\.(\d+)\s*'), # "1.1.1 " → ""
'H4': re.compile(r'^[가-하]\.\s*'), # "가. " → ""
'H5': re.compile(r'^(\d+)\)\s*'), # "1) " → ""
'H6': re.compile(r'^\((\d+)\)\s*'), # "(1) " → ""
'H7': re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]\s*'), # "① " → ""
'LIST_ITEM': re.compile(r'^[•\-○]\s*'), # "• " → ""
}
def strip_numbering(text: str, role: str) -> str:
"""
역할에 따라 텍스트 앞의 번호/기호 제거
HWP 개요 기능이 번호를 자동 생성하므로 중복 방지
"""
if not text:
return text
pattern = NUMBERING_PATTERNS.get(role)
if pattern:
return pattern.sub('', text).strip()
return text.strip()
if __name__ == "__main__":
# 테스트
print("=== 스타일 매핑 테스트 ===")
gen = HwpStyGenerator()
# HTML 스타일 시뮬레이션
html_styles = {
'H1': {'font_size': 20, 'color': '#1a365d', 'bold': True},
'H2': {'font_size': 16, 'color': '#2c5282', 'bold': True},
'BODY': {'font_size': 11, 'align': 'justify'},
}
gen.update_from_html(html_styles)
for role, style in gen.styles.items():
print(f"{role:15} → size={style.font_size}pt, bold={style.font_bold}, color=#{style.font_color}")

View File

@@ -0,0 +1,431 @@
"""
HWPX 파일 생성기
StyleAnalyzer 결과를 받아 스타일이 적용된 HWPX 파일 생성
"""
import os
import zipfile
import xml.etree.ElementTree as ET
from typing import List, Dict, Optional
from dataclasses import dataclass
from pathlib import Path
from style_analyzer import StyleAnalyzer, StyledElement
from hwp_style_mapping import HwpStyleMapper, HwpStyle, ROLE_TO_STYLE_NAME
@dataclass
class HwpxConfig:
"""HWPX 생성 설정"""
paper_width: int = 59528 # A4 너비 (hwpunit, 1/7200 inch)
paper_height: int = 84188 # A4 높이
margin_left: int = 8504
margin_right: int = 8504
margin_top: int = 5668
margin_bottom: int = 4252
default_font: str = "함초롬바탕"
default_font_size: int = 1000 # 10pt (hwpunit)
class HwpxGenerator:
"""HWPX 파일 생성기"""
def __init__(self, config: Optional[HwpxConfig] = None):
self.config = config or HwpxConfig()
self.mapper = HwpStyleMapper()
self.used_styles: set = set()
def generate(self, elements: List[StyledElement], output_path: str) -> str:
"""
StyledElement 리스트로부터 HWPX 파일 생성
Args:
elements: StyleAnalyzer로 분류된 요소 리스트
output_path: 출력 파일 경로 (.hwpx)
Returns:
생성된 파일 경로
"""
# 사용된 스타일 수집
self.used_styles = {e.role for e in elements}
# 임시 디렉토리 생성
temp_dir = Path(output_path).with_suffix('.temp')
temp_dir.mkdir(parents=True, exist_ok=True)
try:
# HWPX 구조 생성
self._create_mimetype(temp_dir)
self._create_meta_inf(temp_dir)
self._create_version(temp_dir)
self._create_header(temp_dir)
self._create_content(temp_dir, elements)
self._create_settings(temp_dir)
# ZIP으로 압축
self._create_hwpx(temp_dir, output_path)
return output_path
finally:
# 임시 파일 정리
import shutil
if temp_dir.exists():
shutil.rmtree(temp_dir)
def _create_mimetype(self, temp_dir: Path):
"""mimetype 파일 생성"""
mimetype_path = temp_dir / "mimetype"
mimetype_path.write_text("application/hwp+zip")
def _create_meta_inf(self, temp_dir: Path):
"""META-INF/manifest.xml 생성"""
meta_dir = temp_dir / "META-INF"
meta_dir.mkdir(exist_ok=True)
manifest = """<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/hwp+zip"/>
<manifest:file-entry manifest:full-path="version.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/header.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="Contents/section0.xml" manifest:media-type="application/xml"/>
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="application/xml"/>
</manifest:manifest>"""
(meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8')
def _create_version(self, temp_dir: Path):
"""version.xml 생성"""
version = """<?xml version="1.0" encoding="UTF-8"?>
<hh:HWPMLVersion xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" version="1.1"/>"""
(temp_dir / "version.xml").write_text(version, encoding='utf-8')
def _create_header(self, temp_dir: Path):
"""Contents/header.xml 생성 (스타일 정의 포함)"""
contents_dir = temp_dir / "Contents"
contents_dir.mkdir(exist_ok=True)
# 스타일별 속성 생성
char_props_xml = self._generate_char_properties()
para_props_xml = self._generate_para_properties()
styles_xml = self._generate_styles_xml()
header = f"""<?xml version="1.0" encoding="UTF-8"?>
<hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head"
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core"
xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
version="1.5" secCnt="1">
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
<hh:refList>
<hh:fontfaces itemCnt="7">
<hh:fontface lang="HANGUL" fontCnt="2">
<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="2">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="HANJA" fontCnt="2">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
<hh:font id="1" face="함초롬돋움" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="JAPANESE" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="OTHER" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="SYMBOL" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
<hh:fontface lang="USER" fontCnt="1">
<hh:font id="0" face="맑은 고딕" type="TTF" isEmbedded="0"/>
</hh:fontface>
</hh:fontfaces>
<hh:borderFills itemCnt="2">
<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE">
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
</hh:borderFill>
<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE">
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
<hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:topBorder type="NONE" width="0.1 mm" color="#000000"/>
<hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/>
<hc:fillBrush><hc:winBrush faceColor="none" hatchColor="#000000" alpha="0"/></hc:fillBrush>
</hh:borderFill>
</hh:borderFills>
{char_props_xml}
{para_props_xml}
{styles_xml}
</hh:refList>
<hh:compatibleDocument targetProgram="HWP201X"/>
<hh:docOption>
<hh:linkinfo path="" pageInherit="1" footnoteInherit="0"/>
</hh:docOption>
</hh:head>"""
(contents_dir / "header.xml").write_text(header, encoding='utf-8')
def _generate_char_properties(self) -> str:
"""글자 속성 XML 생성"""
lines = [f' <hh:charProperties itemCnt="{len(self.used_styles) + 1}">']
# 기본 글자 속성 (id=0)
lines.append(''' <hh:charPr id="0" height="1000" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
<hh:strikeout shape="NONE" color="#000000"/>
<hh:outline type="NONE"/>
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
</hh:charPr>''')
# 역할별 글자 속성
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
height = int(style.font_size * 100) # pt → hwpunit
color = style.font_color.lstrip('#')
font_id = "1" if style.font_bold else "0" # 굵게면 함초롬돋움
lines.append(f''' <hh:charPr id="{idx}" height="{height}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
<hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/>
<hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/>
<hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>
<hh:underline type="NONE" shape="SOLID" color="#000000"/>
<hh:strikeout shape="NONE" color="#000000"/>
<hh:outline type="NONE"/>
<hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/>
</hh:charPr>''')
lines.append(' </hh:charProperties>')
return '\n'.join(lines)
def _generate_para_properties(self) -> str:
"""문단 속성 XML 생성"""
lines = [f' <hh:paraProperties itemCnt="{len(self.used_styles) + 1}">']
# 기본 문단 속성 (id=0)
lines.append(''' <hh:paraPr id="0" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="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"/>
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
<hh:margin><hc:intent value="0" 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="160" unit="HWPUNIT"/>
</hp:case>
<hp:default>
<hh:margin><hc:intent value="0" 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="160" unit="HWPUNIT"/>
</hp:default>
</hp:switch>
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
</hh:paraPr>''')
# 역할별 문단 속성
align_map = {"left": "LEFT", "center": "CENTER", "right": "RIGHT", "justify": "JUSTIFY"}
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
align_val = align_map.get(style.align, "JUSTIFY")
line_spacing = int(style.line_spacing)
left_margin = int(style.indent_left * 100)
indent = int(style.indent_first * 100)
space_before = int(style.space_before * 100)
space_after = int(style.space_after * 100)
lines.append(f''' <hh:paraPr id="{idx}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0">
<hh:align horizontal="{align_val}" 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"/>
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
<hp:switch xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:case hp:required-namespace="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar">
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
</hp:case>
<hp:default>
<hh:margin><hc:intent value="{indent}" unit="HWPUNIT"/><hc:left value="{left_margin}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{space_before}" unit="HWPUNIT"/><hc:next value="{space_after}" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="{line_spacing}" unit="HWPUNIT"/>
</hp:default>
</hp:switch>
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
</hh:paraPr>''')
lines.append(' </hh:paraProperties>')
return '\n'.join(lines)
def _generate_styles_xml(self) -> str:
"""스타일 정의 XML 생성 (charPrIDRef, paraPrIDRef 참조)"""
lines = [f' <hh:styles itemCnt="{len(self.used_styles) + 1}">']
# 기본 스타일 (id=0, 바탕글)
lines.append(' <hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="0" charPrIDRef="0" nextStyleIDRef="0" langID="1042" lockForm="0"/>')
# 역할별 스타일 (charPrIDRef, paraPrIDRef 참조)
for idx, role in enumerate(sorted(self.used_styles), start=1):
style = self.mapper.get_style(role)
style_name = style.name.replace('<', '&lt;').replace('>', '&gt;')
lines.append(f' <hh:style id="{idx}" type="PARA" name="{style_name}" engName="" paraPrIDRef="{idx}" charPrIDRef="{idx}" nextStyleIDRef="{idx}" langID="1042" lockForm="0"/>')
lines.append(' </hh:styles>')
return '\n'.join(lines)
def _create_content(self, temp_dir: Path, elements: List[StyledElement]):
"""Contents/section0.xml 생성 (본문 + 스타일 참조)"""
contents_dir = temp_dir / "Contents"
# 문단 XML 생성
paragraphs = []
current_table = None
# 역할 → 스타일 인덱스 매핑 생성
role_to_idx = {role: idx for idx, role in enumerate(sorted(self.used_styles), start=1)}
for elem in elements:
style = self.mapper.get_style(elem.role)
style_idx = role_to_idx.get(elem.role, 0)
# 테이블 요소는 특수 처리
if elem.role in ["TH", "TD", "TABLE_CAPTION", "TABLE", "FIGURE"]:
continue # 테이블/그림은 별도 처리 필요
# 일반 문단
para_xml = self._create_paragraph(elem.text, style, style_idx)
paragraphs.append(para_xml)
section = f"""<?xml version="1.0" encoding="UTF-8"?>
<hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"
xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
{"".join(paragraphs)}
</hs:sec>"""
(contents_dir / "section0.xml").write_text(section, encoding='utf-8')
def _create_paragraph(self, text: str, style: HwpStyle, style_idx: int) -> str:
"""단일 문단 XML 생성"""
text = self._escape_xml(text)
return f'''
<hp:p xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph"
paraPrIDRef="{style_idx}" styleIDRef="{style_idx}" pageBreak="0" columnBreak="0" merged="0">
<hp:run charPrIDRef="{style_idx}">
<hp:t>{text}</hp:t>
</hp:run>
</hp:p>'''
def _escape_xml(self, text: str) -> str:
"""XML 특수문자 이스케이프"""
return (text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;"))
def _create_settings(self, temp_dir: Path):
"""settings.xml 생성"""
settings = """<?xml version="1.0" encoding="UTF-8"?>
<hs:settings xmlns:hs="http://www.hancom.co.kr/hwpml/2011/settings">
<hs:viewSetting>
<hs:viewType val="printView"/>
<hs:zoom val="100"/>
</hs:viewSetting>
</hs:settings>"""
(temp_dir / "settings.xml").write_text(settings, encoding='utf-8')
def _create_hwpx(self, temp_dir: Path, output_path: str):
"""HWPX 파일 생성 (ZIP 압축)"""
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축하지 않고 첫 번째로
mimetype_path = temp_dir / "mimetype"
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zf.write(file_path, arcname)
def convert_html_to_hwpx(html: str, output_path: str) -> str:
"""
HTML → HWPX 변환 메인 함수
Args:
html: HTML 문자열
output_path: 출력 파일 경로
Returns:
생성된 파일 경로
"""
# 1. HTML 분석 → 역할 분류
analyzer = StyleAnalyzer()
elements = analyzer.analyze(html)
print(f"📊 분석 완료: {len(elements)}개 요소")
for role, count in analyzer.get_role_summary().items():
print(f" {role}: {count}")
# 2. HWPX 생성
generator = HwpxGenerator()
result_path = generator.generate(elements, output_path)
print(f"✅ 생성 완료: {result_path}")
return result_path
if __name__ == "__main__":
# 테스트
test_html = """
<html>
<body>
<div class="box-cover">
<h1>건설·토목 측량 DX 실무지침</h1>
<h2>드론/UAV·GIS·지형/지반 모델 기반</h2>
<p>2024년 1월</p>
</div>
<h1>1. 개요</h1>
<p>본 보고서는 건설 및 토목 분야의 측량 디지털 전환에 대한 실무 지침을 제공합니다.</p>
<h2>1.1 배경</h2>
<p>최근 드론과 GIS 기술의 발전으로 측량 업무가 크게 변화하고 있습니다.</p>
<h3>1.1.1 기술 동향</h3>
<p>1) <strong>드론 측량의 발전</strong></p>
<p>드론을 활용한 측량은 기존 방식 대비 효율성이 크게 향상되었습니다.</p>
<p>(1) <strong>RTK 드론</strong></p>
<p>실시간 보정 기능을 갖춘 RTK 드론이 보급되고 있습니다.</p>
<ul>
<li>고정밀 GPS 수신기 내장</li>
<li>센티미터 단위 정확도</li>
</ul>
</body>
</html>
"""
output = "/home/claude/test_output.hwpx"
convert_html_to_hwpx(test_html, output)

View File

@@ -0,0 +1,750 @@
"""
HWPX 스타일 주입기
pyhwpx로 생성된 HWPX 파일에 커스텀 스타일을 후처리로 주입
워크플로우:
1. HWPX 압축 해제
2. header.xml에 커스텀 스타일 정의 추가
3. section*.xml에서 역할별 styleIDRef 매핑
4. 다시 압축
"""
import os
import re
import zipfile
import shutil
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class StyleDefinition:
"""스타일 정의"""
id: int
name: str
font_size: int # hwpunit (pt * 100)
font_bold: bool
font_color: str # #RRGGBB
align: str # LEFT, CENTER, RIGHT, JUSTIFY
line_spacing: int # percent (160 = 160%)
indent_left: int # hwpunit
indent_first: int # hwpunit
space_before: int # hwpunit
space_after: int # hwpunit
outline_level: int = -1 # 🆕 개요 수준 (-1=없음, 0=1수준, 1=2수준, ...)
# 역할 → 스타일 정의 매핑
ROLE_STYLES: Dict[str, StyleDefinition] = {
# 🆕 개요 문단 (자동 번호 매기기!)
'H1': StyleDefinition(
id=101, name='제1장 제목', font_size=2200, font_bold=True,
font_color='#006400', align='CENTER', line_spacing=200,
indent_left=0, indent_first=0, space_before=400, space_after=200,
outline_level=0 # 🆕 제^1장
),
'H2': StyleDefinition(
id=102, name='1.1 제목', font_size=1500, font_bold=True,
font_color='#03581d', align='LEFT', line_spacing=200,
indent_left=0, indent_first=0, space_before=300, space_after=100,
outline_level=1 # 🆕 ^1.^2
),
'H3': StyleDefinition(
id=103, name='1.1.1 제목', font_size=1400, font_bold=True,
font_color='#228B22', align='LEFT', line_spacing=200,
indent_left=500, indent_first=0, space_before=200, space_after=100,
outline_level=2 # 🆕 ^1.^2.^3
),
'H4': StyleDefinition(
id=104, name='가. 제목', font_size=1300, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=1000, indent_first=0, space_before=150, space_after=50,
outline_level=3 # 🆕 ^4.
),
'H5': StyleDefinition(
id=105, name='1) 제목', font_size=1200, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=1500, indent_first=0, space_before=100, space_after=50,
outline_level=4 # 🆕 ^5)
),
'H6': StyleDefinition(
id=106, name='가) 제목', font_size=1150, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=2000, indent_first=0, space_before=100, space_after=50,
outline_level=5 # 🆕 ^6)
),
'H7': StyleDefinition(
id=115, name='① 제목', font_size=1100, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=200,
indent_left=2300, indent_first=0, space_before=100, space_after=50,
outline_level=6 # 🆕 ^7 (원문자)
),
# 본문 스타일 (개요 아님)
'BODY': StyleDefinition(
id=107, name='○본문', font_size=1100, font_bold=False,
font_color='#000000', align='JUSTIFY', line_spacing=200,
indent_left=1500, indent_first=0, space_before=0, space_after=0
),
'LIST_ITEM': StyleDefinition(
id=108, name='●본문', font_size=1050, font_bold=False,
font_color='#000000', align='JUSTIFY', line_spacing=200,
indent_left=2500, indent_first=0, space_before=0, space_after=0
),
'TABLE_CAPTION': StyleDefinition(
id=109, name='<표 제목>', font_size=1100, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=130,
indent_left=0, indent_first=0, space_before=200, space_after=100
),
'FIGURE_CAPTION': StyleDefinition(
id=110, name='<그림 제목>', font_size=1100, font_bold=True,
font_color='#000000', align='CENTER', line_spacing=130,
indent_left=0, indent_first=0, space_before=100, space_after=200
),
'COVER_TITLE': StyleDefinition(
id=111, name='표지제목', font_size=2800, font_bold=True,
font_color='#1a365d', align='CENTER', line_spacing=150,
indent_left=0, indent_first=0, space_before=0, space_after=200
),
'COVER_SUBTITLE': StyleDefinition(
id=112, name='표지부제', font_size=1800, font_bold=False,
font_color='#2d3748', align='CENTER', line_spacing=150,
indent_left=0, indent_first=0, space_before=0, space_after=100
),
'TOC_1': StyleDefinition(
id=113, name='목차1수준', font_size=1200, font_bold=True,
font_color='#000000', align='LEFT', line_spacing=180,
indent_left=0, indent_first=0, space_before=100, space_after=50
),
'TOC_2': StyleDefinition(
id=114, name='목차2수준', font_size=1100, font_bold=False,
font_color='#000000', align='LEFT', line_spacing=180,
indent_left=500, indent_first=0, space_before=0, space_after=0
),
}
# ⚠️ 개요 자동 번호 기능 활성화!
# idRef="0"은 numbering id=1을 참조하므로, 해당 패턴을 교체하면 동작함
class HwpxStyleInjector:
"""HWPX 스타일 주입기"""
def __init__(self):
self.temp_dir: Optional[Path] = None
self.role_to_style_id: Dict[str, int] = {}
self.role_to_para_id: Dict[str, int] = {} # 🆕
self.role_to_char_id: Dict[str, int] = {} # 🆕
self.next_char_id = 0
self.next_para_id = 0
self.next_style_id = 0
def _find_max_ids(self):
"""기존 스타일 교체: 바탕글(id=0)만 유지, 나머지는 우리 스타일로 교체"""
header_path = self.temp_dir / "Contents" / "header.xml"
if not header_path.exists():
self.next_char_id = 1
self.next_para_id = 1
self.next_style_id = 1
return
content = header_path.read_text(encoding='utf-8')
# 🆕 기존 "본문", "개요 1~10" 등 스타일 제거 (id=1~22)
# 바탕글(id=0)만 유지!
# style id=1~30 제거 (바탕글 제외)
content = re.sub(r'<hh:style id="([1-9]|[12]\d|30)"[^/]*/>\s*', '', content)
# itemCnt는 나중에 _update_item_counts에서 자동 업데이트됨
# 파일 저장
header_path.write_text(content, encoding='utf-8')
print(f" [INFO] 기존 스타일(본문, 개요1~10 등) 제거 완료")
# charPr, paraPr은 기존 것 다음부터 (참조 깨지지 않도록)
char_ids = [int(m) for m in re.findall(r'<hh:charPr id="(\d+)"', content)]
self.next_char_id = max(char_ids) + 1 if char_ids else 20
para_ids = [int(m) for m in re.findall(r'<hh:paraPr id="(\d+)"', content)]
self.next_para_id = max(para_ids) + 1 if para_ids else 20
# 스타일은 1부터 시작! (Ctrl+2 = id=1, Ctrl+3 = id=2, ...)
self.next_style_id = 1
def inject(self, hwpx_path: str, role_positions: Dict[str, List[tuple]]) -> str:
"""
HWPX 파일에 커스텀 스타일 주입
Args:
hwpx_path: 원본 HWPX 파일 경로
role_positions: 역할별 위치 정보 {role: [(section_idx, para_idx), ...]}
Returns:
수정된 HWPX 파일 경로
"""
print(f"\n🎨 HWPX 스타일 주입 시작...")
print(f" 입력: {hwpx_path}")
# 1. 임시 디렉토리에 압축 해제
self.temp_dir = Path(tempfile.mkdtemp(prefix='hwpx_inject_'))
print(f" 임시 폴더: {self.temp_dir}")
try:
with zipfile.ZipFile(hwpx_path, 'r') as zf:
zf.extractall(self.temp_dir)
# 압축 해제 직후 section 파일 크기 확인
print(f" [DEBUG] After unzip:")
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
sec_path = self.temp_dir / "Contents" / sec
if sec_path.exists():
print(f" [DEBUG] {sec} size: {sec_path.stat().st_size} bytes")
# 🆕 기존 최대 ID 찾기 (연속 ID 할당을 위해)
self._find_max_ids()
print(f" [DEBUG] Starting IDs: char={self.next_char_id}, para={self.next_para_id}, style={self.next_style_id}")
# 2. header.xml에 스타일 정의 추가
used_roles = set(role_positions.keys())
self._inject_header_styles(used_roles)
# 3. section*.xml에 styleIDRef 매핑
self._inject_section_styles(role_positions)
# 4. 다시 압축
output_path = hwpx_path # 원본 덮어쓰기
self._repack_hwpx(output_path)
print(f" ✅ 스타일 주입 완료: {output_path}")
return output_path
finally:
# 임시 폴더 정리
if self.temp_dir and self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
def _inject_header_styles(self, used_roles: set):
"""header.xml에 스타일 정의 추가 (모든 ROLE_STYLES 주입)"""
header_path = self.temp_dir / "Contents" / "header.xml"
if not header_path.exists():
print(" [경고] header.xml 없음")
return
content = header_path.read_text(encoding='utf-8')
# 🆕 모든 ROLE_STYLES 주입 (used_roles 무시)
char_props = []
para_props = []
styles = []
for role, style_def in ROLE_STYLES.items():
char_id = self.next_char_id
para_id = self.next_para_id
style_id = self.next_style_id
self.role_to_style_id[role] = style_id
self.role_to_para_id[role] = para_id # 🆕
self.role_to_char_id[role] = char_id # 🆕
# charPr 생성
char_props.append(self._make_char_pr(char_id, style_def))
# paraPr 생성
para_props.append(self._make_para_pr(para_id, style_def))
# style 생성
styles.append(self._make_style(style_id, style_def.name, para_id, char_id))
self.next_char_id += 1
self.next_para_id += 1
self.next_style_id += 1
if not styles:
print(" [정보] 주입할 스타일 없음")
return
# charProperties에 추가
content = self._insert_before_tag(
content, '</hh:charProperties>', '\n'.join(char_props) + '\n'
)
# paraProperties에 추가
content = self._insert_before_tag(
content, '</hh:paraProperties>', '\n'.join(para_props) + '\n'
)
# styles에 추가
content = self._insert_before_tag(
content, '</hh:styles>', '\n'.join(styles) + '\n'
)
# 🆕 numbering id=1 패턴 교체 (idRef="0"이 참조하는 기본 번호 모양)
# 이렇게 하면 개요 자동 번호가 "제1장, 1.1, 1.1.1..." 형식으로 동작!
content = self._replace_default_numbering(content)
# itemCnt 업데이트
content = self._update_item_counts(content)
header_path.write_text(content, encoding='utf-8')
print(f" → header.xml 수정 완료 ({len(styles)}개 스타일 추가)")
def _make_char_pr(self, id: int, style: StyleDefinition) -> str:
"""charPr XML 생성 (한 줄로!)"""
color = style.font_color.lstrip('#')
font_id = "1" if style.font_bold else "0"
return f'<hh:charPr id="{id}" height="{style.font_size}" textColor="#{color}" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="{font_id}" latin="{font_id}" hanja="{font_id}" japanese="{font_id}" other="{font_id}" symbol="{font_id}" user="{font_id}"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:underline type="NONE" shape="SOLID" color="#000000"/><hh:strikeout shape="NONE" color="#000000"/><hh:outline type="NONE"/><hh:shadow type="NONE" color="#B2B2B2" offsetX="10" offsetY="10"/></hh:charPr>'
def _make_para_pr(self, id: int, style: StyleDefinition) -> str:
"""paraPr XML 생성 (한 줄로!)"""
# 개요 문단이면 type="OUTLINE", 아니면 type="NONE"
# idRef="0"은 numbering id=1 (기본 번호 모양)을 참조
if style.outline_level >= 0:
heading = f'<hh:heading type="OUTLINE" idRef="0" level="{style.outline_level}"/>'
else:
heading = '<hh:heading type="NONE" idRef="0" level="0"/>'
return f'<hh:paraPr id="{id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0"><hh:align horizontal="{style.align}" vertical="BASELINE"/>{heading}<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:autoSpacing eAsianEng="0" eAsianNum="0"/><hh:margin><hc:intent value="{style.indent_first}" unit="HWPUNIT"/><hc:left value="{style.indent_left}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="{style.space_before}" unit="HWPUNIT"/><hc:next value="{style.space_after}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="{style.line_spacing}" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>'
def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str:
"""style XML 생성"""
safe_name = name.replace('<', '&lt;').replace('>', '&gt;')
return f'<hh:style id="{id}" type="PARA" name="{safe_name}" engName="" paraPrIDRef="{para_id}" charPrIDRef="{char_id}" nextStyleIDRef="{id}" langID="1042" lockForm="0"/>'
def _insert_before_tag(self, content: str, tag: str, insert_text: str) -> str:
"""특정 태그 앞에 텍스트 삽입"""
return content.replace(tag, insert_text + tag)
def _update_item_counts(self, content: str) -> str:
"""itemCnt 속성 업데이트"""
# charProperties itemCnt
char_count = content.count('<hh:charPr ')
content = re.sub(
r'<hh:charProperties itemCnt="(\d+)"',
f'<hh:charProperties itemCnt="{char_count}"',
content
)
# paraProperties itemCnt
para_count = content.count('<hh:paraPr ')
content = re.sub(
r'<hh:paraProperties itemCnt="(\d+)"',
f'<hh:paraProperties itemCnt="{para_count}"',
content
)
# styles itemCnt
style_count = content.count('<hh:style ')
content = re.sub(
r'<hh:styles itemCnt="(\d+)"',
f'<hh:styles itemCnt="{style_count}"',
content
)
# 🆕 numberings itemCnt
numbering_count = content.count('<hh:numbering ')
content = re.sub(
r'<hh:numberings itemCnt="(\d+)"',
f'<hh:numberings itemCnt="{numbering_count}"',
content
)
return content
def _replace_default_numbering(self, content: str) -> str:
"""numbering id=1의 패턴을 우리 패턴으로 교체"""
# 우리가 원하는 개요 번호 패턴
new_patterns = [
{'level': '1', 'format': 'DIGIT', 'pattern': '제^1장'},
{'level': '2', 'format': 'DIGIT', 'pattern': '^1.^2'},
{'level': '3', 'format': 'DIGIT', 'pattern': '^1.^2.^3'},
{'level': '4', 'format': 'HANGUL_SYLLABLE', 'pattern': '^4.'},
{'level': '5', 'format': 'DIGIT', 'pattern': '^5)'},
{'level': '6', 'format': 'HANGUL_SYLLABLE', 'pattern': '^6)'},
{'level': '7', 'format': 'CIRCLED_DIGIT', 'pattern': '^7'},
]
# numbering id="1" 찾기
match = re.search(r'(<hh:numbering id="1"[^>]*>)(.*?)(</hh:numbering>)', content, re.DOTALL)
if not match:
print(" [경고] numbering id=1 없음, 교체 건너뜀")
return content
numbering_content = match.group(2)
for np in new_patterns:
level = np['level']
fmt = np['format']
pattern = np['pattern']
# 해당 level의 paraHead 찾아서 교체
def replace_parahead(m):
tag = m.group(0)
# numFormat 변경
tag = re.sub(r'numFormat="[^"]*"', f'numFormat="{fmt}"', tag)
# 패턴(텍스트 내용) 변경
tag = re.sub(r'>([^<]*)</hh:paraHead>', f'>{pattern}</hh:paraHead>', tag)
return tag
numbering_content = re.sub(
rf'<hh:paraHead[^>]*level="{level}"[^>]*>.*?</hh:paraHead>',
replace_parahead,
numbering_content
)
new_content = match.group(1) + numbering_content + match.group(3)
print(" [INFO] numbering id=1 패턴 교체 완료 (제^1장, ^1.^2, ^1.^2.^3...)")
return content.replace(match.group(0), new_content)
def _adjust_tables(self, content: str) -> str:
"""표 셀 크기 자동 조정
1. 행 높이: 최소 800 hwpunit (내용 잘림 방지)
2. 열 너비: 표 전체 너비를 열 개수로 균등 분배 (또는 첫 열 좁게)
"""
def adjust_table(match):
tbl = match.group(0)
# 표 전체 너비 추출
sz_match = re.search(r'<hp:sz width="(\d+)"', tbl)
table_width = int(sz_match.group(1)) if sz_match else 47624
# 열 개수 추출
col_match = re.search(r'colCnt="(\d+)"', tbl)
col_cnt = int(col_match.group(1)) if col_match else 4
# 열 너비 계산 (첫 열은 30%, 나머지 균등)
first_col_width = int(table_width * 0.25)
other_col_width = (table_width - first_col_width) // (col_cnt - 1) if col_cnt > 1 else table_width
# 행 높이 최소값 설정
min_height = 800 # 약 8mm
# 셀 크기 조정
col_idx = [0] # closure용
def adjust_cell_sz(cell_match):
width = int(cell_match.group(1))
height = int(cell_match.group(2))
# 높이 조정
new_height = max(height, min_height)
return f'<hp:cellSz width="{width}" height="{new_height}"/>'
tbl = re.sub(
r'<hp:cellSz width="(\d+)" height="(\d+)"/>',
adjust_cell_sz,
tbl
)
return tbl
return re.sub(r'<hp:tbl[^>]*>.*?</hp:tbl>', adjust_table, content, flags=re.DOTALL)
def _inject_section_styles(self, role_positions: Dict[str, List[tuple]]):
"""section*.xml에 styleIDRef 매핑 (텍스트 매칭 방식)"""
contents_dir = self.temp_dir / "Contents"
# 🔍 디버그: role_to_style_id 확인
print(f" [DEBUG] role_to_style_id: {self.role_to_style_id}")
# section 파일들 찾기
section_files = sorted(contents_dir.glob("section*.xml"))
print(f" [DEBUG] section files: {[f.name for f in section_files]}")
total_modified = 0
for section_file in section_files:
print(f" [DEBUG] Processing: {section_file.name}")
original_content = section_file.read_text(encoding='utf-8')
print(f" [DEBUG] File size: {len(original_content)} bytes")
content = original_content # 작업용 복사본
# 🆕 머리말/꼬리말 영역 보존 (placeholder로 교체)
header_footer_map = {}
placeholder_idx = 0
def save_header_footer(match):
nonlocal placeholder_idx
key = f"__HF_PLACEHOLDER_{placeholder_idx}__"
header_footer_map[key] = match.group(0)
placeholder_idx += 1
return key
# 머리말/꼬리말 임시 교체
content = re.sub(r'<hp:header[^>]*>.*?</hp:header>', save_header_footer, content, flags=re.DOTALL)
content = re.sub(r'<hp:footer[^>]*>.*?</hp:footer>', save_header_footer, content, flags=re.DOTALL)
# 모든 <hp:p> 태그와 내부 텍스트 추출
para_pattern = r'(<hp:p [^>]*>)(.*?)(</hp:p>)'
section_modified = 0
def replace_style(match):
nonlocal total_modified, section_modified
open_tag = match.group(1)
inner = match.group(2)
close_tag = match.group(3)
# 텍스트 추출 (태그 제거)
text = re.sub(r'<[^>]+>', '', inner).strip()
if not text:
return match.group(0)
# 텍스트 앞부분으로 역할 판단
text_start = text[:50] # 처음 50자로 판단
matched_role = None
matched_style_id = None
matched_para_id = None
matched_char_id = None
# 제목 패턴 매칭 (앞에 특수문자 허용)
# Unicode: ■\u25a0 ▸\u25b8 ◆\u25c6 ▶\u25b6 ●\u25cf ○\u25cb ▪\u25aa ►\u25ba ☞\u261e ★\u2605 ※\u203b ·\u00b7
prefix = r'^[\u25a0\u25b8\u25c6\u25b6\u25cf\u25cb\u25aa\u25ba\u261e\u2605\u203b\u00b7\s]*'
# 🆕 FIGURE_CAPTION: "[그림 1-1]", "[그림 1-2]" 등 (가장 먼저 체크!)
# 그림 = \uadf8\ub9bc
if re.match(r'^\[\uadf8\ub9bc\s*[\d-]+\]', text_start):
matched_role = 'FIGURE_CAPTION'
# 🆕 TABLE_CAPTION: "<표 1-1>", "[표 1-1]" 등
# 표 = \ud45c
elif re.match(r'^[<\[]\ud45c\s*[\d-]+[>\]]', text_start):
matched_role = 'TABLE_CAPTION'
# H1: "제1장", "1 개요" 등
elif re.match(prefix + r'\uc81c?\s*\d+\uc7a5?\s', text_start) or re.match(prefix + r'[1-9]\s+[\uac00-\ud7a3]', text_start):
matched_role = 'H1'
# H3: "1.1.1 " (H2보다 먼저 체크!)
elif re.match(prefix + r'\d+\.\d+\.\d+\s', text_start):
matched_role = 'H3'
# H2: "1.1 "
elif re.match(prefix + r'\d+\.\d+\s', text_start):
matched_role = 'H2'
# H4: "가. "
elif re.match(prefix + r'[\uac00-\ud7a3]\.\s', text_start):
matched_role = 'H4'
# H5: "1) "
elif re.match(prefix + r'\d+\)\s', text_start):
matched_role = 'H5'
# H6: "(1) " 또는 "가) "
elif re.match(prefix + r'\(\d+\)\s', text_start):
matched_role = 'H6'
elif re.match(prefix + r'[\uac00-\ud7a3]\)\s', text_start):
matched_role = 'H6'
# LIST_ITEM: "○ ", "● ", "• " 등
elif re.match(r'^[\u25cb\u25cf\u25e6\u2022\u2023\u25b8]\s', text_start):
matched_role = 'LIST_ITEM'
elif re.match(r'^[-\u2013\u2014]\s', text_start):
matched_role = 'LIST_ITEM'
# 매칭된 역할이 있고 스타일 ID가 있으면 적용
if matched_role and matched_role in self.role_to_style_id:
matched_style_id = self.role_to_style_id[matched_role]
matched_para_id = self.role_to_para_id[matched_role]
matched_char_id = self.role_to_char_id[matched_role]
elif 'BODY' in self.role_to_style_id and len(text) > 20:
# 긴 텍스트는 본문으로 간주
matched_role = 'BODY'
matched_style_id = self.role_to_style_id['BODY']
matched_para_id = self.role_to_para_id['BODY']
matched_char_id = self.role_to_char_id['BODY']
if matched_style_id:
# 1. hp:p 태그의 styleIDRef 변경
if 'styleIDRef="' in open_tag:
new_open = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{matched_style_id}"', open_tag)
else:
new_open = open_tag.replace('<hp:p ', f'<hp:p styleIDRef="{matched_style_id}" ')
# 2. hp:p 태그의 paraPrIDRef도 변경! (스타일의 paraPrIDRef와 일치!)
new_open = re.sub(r'paraPrIDRef="[^"]*"', f'paraPrIDRef="{matched_para_id}"', new_open)
# 3. inner에서 hp:run의 charPrIDRef도 변경! (스타일의 charPrIDRef와 일치!)
new_inner = re.sub(r'(<hp:run[^>]*charPrIDRef=")[^"]*(")', f'\\g<1>{matched_char_id}\\2', inner)
# 🆕 4. 개요 문단이면 수동 번호 제거 (자동 번호가 붙으니까!)
if matched_role in ROLE_STYLES and ROLE_STYLES[matched_role].outline_level >= 0:
new_inner = self._remove_manual_numbering(new_inner, matched_role)
total_modified += 1
section_modified += 1
return new_open + new_inner + close_tag
return match.group(0)
new_content = re.sub(para_pattern, replace_style, content, flags=re.DOTALL)
# 🆕 표 크기 자동 조정
new_content = self._adjust_tables(new_content)
# 🆕 outlineShapeIDRef를 1로 변경 (우리가 교체한 numbering id=1 사용)
new_content = re.sub(
r'outlineShapeIDRef="[^"]*"',
'outlineShapeIDRef="1"',
new_content
)
# 🆕 머리말/꼬리말 복원
for key, original in header_footer_map.items():
new_content = new_content.replace(key, original)
print(f" [DEBUG] {section_file.name}: {section_modified} paras modified, content changed: {new_content != original_content}")
if new_content != original_content:
section_file.write_text(new_content, encoding='utf-8')
print(f" -> {section_file.name} saved")
print(f" -> Total {total_modified} paragraphs styled")
def _update_para_style(self, content: str, para_idx: int, style_id: int) -> str:
"""특정 인덱스의 문단 styleIDRef 변경"""
# <hp:p ...> 태그들 찾기
pattern = r'<hp:p\s[^>]*>'
matches = list(re.finditer(pattern, content))
if para_idx >= len(matches):
return content
match = matches[para_idx]
old_tag = match.group(0)
# styleIDRef 속성 변경 또는 추가
if 'styleIDRef=' in old_tag:
new_tag = re.sub(r'styleIDRef="[^"]*"', f'styleIDRef="{style_id}"', old_tag)
else:
# 속성 추가
new_tag = old_tag.replace('<hp:p ', f'<hp:p styleIDRef="{style_id}" ')
return content[:match.start()] + new_tag + content[match.end():]
def _remove_manual_numbering(self, inner: str, role: str) -> str:
"""🆕 개요 문단에서 수동 번호 제거 (자동 번호가 붙으니까!)
HTML에서 "제1장 DX 개요""DX 개요" (자동으로 "제1장" 붙음)
HTML에서 "1.1 측량 DX""측량 DX" (자동으로 "1.1" 붙음)
"""
# 역할별 번호 패턴
patterns = {
'H1': r'^(제\s*\d+\s*장\s*)', # "제1장 " → 제거
'H2': r'^(\d+\.\d+\s+)', # "1.1 " → 제거
'H3': r'^(\d+\.\d+\.\d+\s+)', # "1.1.1 " → 제거
'H4': r'^([가-힣]\.\s+)', # "가. " → 제거
'H5': r'^(\d+\)\s+)', # "1) " → 제거
'H6': r'^([가-힣]\)\s+|\(\d+\)\s+)', # "가) " 또는 "(1) " → 제거
'H7': r'^([①②③④⑤⑥⑦⑧⑨⑩]+\s*)', # "① " → 제거
}
if role not in patterns:
return inner
pattern = patterns[role]
# <hp:t> 태그 내 텍스트에서 번호 제거
def remove_number(match):
text = match.group(1)
# 첫 번째 <hp:t> 내용에서만 번호 제거
new_text = re.sub(pattern, '', text, count=1)
return f'<hp:t>{new_text}</hp:t>'
# 첫 번째 hp:t 태그만 처리
new_inner = re.sub(r'<hp:t>([^<]*)</hp:t>', remove_number, inner, count=1)
return new_inner
def _repack_hwpx(self, output_path: str):
"""HWPX 재압축"""
print(f" [DEBUG] Repacking to: {output_path}")
print(f" [DEBUG] Source dir: {self.temp_dir}")
# 압축 전 section 파일 크기 확인
for sec in ['section0.xml', 'section1.xml', 'section2.xml']:
sec_path = self.temp_dir / "Contents" / sec
if sec_path.exists():
print(f" [DEBUG] {sec} size before zip: {sec_path.stat().st_size} bytes")
# 🆕 임시 파일에 먼저 저장 (원본 파일 잠금 문제 회피)
temp_output = output_path + ".tmp"
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축 없이 첫 번째로
mimetype_path = self.temp_dir / "mimetype"
if mimetype_path.exists():
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
file_count = 0
for root, dirs, files in os.walk(self.temp_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(self.temp_dir)
zf.write(file_path, arcname)
file_count += 1
print(f" [DEBUG] Total files zipped: {file_count}")
# 🆕 원본 삭제 후 임시 파일을 원본 이름으로 변경
import time
for attempt in range(3):
try:
if os.path.exists(output_path):
os.remove(output_path)
os.rename(temp_output, output_path)
break
except PermissionError:
print(f" [DEBUG] 파일 잠금 대기 중... ({attempt + 1}/3)")
time.sleep(0.5)
else:
# 3번 시도 실패 시 임시 파일 이름으로 유지
print(f" [경고] 원본 덮어쓰기 실패, 임시 파일 사용: {temp_output}")
output_path = temp_output
# 압축 후 결과 확인
print(f" [DEBUG] Output file size: {Path(output_path).stat().st_size} bytes")
def inject_styles_to_hwpx(hwpx_path: str, elements: list) -> str:
"""
편의 함수: StyledElement 리스트로부터 역할 위치 추출 후 스타일 주입
Args:
hwpx_path: HWPX 파일 경로
elements: StyleAnalyzer의 StyledElement 리스트
Returns:
수정된 HWPX 파일 경로
"""
# 역할별 위치 수집
# 참고: 현재는 section 0, para 순서대로 가정
role_positions: Dict[str, List[tuple]] = {}
for idx, elem in enumerate(elements):
role = elem.role
if role not in role_positions:
role_positions[role] = []
# (section_idx, para_idx) - 현재는 section 0 가정
role_positions[role].append((0, idx))
injector = HwpxStyleInjector()
return injector.inject(hwpx_path, role_positions)
# 테스트
if __name__ == "__main__":
# 테스트용
test_positions = {
'H1': [(0, 0), (0, 5)],
'H2': [(0, 1), (0, 6)],
'BODY': [(0, 2), (0, 3), (0, 4)],
}
# injector = HwpxStyleInjector()
# injector.inject("test.hwpx", test_positions)
print("HwpxStyleInjector 모듈 로드 완료")

View File

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""
HWPX 표 열 너비 수정기 v2
표 생성 후 HWPX 파일을 직접 수정하여 열 너비 적용
"""
import zipfile
import re
from pathlib import Path
import tempfile
import shutil
# mm → HWPML 단위 변환 (1mm ≈ 283.46 HWPML units)
MM_TO_HWPML = 7200 / 25.4 # ≈ 283.46
def inject_table_widths(hwpx_path: str, table_widths_list: list):
"""
HWPX 파일의 표 열 너비를 수정
Args:
hwpx_path: HWPX 파일 경로
table_widths_list: [[w1, w2, w3], [w1, w2], ...] 형태 (mm 단위)
"""
if not table_widths_list:
print(" [INFO] 수정할 표 없음")
return
print(f"📐 HWPX 표 열 너비 수정 시작... ({len(table_widths_list)}개 표)")
# HWPX 압축 해제
temp_dir = Path(tempfile.mkdtemp(prefix="hwpx_table_"))
with zipfile.ZipFile(hwpx_path, 'r') as zf:
zf.extractall(temp_dir)
# section*.xml 파일들에서 표 찾기
contents_dir = temp_dir / "Contents"
table_idx = 0
total_modified = 0
for section_file in sorted(contents_dir.glob("section*.xml")):
with open(section_file, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
# 모든 표(<hp:tbl>...</hp:tbl>) 찾기
tbl_pattern = re.compile(r'(<hp:tbl\b[^>]*>)(.*?)(</hp:tbl>)', re.DOTALL)
def process_table(match):
nonlocal table_idx, total_modified
if table_idx >= len(table_widths_list):
return match.group(0)
tbl_open = match.group(1)
tbl_content = match.group(2)
tbl_close = match.group(3)
col_widths_mm = table_widths_list[table_idx]
col_widths_hwpml = [int(w * MM_TO_HWPML) for w in col_widths_mm]
# 표 전체 너비 수정 (hp:sz width="...")
total_width = int(sum(col_widths_mm) * MM_TO_HWPML)
tbl_content = re.sub(
r'(<hp:sz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{total_width}{m.group(3)}',
tbl_content,
count=1
)
# 각 셀의 cellSz width 수정
# 방법: colAddr별로 너비 매핑
def replace_cell_width(tc_match):
tc_content = tc_match.group(0)
# colAddr 추출
col_addr_match = re.search(r'<hp:cellAddr\s+colAddr="(\d+)"', tc_content)
if not col_addr_match:
return tc_content
col_idx = int(col_addr_match.group(1))
if col_idx >= len(col_widths_hwpml):
return tc_content
new_width = col_widths_hwpml[col_idx]
# cellSz width 교체
tc_content = re.sub(
r'(<hp:cellSz\s+width=")(\d+)(")',
lambda m: f'{m.group(1)}{new_width}{m.group(3)}',
tc_content
)
return tc_content
# 각 <hp:tc>...</hp:tc> 블록 처리
tbl_content = re.sub(
r'<hp:tc\b[^>]*>.*?</hp:tc>',
replace_cell_width,
tbl_content,
flags=re.DOTALL
)
print(f" ✅ 표 #{table_idx + 1}: {col_widths_mm} mm → HWPML 적용")
table_idx += 1
total_modified += 1
return tbl_open + tbl_content + tbl_close
# 표 처리
new_content = tbl_pattern.sub(process_table, content)
# 변경사항 있으면 저장
if new_content != original_content:
with open(section_file, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"{section_file.name} 저장됨")
# 다시 압축
repack_hwpx(temp_dir, hwpx_path)
# 임시 폴더 삭제
shutil.rmtree(temp_dir)
print(f" ✅ 총 {total_modified}개 표 열 너비 수정 완료")
def repack_hwpx(source_dir: Path, output_path: str):
"""HWPX 파일 다시 압축"""
import os
import time
temp_output = output_path + ".tmp"
with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
# mimetype은 압축 없이 첫 번째로
mimetype_path = source_dir / "mimetype"
if mimetype_path.exists():
zf.write(mimetype_path, "mimetype", compress_type=zipfile.ZIP_STORED)
# 나머지 파일들
for root, dirs, files in os.walk(source_dir):
for file in files:
if file == "mimetype":
continue
file_path = Path(root) / file
arcname = file_path.relative_to(source_dir)
zf.write(file_path, arcname)
# 원본 교체
for attempt in range(3):
try:
if os.path.exists(output_path):
os.remove(output_path)
os.rename(temp_output, output_path)
break
except PermissionError:
time.sleep(0.5)
# 테스트용
if __name__ == "__main__":
test_widths = [
[18.2, 38.9, 42.8, 70.1],
[19.9, 79.6, 70.5],
[28.7, 81.4, 59.9],
[19.2, 61.4, 89.5],
]
hwpx_path = r"C:\Users\User\AppData\Local\Temp\geulbeot_output.hwpx"
inject_table_widths(hwpx_path, test_widths)

Some files were not shown because too many files have changed in this diff Show More