diff --git a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py b/03.Code/업로드용/handlers/doc/doc_type_analyzer.py
index 5e2fcfb..b0ef72d 100644
--- a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py
+++ b/03.Code/업로드용/handlers/doc/doc_type_analyzer.py
@@ -1,3 +1,903 @@
-# 문서 유형 분석기
+# -*- coding: utf-8 -*-
+"""
+Doc Type Analyzer (Phase 1 & 2 – Layer A)
+- HWPX 파일에서 구조 및 스타일 정보 추출 (XML 파싱)
+- AI를 사용하지 않는 순수 로직 기반 분석
+"""
+
+import zipfile
+import re
+import json
+import time
+from pathlib import Path
+from typing import List, Optional
+
+# 공통 유틸리티 (hwpx_utils가 있다고 가정하거나 내부 구현)
+# 여기서는 필요한 변환 로직을 내부에 포함
+
+
+def hwpunit_to_mm(unit):
+ """HWP단위 -> mm 변환 (1mm = 283.465 unit)"""
+ return round(int(unit) / 283.465, 1)
+
+
+def charsize_to_pt(size):
+ """HWP 글자크기 -> pt 변환 (1pt = 100 size)"""
+ return round(int(size) / 100, 1)
+
+
+def mm_format(unit):
+ """mm 문자열 포맷팅"""
+ return f"{hwpunit_to_mm(unit)}mm"
+
+
class DocTypeAnalyzer:
- pass
+ """HWPX 문서를 분석하여 구조 정보를 추출하는 클래수"""
+
+ def parse(self, file_path: str) -> dict:
+ """HWPX 파일을 파싱하여 원본 데이터 추출"""
+ if not os.path.exists(file_path):
+ raise FileNotFoundError(f"파일이 없습니다: {file_path}")
+
+ return self._extract_from_hwpx(file_path)
+
+ # ================================================================
+ # Step 1: HWPX 데이터 추출 (XML 파싱)
+ # ================================================================
+
+ def _extract_from_hwpx(self, file_path: str) -> dict:
+ """HWPX 압축 해제 및 주요 XML 구조 보존"""
+ content = {
+ "text": "",
+ "raw_xml": {},
+ "paragraphs": [],
+ "tables": [],
+ "images": [],
+ "header_xml": "",
+ "footer_xml": "",
+ "section_xml": "",
+ }
+
+ with zipfile.ZipFile(file_path, 'r') as zf:
+ for name in zf.namelist():
+ if name.endswith('.xml'):
+ try:
+ xml_content = zf.read(name).decode('utf-8')
+ content["raw_xml"][name] = xml_content
+
+ if 'section' in name.lower():
+ content["section_xml"] = xml_content
+ content["text"] = self._extract_all_text(
+ xml_content)
+ content["paragraphs"] = (
+ self._extract_paragraphs(xml_content))
+ content["tables"] = (
+ self._extract_tables_detailed(xml_content))
+ content["page_header_xml"] = self._extract_header(xml_content)
+ content["page_footer_xml"] = self._extract_footer(xml_content)
+ content["images"] = self._extract_images(
+ xml_content)
+ except Exception as e:
+ print(f"[WARN] XML 파싱 실패 ({name}): {e}")
+
+ return content
+
+ def _extract_all_text(self, xml: str) -> str:
+ """모든 텍스트 추출"""
+ texts = re.findall(r'([^<]*)', xml)
+ return ' '.join(texts)
+
+ def _extract_paragraphs(self, xml: str) -> List[dict]:
+ """문단별 구조 추출"""
+ paragraphs = []
+ p_pattern = re.compile(r']*>(.*?)', re.DOTALL)
+
+ for match in p_pattern.finditer(xml):
+ p_content = match.group(1)
+ texts = re.findall(r'([^<]*)', p_content)
+ text = ' '.join(texts).strip()
+
+ if not text:
+ continue
+
+ style_ref = re.search(r'styleIDRef="(\d+)"', match.group(0))
+ char_ref = re.search(r'charPrIDRef="(\d+)"', p_content)
+ has_image = ' List[dict]:
+ """표 및 셀 구조 추출 - 위치별 분리"""
+ tables = []
+
+ # header 영역
+ header_xml = self._extract_header(xml)
+ for tbl in self._parse_tables_in_region(header_xml):
+ tbl["location"] = "header"
+ tbl["isLayoutTable"] = True
+ tables.append(tbl)
+
+ # footer 영역
+ footer_xml = self._extract_footer(xml)
+ for tbl in self._parse_tables_in_region(footer_xml):
+ tbl["location"] = "footer"
+ tbl["isLayoutTable"] = True
+ tables.append(tbl)
+
+ # 본문 (header/footer 제거)
+ body_xml = re.sub(
+ r']*>.*?', '',
+ xml, flags=re.DOTALL)
+ body_xml = re.sub(
+ r']*>.*?', '',
+ body_xml, flags=re.DOTALL)
+
+ for tbl in self._parse_tables_in_region(body_xml):
+ tbl["location"] = "body"
+ tbl["isLayoutTable"] = self._is_layout_table(tbl["cells"])
+ tables.append(tbl)
+
+ return tables
+
+ def _parse_tables_in_region(self, xml: str) -> List[dict]:
+ """특정 영역 내의 표 파싱
+
+ ★ v3.1: 태그 내 속성 colSpan/rowSpan/width 추출.
+ 참조 맵핑 짠6.3:
+ """
+ tables = []
+ tbl_pattern = re.compile(
+ r']*>(.*?)', re.DOTALL)
+
+ for match in tbl_pattern.finditer(xml):
+ tbl_tag = match.group(0)
+ tbl_content = match.group(1)
+
+ row_cnt = re.search(r'rowCnt="(\d+)"', tbl_tag)
+ col_cnt = re.search(r'colCnt="(\d+)"', tbl_tag)
+
+ cells = []
+ row_pattern = re.compile(
+ r'(.*?)', re.DOTALL)
+
+ for row_match in row_pattern.finditer(tbl_content):
+ row_content = row_match.group(1)
+ row_cells = []
+
+ cell_pattern = re.compile(
+ r']*)>(.*?)', re.DOTALL)
+
+ for cell_match in cell_pattern.finditer(row_content):
+ tc_attrs = cell_match.group(1)
+ cell_content = cell_match.group(2)
+
+ # 셀 내부 텍스트 + 이미지 코드 모듈식 파싱
+ p_texts = []
+ cell_paras = re.findall(
+ r']*>(.*?)',
+ cell_content, re.DOTALL)
+ for cp in cell_paras:
+ cp_text = ' '.join(
+ re.findall(r'([^<]*)', cp)
+ ).strip()
+ if cp_text:
+ p_texts.append(cp_text)
+
+ # 셀 1순위: 태그 속성 (짠6.3)
+ cs_m = re.search(r'colSpan="(\d+)"', tc_attrs)
+ rs_m = re.search(r'rowSpan="(\d+)"', tc_attrs)
+ w_m = re.search(r'width="(\d+)"', tc_attrs)
+
+ # 2순위: 하위 fallback
+ if not cs_m:
+ cs_m = re.search(
+ r']*colSpan="(\d+)"',
+ cell_content)
+ if not rs_m:
+ rs_m = re.search(
+ r']*rowSpan="(\d+)"',
+ cell_content)
+ if not w_m:
+ w_m = re.search(
+ r']*width="(\d+)"',
+ cell_content)
+
+ row_cells.append({
+ "text": ' '.join(p_texts),
+ "lines": p_texts,
+ "colSpan": int(cs_m.group(1)) if cs_m else 1,
+ "rowSpan": int(rs_m.group(1)) if rs_m else 1,
+ "width": int(w_m.group(1)) if w_m else 0,
+ })
+
+ if row_cells:
+ cells.append(row_cells)
+
+ tables.append({
+ "rowCount": (int(row_cnt.group(1))
+ if row_cnt else len(cells)),
+ "colCount": (int(col_cnt.group(1))
+ if col_cnt else 0),
+ "cells": cells,
+ })
+
+ return tables
+
+ def _is_layout_table(self, cells: List[List]) -> bool:
+ """본문 표 중 정보 표(제목 블록) 판별"""
+ if not cells or len(cells) != 1:
+ return False
+
+ row = cells[0]
+ total_text = ' '.join(
+ c["text"] if isinstance(c, dict) else str(c)
+ for c in row)
+
+ return len(row) <= 3 and len(total_text) < 200
+
+ def _extract_header(self, xml: str) -> str:
+ """머리말 추출"""
+ m = re.search(
+ r']*>(.*?)', xml, re.DOTALL)
+ return m.group(1) if m else ""
+
+ def _extract_footer(self, xml: str) -> str:
+ """꼬리말 추출"""
+ m = re.search(
+ r']*>(.*?)', xml, re.DOTALL)
+ return m.group(1) if m else ""
+
+ def _extract_images(self, xml: str) -> List[dict]:
+ """이미지 참조 추출"""
+ images = []
+ pic_pattern = re.compile(
+ r']*>(.*?)', re.DOTALL)
+
+ for match in pic_pattern.finditer(xml):
+ img_ref = re.search(
+ r'binaryItemIDRef="([^"]+)"', match.group(1))
+ if img_ref:
+ images.append({
+ "ref": img_ref.group(1),
+ "raw": match.group(0)[:300]
+ })
+
+ return images
+
+ # ================================================================
+ # Step 2: 구조 분석 추출 (순수 코드 기반 분석)
+ # ================================================================
+
+ def _extract_layout(self, parsed: dict) -> dict:
+ """코드 기반 구조 추출 - HWPX 실제 데이터를 직접 추출"""
+ header_xml = parsed.get("page_header_xml", "")
+ footer_xml = parsed.get("page_footer_xml", "")
+ tables = parsed.get("tables", [])
+ paragraphs = parsed.get("paragraphs", [])
+
+ # 영역별 표 분리
+ header_tables = [t for t in tables
+ if t.get("location") == "header"]
+ footer_tables = [t for t in tables
+ if t.get("location") == "footer"]
+ body_tables = [t for t in tables
+ if t.get("location") == "body"]
+
+ return {
+ "hasHeader": bool(header_xml.strip()),
+ "headerLayout": self._code_header_layout(
+ header_xml, header_tables),
+ "hasFooter": bool(footer_xml.strip()),
+ "footerLayout": self._code_footer_layout(
+ footer_xml, footer_tables),
+ "titleBlock": self._code_title_block(body_tables),
+ "sections": self._code_sections(parsed.get("section_xml", ""), body_tables),
+ "overallStyle": self._code_overall_style(
+ paragraphs, body_tables),
+ }
+
+ def _code_header_layout(self, header_xml: str,
+ header_tables: list) -> dict:
+ """헤더 레이아웃 - 실제 HWPX 데이터 직접 반영"""
+ if not header_xml.strip() and not header_tables:
+ return {"structure": "없음"}
+
+ if header_tables:
+ ht = header_tables[0]
+ cells = ht.get("cells", [])
+
+ # 실제 표 텍스트/구조 추출
+ cell_texts = []
+ cell_lines = []
+ if cells:
+ for row in cells:
+ for cell in row:
+ text = (cell.get("text", "")
+ if isinstance(cell, dict)
+ else str(cell))
+ lines = (cell.get("lines", [text])
+ if isinstance(cell, dict)
+ else [str(cell)])
+ cell_texts.append(text)
+ cell_lines.append(lines)
+
+ return {
+ "structure": "표",
+ "colCount": (ht.get("colCount")
+ or (len(cells[0]) if cells else 0)),
+ "rowCount": len(cells),
+ "cellTexts": cell_texts,
+ "cellLines": cell_lines,
+ }
+
+ # 텍스트만 있는 헤더
+ texts = re.findall(r'([^<]*)', header_xml)
+ return {
+ "structure": "텍스트",
+ "texts": texts,
+ }
+
+ def _code_footer_layout(self, footer_xml: str,
+ footer_tables: list) -> dict:
+ """꼬리말 레이아웃 - 실제 HWPX 데이터 직접 반영"""
+ if not footer_xml.strip() and not footer_tables:
+ return {"structure": "없음"}
+
+ if footer_tables:
+ ft = footer_tables[0]
+ cells = ft.get("cells", [])
+
+ cell_texts = []
+ cell_lines = []
+ if cells:
+ for row in cells:
+ for cell in row:
+ text = (cell.get("text", "")
+ if isinstance(cell, dict)
+ else str(cell))
+ lines = (cell.get("lines", [text])
+ if isinstance(cell, dict)
+ else [str(cell)])
+ cell_texts.append(text)
+ cell_lines.append(lines)
+
+ return {
+ "structure": "표",
+ "colCount": (ft.get("colCount")
+ or (len(cells[0]) if cells else 0)),
+ "rowCount": len(cells),
+ "cellTexts": cell_texts,
+ "cellLines": cell_lines,
+ }
+
+ texts = re.findall(r'([^<]*)', footer_xml)
+ return {
+ "structure": "텍스트",
+ "texts": texts,
+ }
+
+ def _code_title_block(self, body_tables: list) -> dict:
+ """제목 블록 본문 첫 표 정보 추출"""
+ layout_tables = [t for t in body_tables
+ if t.get("isLayoutTable")]
+
+ if layout_tables:
+ lt = layout_tables[0]
+ cells = lt.get("cells", [])
+ text = ""
+ if cells and cells[0]:
+ text = ' '.join(
+ c.get("text", "") if isinstance(c, dict) else str(c)
+ for c in cells[0]
+ ).strip()
+ return {
+ "type": "표",
+ "colCount": (lt.get("colCount")
+ or (len(cells[0]) if cells else 1)),
+ "text": text,
+ }
+
+ return {"type": "없음"}
+
+ def _code_sections(self, section_xml: str,
+ body_tables: list) -> list:
+ """섹션 추출 - section_xml에서 문단 패턴 매칭"""
+ sections = []
+ data_tables = [t for t in body_tables
+ if not t.get("isLayoutTable")]
+ table_idx = 0
+ current_section = None
+
+ # 헤더/푸터 제거
+ clean_xml = re.sub(
+ r']*>.*?', '',
+ section_xml, flags=re.DOTALL)
+ clean_xml = re.sub(
+ r']*>.*?', '',
+ clean_xml, flags=re.DOTALL)
+
+ paragraphs = re.findall(
+ r']*>(.*?)', clean_xml, re.DOTALL)
+
+ for p_content in paragraphs:
+ texts = re.findall(r'([^<]*)', p_content)
+ text = ' '.join(texts).strip()
+ if not text:
+ continue
+
+ is_section_title = False
+
+ if re.match(r'^\d+\.\s+', text):
+ is_section_title = True
+ elif ' dict:
+ """전체 문체/불렛/표 사용 패턴 - 코드 기반 분석"""
+ bullet_chars = {
+ '·': 0, '●': 0, '○': 0, '■': 0,
+ '□': 0, '-': 0, '▶': 0, '•': 0,
+ }
+ bullet_total = 0
+ prose_count = 0
+
+ for p in paragraphs:
+ text = p.get("text", "").strip()
+ if not text:
+ continue
+
+ found_bullet = False
+ for char in bullet_chars:
+ if text.startswith(char) or text.startswith(f' {char}'):
+ bullet_chars[char] += 1
+ bullet_total += 1
+ found_bullet = True
+ break
+
+ if not found_bullet and len(text) > 50:
+ prose_count += 1
+
+ # 가장 많이 사용된 불렛
+ most_common = None
+ if any(v > 0 for v in bullet_chars.values()):
+ most_common = max(bullet_chars, key=bullet_chars.get)
+
+ # 문체 판정
+ writing_style = "일반"
+ if bullet_total > prose_count * 2:
+ writing_style = "개조식"
+ elif prose_count > bullet_total * 2:
+ writing_style = "서술식"
+
+ # 표 사용량
+ data_tables = [t for t in body_tables
+ if not t.get("isLayoutTable")]
+ table_count = len(data_tables)
+ if table_count >= 3:
+ table_usage = "많음"
+ elif table_count >= 1:
+ table_usage = "보통"
+ else:
+ table_usage = "없음"
+
+ return {
+ "writingStyle": writing_style,
+ "bulletType": most_common or "·",
+ "tableUsage": table_usage,
+ }
+
+ # ================================================================
+ # Step 3: 맵핑 (의미 분석 부)
+ # ================================================================
+
+ def _analyze_context(self, parsed: dict, layout: dict) -> dict:
+ """문서 맥락 (정의, 유형, 목적)
+
+ ★ v3.1: 물리적 구조는 코드 추출 완료 상태.
+ AI는 텍스트+섹션명을 주고 핵심 정보(문서정의/목적)만 매칭 요청.
+ """
+ text = parsed.get("text", "")[:4000]
+ sections = layout.get("sections", [])
+ section_names = [s.get("name", "") for s in sections]
+
+ prompt = f"""당신은 문서 유형 분석 전문가입니다.
+
+## 문서 텍스트 (일부)
+{text}
+
+## 문서에서 추출된 섹션 목록
+{json.dumps(section_names, ensure_ascii=False)}
+
+## 핵심 과제
+이 문서를 보고서 전문가로서 "아하! 이 문서는 OOO을 하기 위한 OOO 문서구나!"라고
+한 문장으로 정의하십시오.
+
+예시:
+- "발표를 하기 위한 기획서"
+- "보고를 받기 위한 제안서"
+- "성과를 공유하기 위한 회의록"
+- "업무 현황을 보고하기 위한 보고서"
+
+## 주어지는 문서의 성격과 내용 무관하게 (공공기관, 프로젝트, 기술적 내용 등)
+- 문서 속성/정의**만 분석
+
+JSON으로 응답:
+{{
+ "documentDefinition": "OOO를 하기 위한 OOO 문서",
+ "documentType": "문서 유형명 (기획서, 보고서, 제안서 등)",
+ "purpose": "작성 목적",
+ "perspective": "어떤 내용들이 들어가야 이 관점으로 작성된다고 볼 수 있는지",
+ "audience": "주 대상 (의사결정권자)",
+ "tone": "어조 (보고형/제안형/공유형)"
+}}"""
+
+ try:
+ response = call_claude(
+ "문서 맥락 분석 전문가입니다.",
+ prompt,
+ max_tokens=1000
+ )
+ result = extract_json(response)
+ if result:
+ return result
+ except Exception as e:
+ print(f"[WARN] 맥락 분석 오류: {e}")
+
+ return {
+ "documentDefinition": "",
+ "documentType": "일반 문서",
+ "purpose": "",
+ "perspective": "",
+ "audience": "의사결정권자",
+ }
+
+ # ================================================================
+ # Step 4: 구조 분석 (섹션 역할 분석)
+ # ================================================================
+
+ def _analyze_structure(self, parsed: dict, layout: dict,
+ context: dict) -> dict:
+ """구조 상세 분석 - 섹션별 역할, 문체, 표 구조
+
+ ★ v3.1: layout.sections가 코드 추출로 전달된 상태.
+ AI는 각 섹션이 무엇인지와 작성 가이드만 생성.
+ """
+ text = parsed.get("text", "")[:4000]
+ tables = parsed.get("tables", [])
+ sections = layout.get("sections", [])
+
+ # 표 상세 정보 (본문 데이터 표만)
+ table_details = []
+ for i, t in enumerate(tables):
+ if t.get("location") == "body" and not t.get("isLayoutTable"):
+ cells = t.get("cells", [])
+ headers = cells[0] if cells else []
+ sample_rows = cells[1:3] if len(cells) > 1 else []
+
+ table_details.append({
+ "index": i + 1,
+ "rows": t["rowCount"],
+ "cols": t["colCount"],
+ "headers": [
+ (c.get("text", "") if isinstance(c, dict)
+ else str(c))
+ for c in headers
+ ],
+ "sampleData": [
+ [(c.get("text", "")[:50] if isinstance(c, dict)
+ else str(c)[:50])
+ for c in row]
+ for row in sample_rows
+ ]
+ })
+
+ prompt = f"""당신은 문서 구조 분석 전문가입니다.
+
+## 문서 정의
+{context.get('documentDefinition', '문서')}
+
+## 문서에서 추출된 섹션들
+{json.dumps(sections, ensure_ascii=False, indent=2)}
+
+## 본문 텍스트
+{text}
+
+## 표 상세 정보
+{json.dumps(table_details, ensure_ascii=False, indent=2)}
+
+## 분석 과제
+각 섹션이 **무엇인지와 작성 가이드**를 분석하십시오.
+
+### 필수 항목
+1. **섹션명**: 원본 섹션 명
+2. **역할**: 이 섹션이 문서에서 하는 역할
+3. **문체**: 개조식/서술식 등의 가이드
+4. **표 포함 여부**: 표가 있다면 구조까지 상세히
+
+### 표가 있는 섹션 분석:
+- 컬럼 개수, 각 컬럼의 역할
+- 각 컬럼이 어떤 형식으로 내용이 채워지는지
+
+## 주어지는 문서의 성격과 내용 무관하게 글짓기 금지
+- 섹션별 성격과 작성 가이드**만 분석
+
+JSON:
+{{
+ "sectionGuides": [
+ {{
+ "name": "섹션명",
+ "role": "이 섹션의 역할",
+ "writingStyle": "개조식/서술식 등 가이드",
+ "hasTable": false
+ }},
+ {{
+ "name": "섹션명",
+ "role": "역할",
+ "writingStyle": "개조식",
+ "hasTable": true,
+ "tableStructure": {{
+ "columns": 3,
+ "columnDefs": [
+ {{"name": "컬럼명", "role": "역할", "style": "스타일"}},
+ {{"name": "컬럼명", "role": "역할", "style": "스타일"}},
+ {{"name": "컬럼명", "role": "역할", "style": "스타일"}}
+ ],
+ "rowGuide": "각 행 설명"
+ }}
+ }}
+ ],
+ "writingPrinciples": [
+ "이 문서 전체를 작성할 때의 원칙1",
+ "원칙2"
+ ],
+ "pageEstimate": 1
+}}"""
+
+ try:
+ response = call_claude(
+ "문서 구조 분석 전문가입니다. "
+ "섹션별 성격과 표 구조를 상세히 분석합니다.",
+ prompt,
+ max_tokens=3000
+ )
+ result = extract_json(response)
+ if result:
+ return result
+ except Exception as e:
+ print(f"[WARN] 구조 분석 오류: {e}")
+
+ return {
+ "sectionGuides": [],
+ "writingPrinciples": [],
+ "pageEstimate": 1,
+ }
+
+ # ================================================================
+ # Step 5: 스타일 추출 (hwpx_utils 연동)
+ # ================================================================
+
+ def _extract_style(self, parsed: dict) -> dict:
+ """스타일 추출 - XML 직접 파싱
+
+ ★ v3.1 FIX:
+ - 여백: hwpunit_to_mm() 사용
+ 이전 v3.0: int(val) / 100 -> 5668이 56.7mm (오차)
+ 이후 v3.1: hwpunit_to_mm(5668) -> 20.0mm (정확)
+ - 폰트 크기: charsize_to_pt() 사용
+ - 폰트 이름: fontface에서 실제 face 추출 (ID가 아님)
+ - 도메인 가이드 맵핑 짠1, 짠3, 짠4, 짠7 참조
+ """
+ raw_xml = parsed.get("raw_xml", {})
+ section_xml = parsed.get("section_xml", "")
+
+ result = {
+ "font": {"name": None, "size": None},
+ "colors": {"primary": None, "secondary": None},
+ "margins": {
+ "top": None, "bottom": None,
+ "left": None, "right": None},
+ "lineHeight": None,
+ "headingStyle": {"h1": None, "h2": None},
+ "bulletStyle": None,
+ "alignment": None,
+ }
+
+ for xml_name, xml_content in raw_xml.items():
+ # ★ 짠7.2: 용지 마진 (HWPUNIT -> mm)
+ for tag in ['margin', 'pageMargin']:
+ # 정규식으로 속성 무관 가변 추출
+ t_m = re.search(
+ rf'<(?:\w+:)?{tag}\b[^>]*\btop="(\d+)"',
+ xml_content)
+ if t_m:
+ b_m = re.search(
+ rf'<(?:\w+:)?{tag}\b[^>]*\bbottom="(\d+)"',
+ xml_content)
+ l_m = re.search(
+ rf'<(?:\w+:)?{tag}\b[^>]*\bleft="(\d+)"',
+ xml_content)
+ r_m = re.search(
+ rf'<(?:\w+:)?{tag}\b[^>]*\bright="(\d+)"',
+ xml_content)
+ result["margins"] = {
+ "top": mm_format(int(t_m.group(1))),
+ "bottom": (mm_format(int(b_m.group(1)))
+ if b_m else "15.0mm"),
+ "left": (mm_format(int(l_m.group(1)))
+ if l_m else "20.0mm"),
+ "right": (mm_format(int(r_m.group(1)))
+ if r_m else "20.0mm"),
+ }
+ break
+
+ # 짠3.2: 폰트 - fontface에서 실제 face 명 추출
+ if not result["font"]["name"]:
+ font_match = re.search(
+ r'<(?:\w+:)?fontface[^>]*lang="HANGUL"[^>]*>.*?'
+ r'<(?:\w+:)?font[^>]*face="([^"]+)"',
+ xml_content, re.DOTALL)
+ if font_match:
+ result["font"]["name"] = font_match.group(1)
+
+ # fallback: fontRef hangul (속성, ID면 무시)
+ if not result["font"]["name"]:
+ fr_match = re.search(
+ r'<(?:\w+:)?fontRef[^>]*hangul="([^"]+)"',
+ xml_content)
+ if fr_match:
+ val = fr_match.group(1)
+ if not val.isdigit():
+ result["font"]["name"] = val
+
+ # 짠4.1: 글자 크기 (charPr height -> pt)
+ if not result["font"]["size"]:
+ size_match = re.search(
+ r'<(?:\w+:)?charPr[^>]*height="(\d+)"',
+ xml_content)
+ if size_match:
+ pt = charsize_to_pt(int(size_match.group(1)))
+ result["font"]["size"] = f"{pt:.0f}pt"
+
+ # 짠1.3: 색상 (HWPX는 #RRGGBB)
+ if not result["colors"]["primary"]:
+ color_match = re.search(
+ r'\bcolor="(#[0-9a-fA-F]{6})"', xml_content)
+ if color_match:
+ result["colors"]["primary"] = color_match.group(1)
+
+ # 줄 간격 (lineSpacing 속성)
+ line_match = re.search(r'lineSpacing="(\d+)"', section_xml)
+ if line_match:
+ result["lineHeight"] = int(line_match.group(1))
+
+ # 정렬 (align 속성)
+ align_match = re.search(r'\balign="([A-Z]+)"', section_xml)
+ if align_match:
+ result["alignment"] = align_match.group(1)
+
+ return result
+
+ # ================================================================
+ # Step 6: Config 생성
+ # ================================================================
+
+ def _generate_config(self, doc_name: str, description: str,
+ result: dict) -> dict:
+ """config.json 생성"""
+ doc_id = f"user_{int(time.time())}"
+ context = result.get("context", {})
+ structure = result.get("structure", {})
+ layout = result.get("layout", {})
+
+ features = []
+ features.append({
+ "icon": "📄",
+ "text": context.get("documentType", "문서")
+ })
+
+ purpose = context.get("purpose", "")
+ purpose_short = (
+ (purpose[:15] + "...") if len(purpose) > 15 else purpose)
+ if purpose_short:
+ features.append({"icon": "🎯", "text": purpose_short})
+
+ features.append({
+ "icon": "👥",
+ "text": context.get("audience", "일반")
+ })
+ features.append({
+ "icon": "📏",
+ "text": f"약 {structure.get('pageEstimate', '?')}p"
+ })
+
+ return {
+ "id": doc_id,
+ "name": doc_name,
+ "icon": "📝",
+ "description": (description
+ or context.get("documentType", "")),
+ "features": features[:4],
+ "thumbnailType": "custom",
+ "enabled": True,
+ "isDefault": False,
+ "order": 100,
+ "template_id": result.get("template_id"),
+
+ "context": {
+ "documentDefinition": context.get(
+ "documentDefinition", ""),
+ "documentType": context.get("documentType", ""),
+ "purpose": context.get("purpose", ""),
+ "perspective": context.get("perspective", ""),
+ "audience": context.get("audience", ""),
+ "tone": context.get("tone", ""),
+ },
+
+ "layout": layout,
+ "structure": structure,
+ "content_prompt": result.get("content_prompt", {}),
+ "options": {},
+
+ "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+
+ # ================================================================
+ # 저장
+ # ================================================================
+
+ def save_doc_type(self, config: dict, template: str,
+ base_path: str = "templates/user/doc_types") -> str:
+ """분석 결과 저장 (config.json 및 템플릿 template_manager가 관리)"""
+ doc_path = Path(base_path) / config["id"]
+ doc_path.mkdir(parents=True, exist_ok=True)
+
+ config_path = doc_path / "config.json"
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, ensure_ascii=False, indent=2)
+
+ # ★ content_prompt.json 별도 저장 가능!
+ content_prompt = config.pop("content_prompt", {})
+ if content_prompt:
+ with open(doc_path / "content_prompt.json", "w", encoding="utf-8") as f:
+ json.dump(content_prompt, f, ensure_ascii=False, indent=2)
+
+ # template_id 없는 경우(fallback)만 template.html 직접 저장
+ if not config.get("template_id") and template:
+ template_path = doc_path / "template.html"
+ with open(template_path, "w", encoding="utf-8") as f:
+ f.write(template)
+
+ return str(doc_path)