commit 555a954458d289a86a63f561f8a3dfb9a6a3868b Author: kyeongmin Date: Thu Mar 5 11:32:29 2026 +0900 📊 Initialize Geulbeot structure and merge Prompts & test projects diff --git a/02. Prompts/묞서생성/codedomain/국토음볎_한걎신묞_Python_v01.py b/02. Prompts/묞서생성/codedomain/국토음볎_한걎신묞_Python_v01.py new file mode 100644 index 0000000..5c1a7e6 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/국토음볎_한걎신묞_Python_v01.py @@ -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 == "연합 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/날짜_형식을_Python_v01.py b/02. Prompts/묞서생성/codedomain/날짜_형식을_Python_v01.py new file mode 100644 index 0000000..769400d --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/날짜_형식을_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/닀음_로우데읎터륌_Python_v01.py b/02. Prompts/묞서생성/codedomain/닀음_로우데읎터륌_Python_v01.py new file mode 100644 index 0000000..548be38 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/닀음_로우데읎터륌_Python_v01.py @@ -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) 읎믞지 자동 맀핑 ───────────────────────── \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/닚위음_가능성_Python_v01.py b/02. Prompts/묞서생성/codedomain/닚위음_가능성_Python_v01.py new file mode 100644 index 0000000..90815dd --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/닚위음_가능성_Python_v01.py @@ -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. 음반적읞 계산 Ʞ혞 및 정크 Ʞ혞 제왞 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/닚음_Ʞ사_Python_v01.py b/02. Prompts/묞서생성/codedomain/닚음_Ʞ사_Python_v01.py new file mode 100644 index 0000000..9bc490d --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/닚음_Ʞ사_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/당신은_볎고서_Python_v01.py b/02. Prompts/묞서생성/codedomain/당신은_볎고서_Python_v01.py new file mode 100644 index 0000000..3de070d --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/당신은_볎고서_Python_v01.py @@ -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) 가읎드띌읞에서 필수 섹션 추출 ─────────── \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/로귞_전첎_Python_v01.py b/02. Prompts/묞서생성/codedomain/로귞_전첎_Python_v01.py new file mode 100644 index 0000000..17a4389 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/로귞_전첎_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/로귞_파음을_Python_v01.py b/02. Prompts/묞서생성/codedomain/로귞_파음을_Python_v01.py new file mode 100644 index 0000000..66d945d --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/로귞_파음을_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/늬슀튞_페읎지_Python_v01.py b/02. Prompts/묞서생성/codedomain/늬슀튞_페읎지_Python_v01.py new file mode 100644 index 0000000..0506475 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/늬슀튞_페읎지_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/멀티띌읞_대응_Python_v01.py b/02. Prompts/묞서생성/codedomain/멀티띌읞_대응_Python_v01.py new file mode 100644 index 0000000..656f615 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/멀티띌읞_대응_Python_v01.py @@ -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() \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/믞분류_곌업_Python_v01.py b/02. Prompts/묞서생성/codedomain/믞분류_곌업_Python_v01.py new file mode 100644 index 0000000..5a3abed --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/믞분류_곌업_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/법령_지칚_Python_v01.py b/02. Prompts/묞서생성/codedomain/법령_지칚_Python_v01.py new file mode 100644 index 0000000..73b9429 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/법령_지칚_Python_v01.py @@ -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) 로우데읎터에서 섹션별 낎용 뜑Ʞ ─────────── \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/볎고서_섹션에_Python_v01.py b/02. Prompts/묞서생성/codedomain/볎고서_섹션에_Python_v01.py new file mode 100644 index 0000000..551ccbb --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/볎고서_섹션에_Python_v01.py @@ -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) 디자읞 템플늿 선택 ─────────────────────── \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/사읎튞별_핚수_Python_v01.py b/02. Prompts/묞서생성/codedomain/사읎튞별_핚수_Python_v01.py new file mode 100644 index 0000000..dda7016 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/사읎튞별_핚수_Python_v01.py @@ -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'} + +# ------------------------------------------------- +# 사읎튞별 핚수 (대한겜제 제왞) +# ----------------------------------- \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/섀명읎_없습니닀_Python_v01.py b/02. Prompts/묞서생성/codedomain/섀명읎_없습니닀_Python_v01.py new file mode 100644 index 0000000..71a0a16 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/섀명읎_없습니닀_Python_v01.py @@ -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 "섀명읎 없습니닀." \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/섀정_람띌우저가_Python_v01.py b/02. Prompts/묞서생성/codedomain/섀정_람띌우저가_Python_v01.py new file mode 100644 index 0000000..40ee214 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/섀정_람띌우저가_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/수식_자첎가_Python_v01.py b/02. Prompts/묞서생성/codedomain/수식_자첎가_Python_v01.py new file mode 100644 index 0000000..fdc14e4 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/수식_자첎가_Python_v01.py @@ -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 \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/수식_죌소륌_Python_v01.py b/02. Prompts/묞서생성/codedomain/수식_죌소륌_Python_v01.py new file mode 100644 index 0000000..e9ad0c6 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/수식_죌소륌_Python_v01.py @@ -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" \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/수식을_가젞였Ʞ_Python_v01.py b/02. Prompts/묞서생성/codedomain/수식을_가젞였Ʞ_Python_v01.py new file mode 100644 index 0000000..b8a76ed --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/수식을_가젞였Ʞ_Python_v01.py @@ -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] \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/아래_디자읞_Python_v01.py b/02. Prompts/묞서생성/codedomain/아래_디자읞_Python_v01.py new file mode 100644 index 0000000..394d537 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/아래_디자읞_Python_v01.py @@ -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 생성 ──────────────────────────────────── \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/엔지니얎링데음늬_Ʞ자_Python_v01.py b/02. Prompts/묞서생성/codedomain/엔지니얎링데음늬_Ʞ자_Python_v01.py new file mode 100644 index 0000000..5a11762 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/엔지니얎링데음늬_Ʞ자_Python_v01.py @@ -0,0 +1,13 @@ +def clean_text(text): + replacements = { + ' ': ' ', '‘': "'", '’': "'", '“': '"', '”': '"', + '&': '&', '<': '<', '>': '>', ''': "'", + '"' : "'", '·': "'" + } + + 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.*? \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/엔티티_불필요한_Python_v01.py b/02. Prompts/묞서생성/codedomain/엔티티_불필요한_Python_v01.py new file mode 100644 index 0000000..54ba222 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/엔티티_불필요한_Python_v01.py @@ -0,0 +1,9 @@ +def clean_text(text: str) -> str: + """HTML 엔티티 및 불필요한 태귞 제거""" + reps = { + ' ': ' ', '‘': "'", '’': "'", '“': '"', '”': '"', + '&': '&', '<': '<', '>': '>', ''': "'", '"': "'", '·': "'" + } + for key, val in reps.items(): + text = text.replace(key, val) + return re.sub(r'<[^>]+>', '', text).strip() \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/읞슝서_검슝_Python_v01.py b/02. Prompts/묞서생성/codedomain/읞슝서_검슝_Python_v01.py new file mode 100644 index 0000000..adf0c8a --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/읞슝서_검슝_Python_v01.py @@ -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', ' ') \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/칎테고늬_낎용_Python_v01.py b/02. Prompts/묞서생성/codedomain/칎테고늬_낎용_Python_v01.py new file mode 100644 index 0000000..f9b44cc --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/칎테고늬_낎용_Python_v01.py @@ -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 = \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/컀버_슬띌읎드_Python_v01.py b/02. Prompts/묞서생성/codedomain/컀버_슬띌읎드_Python_v01.py new file mode 100644 index 0000000..4304c84 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/컀버_슬띌읎드_Python_v01.py @@ -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] \ No newline at end of file diff --git a/02. Prompts/묞서생성/codedomain/합계_Ʞ쀀_Python_v01.py b/02. Prompts/묞서생성/codedomain/합계_Ʞ쀀_Python_v01.py new file mode 100644 index 0000000..de4a318 --- /dev/null +++ b/02. Prompts/묞서생성/codedomain/합계_Ʞ쀀_Python_v01.py @@ -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 "" \ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_가계_고ꞈ늬_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_가계_고ꞈ늬_v01.md new file mode 100644 index 0000000..55758da --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_가계_고ꞈ늬_v01.md @@ -0,0 +1,7 @@ +

3-2. 가계: 고ꞈ늬 플크아웃, 하지만 첎감 회복은 느늬닀

+

Ʞ쀀ꞈ늬는 정점을 지나 완만히 낮아지는 방향읎지만, 곌거 쎈저ꞈ늬 시대와 비교하멎 여전히 높은 수쀀읎닀.

+ \ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_겜제_진닚_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_겜제_진닚_v01.md new file mode 100644 index 0000000..9eab505 --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_겜제_진닚_v01.md @@ -0,0 +1,10 @@ + + + + + + + 겜제 진닚 볎고서 + + + \ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_대제목_전첎폭_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_대제목_전첎폭_v01.md new file mode 100644 index 0000000..ed3f7c8 --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_대제목_전첎폭_v01.md @@ -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; + } \ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬드묞_늬드묞_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬드묞_늬드묞_v01.md new file mode 100644 index 0000000..7da0686 --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬드묞_늬드묞_v01.md @@ -0,0 +1,3 @@ +
+ [늬드묞] [늬드묞] [늬드묞] +
\ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬슀크_요읞_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬슀크_요읞_v01.md new file mode 100644 index 0000000..a9162c7 --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_늬슀크_요읞_v01.md @@ -0,0 +1,5 @@ +

1-3. 늬슀크 요읞: 묎역갈등, 지정학, 고부채

+

Ꞁ로벌 전망에서 반복적윌로 등장하는 킀워드는 “정책 불확싀성”읎닀. IMF는 2025년 4·10월 볎고서에서, ꎀ섞 읞상곌 공꞉망 재펞, 지정학적 ꞎ장 고조가 향후 성장률을 추가로 깎아뚹을 수 있는 하방 늬슀크띌고 지적한닀.

+

두 번짞 늬슀크는 고부채닀. 윔로나 위Ʞ 대응 곌정에서 확대한 정부지출곌 읎후의 고ꞈ늬 환겜읎 결합되멎서 많은 국가의 재정 상태가 빠륎게 악화되었닀.

+

마지막윌로, 디지턞 전환곌 에너지 전환(탈탄소화)은 장Ʞ적윌로는 성장 잠재력을 킀우는 요읞읎지만, 닚Ʞ적윌로는 막대한 투자 비용곌 산업 구조조정을 수반한닀.

+ \ No newline at end of file diff --git a/02. Prompts/묞서생성/domain/도메읞_묞서생성_마지막윌로_로드한_v01.md b/02. Prompts/묞서생성/domain/도메읞_묞서생성_마지막윌로_로드한_v01.md new file mode 100644 index 0000000..6d21d40 --- /dev/null +++ b/02. Prompts/묞서생성/domain/도메읞_묞서생성_마지막윌로_로드한_v01.md @@ -0,0 +1,11 @@ + + + + + +⚠ [최종 겜고 - 출력 직전 필수 확읞] +1. 원볞의 몚든 텍슀튞가 100% 포핚되었는가? +2. "..." 또는 요앜된 묞장읎 없는가? +3. 생략된 묞닚읎 당 하나도 없는가? + +위 3가지 쀑 하나띌도 위반 시, 출력을 쀑닚하고 처음부터 닀시 작성하십시였. +원볞 텍슀튞 Ꞁ자 수와 출력 텍슀튞 Ꞁ자 수가 동음핎알 합니닀. \ No newline at end of file diff --git a/03. Code/geulbeot_10th/Procfile b/03. Code/geulbeot_10th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_10th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_10th/README.md b/03. Code/geulbeot_10th/README.md new file mode 100644 index 0000000..6460825 --- /dev/null +++ b/03. Code/geulbeot_10th/README.md @@ -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 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/api_config.py b/03. Code/geulbeot_10th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_10th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_10th/app.py b/03. Code/geulbeot_10th/app.py new file mode 100644 index 0000000..195375b --- /dev/null +++ b/03. Code/geulbeot_10th/app.py @@ -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/', 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/') +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/', 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/', 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/', 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//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//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) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/__init__.py b/03. Code/geulbeot_10th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_10th/converters/html_to_hwp.py b/03. Code/geulbeot_10th/converters/html_to_hwp.py new file mode 100644 index 0000000..7b2b8a0 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/html_to_hwp.py @@ -0,0 +1,1115 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 슀킵 (convert에서 읎믞 완성) ═══ + print(f" ⏭ 슀타음 후처늬 슀킵 (convert 결곌 유지)") + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_10th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/hwp_style_mapping.py b/03. Code/geulbeot_10th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/hwpx_generator.py b/03. Code/geulbeot_10th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_10th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
+

걎섀·토목 잡량 DX 싀묎지칚

+

드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

+

2024년 1월

+
+ +

1. 개요

+

볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

+ +

1.1 배겜

+

최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

+ +

1.1.1 Ʞ술 동향

+

1) 드론 잡량의 발전

+

드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

+ +

(1) RTK 드론

+

싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

+ +
    +
  • 고정밀 GPS 수신Ʞ 낎장
  • +
  • 섌티믞터 닚위 정확도
  • +
+ + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/hwpx_style_injector.py b/03. Code/geulbeot_10th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/hwpx_table_injector.py b/03. Code/geulbeot_10th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_10th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/__init__.py b/03. Code/geulbeot_10th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_10th/converters/pipeline/router.py b/03. Code/geulbeot_10th/converters/pipeline/router.py new file mode 100644 index 0000000..c4795d8 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/router.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 읎믞지 겜로륌 서버 겜로로 변환 + - assets/xxx.png → /assets/xxx.png (Flask 서빙용) + - 절대 겜로나 URL은 귞대로 유지 + """ + + def replace_src(match): + original_path = match.group(1) + + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')): + return match.group(0) + + # assets/로 시작하멎 /assets/로 변환 (Flask 서빙) + if original_path.startswith('assets/'): + return f'src="/{original_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + +def inject_template_css(html_content: str, template_css: str) -> str: + """ + HTML에 템플늿 CSS 죌입 + - 태귞 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태귞 뒀에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없윌멎 ë§š 앞에 추가 + else: + return f'\n{html_content}' + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + 읎제 싀제 step듀을 혞출핚 + """ + try: + processed_html = convert_image_paths(html_content) + + folder_path = options.get('folder_path', '') + write_mode = options.get('write_mode', 'restructure') + + if not folder_path: + # 폮더 없윌멎 HTML만윌로 처늬 (Ʞ졎 로직) + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + + # ★ 파읎프띌읞 싀행은 /api/generate-toc → /api/generate-report-from-toc 에서 처늬 + # router는 여전히 HTML 통곌 역할 유지 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html, + 'needs_pipeline': True # ← 프론튞에서 ë¶„êž° 판닚용 + } + + except Exception as e: + return {'success': False, 'error': str(e), 'pipeline': 'long'} + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + # ⭐ 템플늿 CSS 죌입 + template_css = options.get('template_css') + if template_css and result.get('success') and result.get('html'): + result['html'] = inject_template_css(result['html'], template_css) + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_10th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..d15f2dc --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\in" + OUTPUT_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_10th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..9e9554f --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_10th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..29a5547 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_10th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_10th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..b1309cf --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_10th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..0525082 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_10th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_10th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..4a3cb3e --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step7_index.py b/03. Code/geulbeot_10th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..4f40baf --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
구분번혞제목킀워드
대목찚{num}{title}
쀑목찚{num}{title}
소목찚{num}{title}{kw}
+ """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
+ + +
+

{html_escape(report_title)}

+
+
+ +
+
+
확정 목찚 표 형태 정늬볞
+
+
목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
+ +
+
목찚
+ {table_html} +
+
+ +
+ + + +
+
+ """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_10th/converters/pipeline/step8_content.py b/03. Code/geulbeot_10th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..4330251 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/pipeline/step9_html.py b/03. Code/geulbeot_10th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..9e20780 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
    '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
    ') + lines.append(f'
  • {item.number}. {item.title}
  • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
  • {item.number} {item.title}
  • ') + elif item.level == 3: + lines.append(f'
  • {item.number} {item.title}
  • ') + + if current_l1 is not None: + lines.append('
    ') # 마지막 귞룹 ë‹«êž° + + lines.append('
') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
처늬 + cell = cell.replace('
', '
') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
{cell}
{cell}
') + + # 캡션 추가 + if caption: + html_lines.append(f'
{caption}
') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
+ {caption} +
{caption_text}
+
''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
    ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
  • {content}
  • ') + elif ol_match: + if not in_list: + html_lines.append('
      ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
    1. {content}
    2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

      변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

      {text}

      ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

      {title}

      ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

      {title}

      ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

      {h3_match.group(1)}

      ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

      {h4_match.group(1)}

      ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
      +
      {box_cover}
      +
      {box_toc}
      +
      {box_summary}
      +
      {box_content}
      +
      + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

      {main_title}

      +

      {sub_title}

      +

      {cover_date}

      + {f'

      {cover_author}

      ' if cover_author else ''} + {f'

      {cover_dept}

      ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

      요앜

      \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_10th/converters/style_analyzer.py b/03. Code/geulbeot_10th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_10th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
      + +
      +

      1 DX 개요와 Ʞ볞 개념

      +

      1.1 잡량 DX 프레임

      +

      1.1.1 잡량 DX 발전 닚계

      +

      1) Digitization 정의

      +

      볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

      +

      (1) 닚계별 정의 및 진화

      +

      잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

      +
      + +
      + +
      + +
      +

      ① 첫 번짞 항목

      + + + + +
      표 1. 데읎터 비교
      구분낎용
      항목1섀명1
      +
      + +
      + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/__init__.py b/03. Code/geulbeot_10th/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_10th/domain/civil/dx.txt b/03. Code/geulbeot_10th/domain/civil/dx.txt new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_10th/domain/civil/general.txt b/03. Code/geulbeot_10th/domain/civil/general.txt new file mode 100644 index 0000000..12a12a3 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/general.txt @@ -0,0 +1 @@ +도레믞파솔띌시도 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/anlysis.txt b/03. Code/geulbeot_10th/domain/civil/specialties/anlysis.txt new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/bim.txt b/03. Code/geulbeot_10th/domain/civil/specialties/bim.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/bim.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/bridge.txt b/03. Code/geulbeot_10th/domain/civil/specialties/bridge.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/bridge.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/communication.txt b/03. Code/geulbeot_10th/domain/civil/specialties/communication.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/communication.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/construction.txt b/03. Code/geulbeot_10th/domain/civil/specialties/construction.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/construction.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/geotechnical.txt b/03. Code/geulbeot_10th/domain/civil/specialties/geotechnical.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/geotechnical.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/planning.txt b/03. Code/geulbeot_10th/domain/civil/specialties/planning.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/planning.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/quality_env.txt b/03. Code/geulbeot_10th/domain/civil/specialties/quality_env.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/quality_env.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/road.txt b/03. Code/geulbeot_10th/domain/civil/specialties/road.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/road.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/safety.txt b/03. Code/geulbeot_10th/domain/civil/specialties/safety.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/safety.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/schedule_cost.txt b/03. Code/geulbeot_10th/domain/civil/specialties/schedule_cost.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/schedule_cost.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/structure.txt b/03. Code/geulbeot_10th/domain/civil/specialties/structure.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/structure.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/survey.txt b/03. Code/geulbeot_10th/domain/civil/specialties/survey.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/survey.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/civil/specialties/tunnel.txt b/03. Code/geulbeot_10th/domain/civil/specialties/tunnel.txt new file mode 100644 index 0000000..3014048 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/civil/specialties/tunnel.txt @@ -0,0 +1,27 @@ +시추조사, 지반조사, 지질조사, 볎링조사, 현장조사, +시추공, 시추공별, 시추계획, 시추위치, 시추심도, 시추Ʞ, +죌상도, 지잵죌상도, 시추죌상도, 토질죌상도, +표쀀ꎀ입시험, SPT, N값, ꎀ입시험, 현장시험, +지잵, 지잵정볎, 지잵구성, 지잵분류, 지잵겜계, +토사, 점토, 싀튞, 몚래, 자갈, 혾박돌, 전석, +풍화토, 풍화암, 연암, 볎통암, 겜암, 극겜암, +지하수, 지하수위, 수위잡정, 투수시험, 현장투수시험, +토질, 토질시험, 토질역학, 토질조사, +샘플링, 불교란시료, 교란시료, 윔얎, 윔얎채췚, +지반, 지반정볎, 지반특성, 지반강도, 지반칚하, +Ʞ쎈, Ʞ쎈섀계, 말뚝Ʞ쎈, 직접Ʞ쎈, 깊은Ʞ쎈, +잡량, GPS잡량, GNSS, RTK, 토탈슀테읎션, 레벚잡량, +수치지형도, 정사영상, 수치표고몚덞, DEM, DSM, DTM, +좌표, 좌표계, TM좌표, UTM좌표, 겜위도, 지였읎드, +드론, UAV, 묎읞항공Ʞ, 항공잡량, 드론잡량, +DJI, Terra, Pix4D, 포읞튞큎띌우드, 정밀도, +성곌품, 성곌도, 볎고서, 조사볎고서, 지반조사볎고서, +위치도, 평멎도, 종닚멎도, 횡닚멎도, 닚멎도, +현장, 현장조사, 현장시험, 현장작업, 현장지시, +IoT섌서, 섌서, 자동입력, 싀시간, 웹Ʞ반, +토공, 절토, 성토, 묌량산출, 첎적계산, +도로, 도로녞선, 녞선잡량, 쀑심선, Ʞ쀀점, +신규, Ʞ졎, 추가, 수정, 검토, 승읞, +죌상도, 시추죌상도, 토질죌상도, 지잵죌상도, +신규, 신규의, Ʞ졎의, +지반조사, 지반조사업묎 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain/hwpx/__init__.py b/03. Code/geulbeot_10th/domain/hwpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_10th/domain/hwpx/hwpx_domain_guide.md b/03. Code/geulbeot_10th/domain/hwpx/hwpx_domain_guide.md new file mode 100644 index 0000000..da48039 --- /dev/null +++ b/03. Code/geulbeot_10th/domain/hwpx/hwpx_domain_guide.md @@ -0,0 +1,769 @@ +# HWP/HWPX ↔ HTML/CSS 도메읞 가읎드 + +> **목적**: HWPX에서 묞서 유형·슀타음·템플늿을 추출하거나, HTML → HWPX → HWP 변환 시 +> 하드윔딩 없읎 읎 가읎드륌 찞조하여 정확한 맀핑을 수행한닀. +> **출처**: 한Ꞁ곌컎퓚터 공식 "Ꞁ 묞서 파음 구조 5.0" (revision 1.3, 2018-11-08) +> **범위**: HWP 5.0 바읎너늬 슀펙의 개념 첎계 + HWPX XML 태귞 + HTML/CSS 맀핑 + +--- + +## 0. 묞서 형식 ꎀ계 + +``` +HWP (바읎너늬) HWPX (XML) HTML/CSS +───────────────── ───────────────────── ───────────────── +Compound File ZIP Archive 닚음 HTML 파음 +├─ FileHeader ├─ META-INF/ ├─ +├─ DocInfo │ └─ manifest.xml │ ├─ +│ (Ꞁꌎ, 슀타음, ├─ Contents/ │ └─ + + + +
      +
      +
      +
      +
      +
      + + + + + + + + +⚠ [최종 겜고 - 출력 직전 필수 확읞] +1. 원볞의 몚든 텍슀튞가 100% 포핚되었는가? +2. "..." 또는 요앜된 묞장읎 없는가? +3. 생략된 묞닚읎 당 하나도 없는가? + +위 3가지 쀑 하나띌도 위반 시, 출력을 쀑닚하고 처음부터 닀시 작성하십시였. +원볞 텍슀튞 Ꞁ자 수와 출력 텍슀튞 Ꞁ자 수가 동음핎알 합니닀. \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain_api.py b/03. Code/geulbeot_10th/domain_api.py new file mode 100644 index 0000000..6d28d48 --- /dev/null +++ b/03. Code/geulbeot_10th/domain_api.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +""" +domain_api.py + +도메읞 지식 ꎀ늬 API + 파읎프띌읞 래퍌 +app.py에서 import하여 사용 + +사용법 (app.py): + from domain_api import register_domain_routes + register_domain_routes(app) +""" + +import os +import json +from pathlib import Path +from flask import request, jsonify + +# ===== 겜로 섀정 ===== +# app.py와 같은 레벚에 domains/ 폎더가 있닀고 가정 +BASE_DIR = Path(__file__).parent +DOMAIN_CONFIG_PATH = BASE_DIR / "domain_config.json" +DOMAIN_DIR = BASE_DIR / "domain" + +# 파읎프띌읞 출력 겜로 (step3~9가 사용하는 겜로) +# 싀제 환겜에 맞게 수정 필요 +PIPELINE_OUTPUT_ROOT = Path(os.getenv( + "PIPELINE_OUTPUT_ROOT", + r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out" +)) +CONTEXT_DIR = PIPELINE_OUTPUT_ROOT / "context" + + +def register_domain_routes(app): + """Flask 앱에 도메읞 ꎀ렚 띌우튞 등록""" + + @app.route('/api/domain-config', methods=['GET']) + def get_domain_config(): + """도메읞 구조 섀정 반환""" + try: + if DOMAIN_CONFIG_PATH.exists(): + config = json.loads(DOMAIN_CONFIG_PATH.read_text(encoding='utf-8')) + + # 각 도메읞 파음 졎재 여부 첎크 + for cat in config.get('categories', []): + if cat.get('file'): + fpath = DOMAIN_DIR / cat['file'] + cat['file_exists'] = fpath.exists() + cat['file_size'] = fpath.stat().st_size if fpath.exists() else 0 + + for child in cat.get('children', []): + if child.get('file'): + fpath = DOMAIN_DIR / child['file'] + child['file_exists'] = fpath.exists() + child['file_size'] = fpath.stat().st_size if fpath.exists() else 0 + + return jsonify(config) + else: + return jsonify({'error': 'domain_config.json not found', 'categories': []}), 404 + except Exception as e: + return jsonify({'error': str(e), 'categories': []}), 500 + + + @app.route('/api/domain-combine', methods=['POST']) + def combine_domains(): + """ + 선택된 도메읞 .txt 파음듀을 합쳐서 domain_prompt.txt로 저장 + + 요청: + { "selected": ["civil_general", "survey", "bim"] } + + 응답: + { "success": true, "combined_length": 3200, "selected_names": [...] } + """ + try: + data = request.get_json() + selected_ids = data.get('selected', []) + + if not selected_ids: + return jsonify({ + 'success': True, + 'combined_length': 0, + 'selected_names': [], + 'message': '선택 없음 - step3 자동 분석 몚드' + }) + + # config 로드 + config = json.loads(DOMAIN_CONFIG_PATH.read_text(encoding='utf-8')) + + # 선택된 ID → 파음 겜로 + 읎늄 맀핑 + domain_parts = [] + guide_parts = [] + selected_names = [] + + for cat in config.get('categories', []): + is_guide = (cat['id'] == 'report_guide') + target = guide_parts if is_guide else domain_parts + + if cat['id'] in selected_ids and cat.get('file'): + fpath = DOMAIN_DIR / cat['file'] + if fpath.exists(): + content = fpath.read_text(encoding='utf-8', errors='ignore').strip() + if content: + target.append(f"[{cat['label']}]\n{content}") + selected_names.append(cat['label']) + + for child in cat.get('children', []): + if child['id'] in selected_ids and child.get('file'): + fpath = DOMAIN_DIR / child['file'] + if fpath.exists(): + content = fpath.read_text(encoding='utf-8', errors='ignore').strip() + if content: + target.append(f"[{child['label']}]\n{content}") + selected_names.append(child['label']) + selected_names.append(child['label']) + + if not domain_parts and not guide_parts: + return jsonify({ + 'success': False, + 'error': '선택된 도메읞의 지식 파음읎 비얎있습니닀.' + }) + + sep = "\n\n" + "=" * 50 + "\n\n" + sections = [] + + if domain_parts: + domain_names = [n for n in selected_names if n not in ['목찚 구성 가읎드', '볎고서 묞첎 가읎드']] + sections.append( + f"너는 닀음 분알의 전묞가읎닀: {', '.join(domain_names)}.\n" + f"닀음의 도메읞 지식을 Ʞ반윌로, 사싀에 귌거하여 전묞적읎고 정확한 낎용을 작성하띌.\n" + f"추잡읎나 찜작은 ꞈ지하며, 제공된 귌거 자료의 원묞을 최대한 볎졎하띌.\n\n" + f"[도메읞 전묞 지식]\n" + sep.join(domain_parts) + ) + + if guide_parts: + sections.append( + f"[볎고서 작성 가읎드]\n" + f"닀음 가읎드륌 찞고하여 볎고서의 목찚 구성곌 묞첎륌 결정하띌.\n\n" + + sep.join(guide_parts) + ) + + final_text = ("\n\n" + "=" * 60 + "\n\n").join(sections) + + # report_guide는 항상 자동 죌입 (사용자 선택 아님) + guide_dir = DOMAIN_DIR / 'report_guide' + if guide_dir.exists(): + guide_texts = [] + for gf in sorted(guide_dir.glob('*.txt')): + content = gf.read_text(encoding='utf-8', errors='ignore').strip() + if content: + guide_texts.append(content) + if guide_texts: + guide_sep = "\n\n" + "=" * 50 + "\n\n" + final_text += ( + "\n\n" + "=" * 60 + "\n\n" + "[볎고서 작성 가읎드]\n" + "닀음 가읎드륌 찞고하여 볎고서의 목찚 구성곌 묞첎륌 결정하띌.\n\n" + + guide_sep.join(guide_texts) + ) + + # domain_prompt.txt로 저장 + CONTEXT_DIR.mkdir(parents=True, exist_ok=True) + output_path = CONTEXT_DIR / "domain_prompt.txt" + output_path.write_text(final_text, encoding='utf-8') + + return jsonify({ + 'success': True, + 'combined_length': len(final_text), + 'selected_names': selected_names, + 'selected_ids': selected_ids, + 'output_path': str(output_path) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + + @app.route('/api/domain-list', methods=['GET']) + def list_domain_files(): + """ + domains/ 폎더의 .txt 파음 목록 반환 + 도메읞 지식 파음 ꎀ늬용 + """ + try: + files = [] + + if DOMAIN_DIR.exists(): + for f in sorted(DOMAIN_DIR.rglob('*.txt')): + rel = f.relative_to(DOMAIN_DIR) + files.append({ + 'path': str(rel), + 'name': f.stem, + 'size': f.stat().st_size, + 'preview': f.read_text(encoding='utf-8', errors='ignore')[:200] + }) + + return jsonify({ + 'success': True, + 'files': files, + 'domains_dir': str(DOMAIN_DIR) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + + @app.route('/api/domain-save', methods=['POST']) + def save_domain_file(): + """ + 도메읞 지식 파음 저장/수정 + + 요청: + { "id": "survey", "content": "잡량 분알의 전묞 지식..." } + """ + try: + data = request.get_json() + domain_id = data.get('id', '') + content = data.get('content', '') + + if not domain_id or not content: + return jsonify({'success': False, 'error': 'id와 content가 필요합니닀.'}) + + # config에서 파음 겜로 ì°Ÿêž° + config = json.loads(DOMAIN_CONFIG_PATH.read_text(encoding='utf-8')) + file_path = None + + for cat in config.get('categories', []): + if cat['id'] == domain_id: + file_path = cat.get('file') + break + for child in cat.get('children', []): + if child['id'] == domain_id: + file_path = child.get('file') + break + if file_path: + break + + if not file_path: + return jsonify({'success': False, 'error': f'도메읞 ID륌 찟을 수 없습니닀: {domain_id}'}) + + # 파음 저장 + full_path = BASE_DIR / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content, encoding='utf-8') + + return jsonify({ + 'success': True, + 'path': str(full_path), + 'size': len(content) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + + @app.route('/api/pipeline/status', methods=['GET']) + def pipeline_status(): + """파읎프띌읞 상태 확읞 - 각 step의 출력 파음 졎재 여부""" + try: + status = { + 'step3_domain': (CONTEXT_DIR / 'domain_prompt.txt').exists(), + 'step4_chunks': len(list((PIPELINE_OUTPUT_ROOT / 'rag').glob('*_chunks.json'))) if (PIPELINE_OUTPUT_ROOT / 'rag').exists() else 0, + 'step5_faiss': (PIPELINE_OUTPUT_ROOT / 'rag' / 'faiss.index').exists(), + 'step6_corpus': (CONTEXT_DIR / 'corpus.txt').exists(), + 'step7_outline': (CONTEXT_DIR / 'outline_issue_report.txt').exists(), + 'step8_report': (PIPELINE_OUTPUT_ROOT / 'generated' / 'report_draft.md').exists(), + 'step9_html': (PIPELINE_OUTPUT_ROOT / 'generated' / 'report.html').exists(), + } + + return jsonify({'success': True, 'status': status}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + + # ===== 파읎프띌읞 싀행 API ===== + + @app.route('/api/generate-toc', methods=['POST']) + def generate_toc(): + """ + 목찚 생성 API (step3 → 4 → 5 → 6 → 7) + + 도메읞 선택을 한 겜우: step3 슀킵 (읎믞 domain_prompt.txt 있음) + 도메읞 선택 안 한 겜우: step3 싀행 + + 요청: + { + "folder_path": "D:\\...", + "domain_selected": true/false, + "selected_domains": ["civil_general", "survey"] + } + + 응답: + { + "success": true, + "title": "볎고서 제목", + "toc_items": [ + { "num": "1.1.1", "title": "...", "guide": "...", "keywords": [...] } + ] + } + """ + try: + data = request.get_json() + folder_path = data.get('folder_path', '') + domain_selected = data.get('domain_selected', False) + + # TODO: 싀제 step 싀행 연결 + # 현재는 목찚 파음읎 읎믞 있윌멎 읜얎서 반환 + + outline_path = CONTEXT_DIR / 'outline_issue_report.txt' + + if outline_path.exists(): + # Ʞ졎 목찚 파싱 + toc_items = parse_outline_for_frontend(outline_path) + return jsonify({ + 'success': True, + 'title': toc_items[0].get('report_title', '볎고서') if toc_items else '볎고서', + 'toc_items': toc_items, + 'source': 'cached' + }) + else: + return jsonify({ + 'success': False, + 'error': '목찚 파음읎 아직 생성되지 않았습니닀. 파읎프띌읞을 뚌저 싀행핎죌섞요.', + 'hint': 'step3~7을 순서대로 싀행핎알 합니닀.' + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/generate-report-from-toc', methods=['POST']) + def generate_report_from_toc(): + """ + 펞집된 목찚로 볎고서 생성 (step8 → step9) + + 요청: + { + "toc_items": [...], # 펞집된 목찚 + "write_mode": "restructure", + "instruction": "..." + } + """ + try: + data = request.get_json() + toc_items = data.get('toc_items', []) + write_mode = data.get('write_mode', 'restructure') + instruction = data.get('instruction', '') + + # TODO: step8 싀행 (generate_report_gemini) + # TODO: step9 싀행 (md_to_html_publisher) + + # 현재는 Ʞ졎 generated 파음읎 있윌멎 반환 + report_html_path = PIPELINE_OUTPUT_ROOT / 'generated' / 'report.html' + + if report_html_path.exists(): + html = report_html_path.read_text(encoding='utf-8') + return jsonify({ + 'success': True, + 'html': html, + 'source': 'cached' + }) + else: + return jsonify({ + 'success': False, + 'error': '볎고서 파음읎 아직 생성되지 않았습니닀.' + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + + + @app.route('/api/check-folder', methods=['POST']) + def check_folder(): + """폮더 겜로의 파음 목록 + 확장자별 분류""" + try: + data = request.get_json() + folder = Path(data.get('folder_path', '')) + + if not folder.exists() or not folder.is_dir(): + return jsonify({'success': False, 'error': '폎더륌 찟을 수 없습니닀.'}) + + SUPPORTED = {'.hwpx', '.hwp', '.pdf', '.docx', '.xlsx', '.pptx', '.txt', '.csv', 'md', 'json','img', 'png', 'html'} + + all_files = [f for f in folder.rglob('*') if f.is_file()] + ok_files = [f for f in all_files if f.suffix.lower() in SUPPORTED] + unknown_files = [f for f in all_files if f.suffix.lower() not in SUPPORTED] + + return jsonify({ + 'success': True, + 'total': len(all_files), + 'ok': len(ok_files), + 'unknown': len(unknown_files), + 'ok_list': [{'name': f.name, 'size': f.stat().st_size} for f in ok_files], + 'unknown_list': [f.name for f in unknown_files] + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def parse_outline_for_frontend(outline_path: Path) -> list: + """ + outline_issue_report.txt륌 파싱하여 + displayTocWithAnimation() 형식윌로 변환 + + 반환 형식: + [ + { + "num": "1.1.1", + "title": "소목찚 제목", + "guide": "집필 가읎드", + "keywords": ["킀워드1", "킀워드2"] + } + ] + """ + import re + + raw = outline_path.read_text(encoding='utf-8', errors='ignore').splitlines() + if not raw: + return [] + + report_title = raw[0].strip() + items = [] + + re_l3_head = re.compile(r'^\s*(\d+\.\d+\.\d+)\s+(.+)$') + re_l3_topic = re.compile(r'^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$') + re_keywords = re.compile(r'(#\S+)') + + current_l3 = None + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = { + 'num': m3h.group(1), + 'title': m3h.group(2), + 'report_title': report_title, + 'guide': '', + 'keywords': [] + } + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip('#').strip() for k in re_keywords.findall(m3t.group(2))] + # Ʞ졎 킀워드에 추가 + current_l3['keywords'].extend(kws) + # 가읎드 누적 + if current_l3['guide']: + current_l3['guide'] += ' / ' + current_l3['guide'] += m3t.group(4) + + return items \ No newline at end of file diff --git a/03. Code/geulbeot_10th/domain_config.json b/03. Code/geulbeot_10th/domain_config.json new file mode 100644 index 0000000..fbb8500 --- /dev/null +++ b/03. Code/geulbeot_10th/domain_config.json @@ -0,0 +1,46 @@ +{ + "version": "1.0", + "description": "Ꞁ벗 도메읞 지식 선택 구조", + "base_dir": "domain", + "categories": [ + { + "id": "civil_general", + "label": "토목 음반", + "icon": "🏗", + "description": "토목공학 Ʞ쎈 읎론, 섀계 Ʞ쀀, 시방서 등", + "file": "civil/general.txt", + "children": [] + }, + { + "id": "civil_dx", + "label": "DX (디지턞 전환)", + "icon": "💡", + "description": "걎섀 디지턞 전환, 슀마튞 걎섀, AI/IoT 적용", + "file": "civil/dx.txt", + "children": [] + }, + { + "id": "civil/specialties", + "label": "섞부 분알별", + "icon": "📐", + "description": "전묞 분알륌 선택하섞요 (복수 선택 가능)", + "file": null, + "children": [ + { "id": "survey", "label": "잡량", "group": "Ʞ반Ʞ술", "file": "civil/specialties/survey.txt" }, + { "id": "analysis", "label": "핎석", "group": "Ʞ반Ʞ술", "file": "civil/specialties/analysis.txt" }, + { "id": "communication", "label": "컀뮀니쌀읎션", "group": "Ʞ반Ʞ술", "file": "civil/specialties/communication.txt" }, + { "id": "road", "label": "도로", "group": "구조", "file": "civil/specialties/road.txt" }, + { "id": "structure", "label": "구조묌", "group": "구조", "file": "civil/specialties/structure.txt" }, + { "id": "geotechnical", "label": "지반", "group": "구조", "file": "civil/specialties/geotechnical.txt" }, + { "id": "planning", "label": "계획", "group": "구조", "file": "civil/specialties/planning.txt" }, + { "id": "bridge", "label": "교량", "group": "시섀묌", "file": "civil/specialties/bridge.txt" }, + { "id": "tunnel", "label": "터널", "group": "시섀묌", "file": "civil/specialties/tunnel.txt" }, + { "id": "construction", "label": "시공", "group": "ꎀ늬", "file": "civil/specialties/construction.txt" }, + { "id": "bim", "label": "BIM", "group": "ꎀ늬", "file": "civil/specialties/bim.txt" }, + { "id": "schedule_cost", "label": "공정 및 Ʞ성", "group": "ꎀ늬", "file": "civil/specialties/schedule_cost.txt" }, + { "id": "safety", "label": "안전ꎀ늬", "group": "ꎀ늬", "file": "civil/specialties/safety.txt" }, + { "id": "quality_env", "label": "품질환겜", "group": "ꎀ늬", "file": "civil/specialties/quality_env.txt" } + ] + } + ] +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/__init__.py b/03. Code/geulbeot_10th/handlers/__init__.py new file mode 100644 index 0000000..d6141c9 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +""" +handlers 팚킀지 +묞서 유형별 처늬 로직을 분늬하여 ꎀ늬 +""" + +from .template.doc_template_analyzer import DocTemplateAnalyzer + diff --git a/03. Code/geulbeot_10th/handlers/briefing/__init__.py b/03. Code/geulbeot_10th/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 몚듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/briefing/processor.py b/03. Code/geulbeot_10th/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 로직 +- 1~2페읎지 압축형 볎고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """Ʞ획서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1닚계: 구조 추출 프롬프튞""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5닚계: 배치 계획 프롬프튞""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + + def _get_step2_prompt(self) -> str: + """2닚계: HTML 생성 프롬프튞""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """Ʞ획서 생성""" + try: + if not content.strip(): + return {'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '쎝ꎄꞰ획싀') + additional_prompt = options.get('instruction', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞, 2페읎지는 [첚부]입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부] 형태로 분할합니닀.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝 + if self._content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_5_plan.txt b/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_extract.txt b/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_10th/handlers/briefing/prompts/step2_generate.txt b/03. Code/geulbeot_10th/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
      + +
      +

      {{title}}

      +
      +
      +
      +
      +
      {{lead.text}} - 킀워드 강조
      +
      + +
      +
      {{conclusion.label}}
      +
      {{conclusion.text}}
      +
      +
      +
      - 1 -
      +
      + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
      +
      {{section.title}}
      +
        +
      • {{item.keyword}}: {{item.text}} {{highlight}}
      • +
      +
      +``` + +### table → data-table +```html +
      +
      {{section.title}}
      + + + + + + + + + + + + + +
      {{col1}}{{col2}}
      {{text}}{{text}}
      +
      +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
      +
      {{section.title}}
      +
      +
      +
      {{item.title}}
      +

      {{item.text}} {{highlight}}

      +
      +
      +
      +``` + +### two-column → two-col +```html +
      +
      {{section.title}}
      +
      +
      +
      {{item.title}}
      +

      {{item.text}} {{highlight}}

      +
      +
      +
      +``` + +### process → process-container +```html +
      +
      {{section.title}}
      +
      +
      +
      {{step.number}}
      +
      {{step.title}}: {{step.text}}
      +
      +
      ▌
      + +
      +
      +``` + +### qa → qa-grid +```html +
      +
      {{section.title}}
      +
      +
      + Q. {{question}}
      + A. {{answer}} +
      +
      +
      +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

      [첚부] 핎당 낎용에 맞는 제목

      ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

      [첚부] 핎당 페읎지 낎용에 맞는 제목

      ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/common.py b/03. Code/geulbeot_10th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/doc/__init__.py b/03. Code/geulbeot_10th/handlers/doc/__init__.py new file mode 100644 index 0000000..b2e6a13 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/doc/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""doc 팚킀지 - 묞서 유형 분석/정의/생성""" \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/doc/content_analyzer.py b/03. Code/geulbeot_10th/handlers/doc/content_analyzer.py new file mode 100644 index 0000000..47ae5ed --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/doc/content_analyzer.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +""" +Content Analyzer (Phase 3 — Layer A) +- template_info + semantic_map → content_prompt.json +- 각 placeholder의 의믞/유형/예시값/작성 팹턮 추출 +- Phase 5에서 AI가 새 묞서 생성 시 "레시플"로 ì°žì¡° + +★ 원칙: 몚든 분류는 윔드 100% (AI 없음) + purpose_hint / audience_hint / tone_hint는 빈 묞자엎로 낚김 + → 추후 AI enrichment 닚계에서 채욞 수 있도록 섀계 +""" + +import re + + +def generate(template_info: dict, semantic_map: dict, + parsed: dict = None) -> dict: + """ + content_prompt.json 생성 + + Args: + template_info: doc_template_analyzer 추출 결곌 + semantic_map: semantic_mapper 분류 결곌 + parsed: HWPX 파싱 원볞 (선택) + + Returns: + content_prompt.json 구조 + """ + placeholders = {} + table_guide = {} + + # ① 묞서 Ʞ볞 정볎 + document = _analyze_document(template_info) + + # ② 헀더 placeholders + _analyze_header(template_info, placeholders) + + # ③ 푾터 placeholders + _analyze_footer(template_info, placeholders) + + # ④ 제목 placeholder + _analyze_title(template_info, semantic_map, placeholders) + + # â‘€ 섹션 placeholders + _analyze_sections(semantic_map, placeholders, template_info) + + # â‘€-b content_order êž°ë°˜ 묞닚/읎믞지 placeholders + _analyze_content_order(template_info, semantic_map, placeholders) + + # ⑥ 표 가읎드 + placeholders + _analyze_tables(template_info, semantic_map, + placeholders, table_guide) + + # ⑩ 작성 팹턮 + writing_guide = _analyze_writing_patterns(template_info, semantic_map) + + return { + "version": "1.0", + "document": document, + "placeholders": placeholders, + "table_guide": table_guide, + "writing_guide": writing_guide + } + + +# ================================================================ +# 묞서 Ʞ볞 정볎 +# ================================================================ + +def _analyze_document(template_info: dict) -> dict: + """묞서 레벚 정볎 추출""" + page = template_info.get("page", {}) + paper = page.get("paper", {}) + + return { + "paper": paper.get("name", "A4"), + "layout": "landscape" if paper.get("landscape") else "portrait", + "margins": page.get("margins", {}), + "purpose_hint": "", # AI enrichment 예앜 + "audience_hint": "", # AI enrichment 예앜 + "tone_hint": "" # AI enrichment 예앜 + } + + +# ================================================================ +# 텍슀튞 유형 분류 (윔드 100%, AI 없음) +# ================================================================ + +def _classify_text(text: str) -> dict: + """텍슀튞 팚턎윌로 윘텐잠 유형 분류""" + text = text.strip() + if not text: + return {"type": "empty", "pattern": "빈 셀"} + + # 날짜: "2025. 1. 30(ꞈ)", "2025-01-30", "2025.01.30" + if re.match(r'\d{4}[\.\-/]\s*\d{1,2}[\.\-/]\s*\d{1,2}', text): + return {"type": "date", "pattern": "날짜 (YYYY. M. D)"} + + # ★ 직꞉+읎늄 (부서볎닀 뚌저!) + positions = [ + '사원', '대늬', '곌장', '찚장', '부장', '읎사', '상묎', '전묎', + '연구원', '선임연구원', '책임연구원', '수석연구원', + '죌임', '계장', '팀장', '싀장', '부서장', '섌터장' + ] + for pos in positions: + if pos in text: + return {"type": "author", "pattern": f"읎늄 + 직꞉({pos})"} + + # 부서 (직꞉ 아닌 것만 여Ʞ로) + if re.search(r'(ì‹€|부|êµ­|곌|원|처|섌터|볞부)$', text) and len(text) <= 12: + return {"type": "department", "pattern": "조직명"} + + # 팀 + if re.search(r'팀$', text) and len(text) <= 10: + return {"type": "team", "pattern": "팀명"} + + # 페읎지 ì°žì¡°: "1p", "2p" + if re.match(r'\d+p$', text): + return {"type": "page_ref", "pattern": "페읎지 ì°žì¡°"} + + # 묞서 제목: ~계획(안), ~볎고서, ~제안서 등 + if re.search(r'(계획|볎고서|제안서|Ʞ획서|결곌|방안|현황|분석)' + r'\s*(\(안\))?\s*$', text): + return {"type": "doc_title", "pattern": "묞서 제목"} + + # 슬로걎/비전 (êžžê³  추상적 킀워드 포핚) + if len(text) > 10 and any(k in text for k in + ['핚께', '섞상', '믞래', '가치', '만듀얎']): + return {"type": "slogan", "pattern": "회사 슬로걎/비전"} + + # Ʞ볞 + return {"type": "text", "pattern": "자유 텍슀튞"} + + +# ================================================================ +# 헀더 분석 +# ================================================================ + +def _analyze_header(template_info: dict, placeholders: dict): + """헀더 영역 placeholder 분석""" + header = template_info.get("header", {}) + if not header or not header.get("exists"): + return + + if header.get("type") == "table" and header.get("table"): + _analyze_table_area(header["table"], "HEADER", "header", + placeholders) + else: + texts = header.get("texts", []) + for i in range(max(len(texts), 1)): + ph = f"HEADER_TEXT_{i+1}" + example = texts[i] if i < len(texts) else "" + info = _classify_text(example) + info["example"] = example.strip() + info["location"] = "header" + placeholders[ph] = info + + +# ================================================================ +# 푾터 분석 +# ================================================================ + +def _analyze_footer(template_info: dict, placeholders: dict): + """푾터 영역 placeholder 분석""" + footer = template_info.get("footer", {}) + if not footer or not footer.get("exists"): + return + + if footer.get("type") == "table" and footer.get("table"): + _analyze_table_area(footer["table"], "FOOTER", "footer", + placeholders) + else: + placeholders["PAGE_NUMBER"] = { + "type": "page_number", + "pattern": "페읎지 번혞", + "example": "1", + "location": "footer" + } + + +# ================================================================ +# 헀더/푾터 공통: 표 형태 영역 분석 +# ================================================================ + +def _analyze_table_area(tbl: dict, prefix: str, location: str, + placeholders: dict): + """표 형태의 헀더/푾터 → placeholder 맀핑 + + Args: + tbl: header["table"] 또는 footer["table"] + prefix: "HEADER" 또는 "FOOTER" + location: "header" 또는 "footer" + placeholders: 결곌 dict (in-place 수정) + """ + rows = tbl.get("rows", []) + + for r_idx, row in enumerate(rows): + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + + if len(lines) > 1: + for l_idx, line_text in enumerate(lines): + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}_LINE_{l_idx+1}" + info = _classify_text(line_text) + info["example"] = line_text.strip() + info["location"] = location + placeholders[ph] = info + elif lines: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(lines[0]) + info["example"] = lines[0].strip() + info["location"] = location + placeholders[ph] = info + else: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + placeholders[ph] = { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": location + } + + +# ================================================================ +# 제목 분석 +# ================================================================ + +def _analyze_title(template_info: dict, semantic_map: dict, + placeholders: dict): + """제목 랔록 placeholder 분석 + + ★ v1.1: template_manager._build_title_block_html()곌 동음한 + TITLE_R{r}_C{c} 명명 규칙 사용 (범용 맀핑) + """ + title_idx = semantic_map.get("title_table") + if title_idx is None: + return + + tables = template_info.get("tables", []) + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if not title_tbl: + return + + # 각 셀별로 placeholder 생성 (template곌 동음한 읎늄) + for r_idx, row in enumerate(title_tbl.get("rows", [])): + for c_idx, cell in enumerate(row): + cell_text = cell.get("text", "").strip() + if not cell_text: + continue # 빈 셀은 template에서도 placeholder 없음 + + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(cell_text) + if "title" not in info["type"] and "doc_title" not in info["type"]: + # 제목표 안의 텍슀튞가 doc_title읎 아닐 수도 있음 (부제 등) + # 가장 ꞎ 텍슀튞만 doc_title로 분류 + pass + info["example"] = cell_text + info["location"] = "title_block" + placeholders[ph_name] = info + + # 가장 ꞎ 텍슀튞륌 가진 셀을 doc_title로 마킹 + longest_ph = None + longest_len = 0 + for ph_key in list(placeholders.keys()): + if ph_key.startswith("TITLE_R"): + ex = placeholders[ph_key].get("example", "") + if len(ex) > longest_len: + longest_len = len(ex) + longest_ph = ph_key + if longest_ph: + placeholders[longest_ph]["type"] = "doc_title" + placeholders[longest_ph]["pattern"] = "묞서 제목" + + +# ================================================================ +# 섹션 분석 +# ================================================================ + +def _analyze_sections(semantic_map: dict, placeholders: dict, + template_info: dict = None): + """섹션 placeholder 분석. + + content_order에 묞닚읎 있윌멎 SECTION_n_CONTENT는 생략 + (개별 PARA_n읎 볞묞 역할을 대신핚). + """ + sections = semantic_map.get("sections", []) + + # content_order에 묞닚읎 있윌멎 개별 PARA_n읎 볞묞 닎당 → CONTENT 불필요 + has_co_paragraphs = False + if template_info: + co = template_info.get("content_order", []) + has_co_paragraphs = any(c['type'] == 'paragraph' for c in co) if co else False + + if not sections: + placeholders["SECTION_1_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": "", "location": "body" + } + if not has_co_paragraphs: + placeholders["SECTION_1_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + return + + for i, sec in enumerate(sections): + s_num = i + 1 + title_text = sec if isinstance(sec, str) else sec.get("title", "") + + placeholders[f"SECTION_{s_num}_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": title_text, "location": "body" + } + if not has_co_paragraphs: + placeholders[f"SECTION_{s_num}_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + +# ================================================================ +# content_order êž°ë°˜ 묞닚/읎믞지 분석 (v5.2+) +# ================================================================ + +def _analyze_content_order(template_info: dict, semantic_map: dict, + placeholders: dict): + """content_order의 paragraph/image → PARA_n, IMAGE_n placeholder 생성. + + content_order가 없거나 묞닚읎 없윌멎 아묎것도 안 핹 (legacy 혾환). + """ + content_order = template_info.get("content_order") + if not content_order: + return + if not any(c['type'] == 'paragraph' for c in content_order): + return + + # 섹션 제목 팹턮 (template_manager와 동음) + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + para_num = 0 + img_num = 0 + section_num = 0 + + for item in content_order: + itype = item['type'] + + if itype == 'empty': + continue + + # ── 표: _analyze_tables에서 처늬 → 걎너뛰Ʞ ── + if itype == 'table': + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + placeholders[f"IMAGE_{img_num}"] = { + "type": "image", + "pattern": "읎믞지", + "example_ref": item.get("binaryItemIDRef", ""), + "location": "body" + } + caption = item.get("text", "") + if caption: + placeholders[f"IMAGE_{img_num}_CAPTION"] = { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": caption, + "location": "body" + } + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + + # 섹션 제목 → SECTION_n_TITLE (읎믞 _analyze_sections에서 등록됐을 수 있음) + if any(p.match(text) for p in sec_patterns): + section_num += 1 + ph = f"SECTION_{section_num}_TITLE" + if ph not in placeholders: + placeholders[ph] = { + "type": "section_title", + "pattern": "섹션 제목", + "example": text, + "location": "body" + } + continue + + # 음반 묞닚 + para_num += 1 + runs = item.get('runs', []) + + if len(runs) > 1: + # 닀쀑 run → 각 run별 placeholder + for r_idx, run in enumerate(runs): + ph = f"PARA_{para_num}_RUN_{r_idx+1}" + run_text = run.get("text", "") + info = _classify_text(run_text) + info["example"] = run_text[:100] if len(run_text) > 100 else run_text + info["location"] = "body" + info["run_index"] = r_idx + 1 + placeholders[ph] = info + else: + ph = f"PARA_{para_num}" + info = _classify_text(text) + info["example"] = text[:100] if len(text) > 100 else text + info["location"] = "body" + placeholders[ph] = info + + +# ================================================================ +# 표 분석 → placeholder + 표 가읎드 +# ================================================================ + +def _analyze_tables(template_info: dict, semantic_map: dict, + placeholders: dict, table_guide: dict): + """볞묞 데읎터 표 → placeholder + table_guide""" + tables = template_info.get("tables", []) + body_indices = semantic_map.get("body_tables", []) + table_roles = semantic_map.get("table_roles", {}) + + for tbl_num_0, tbl_idx in enumerate(body_indices): + tbl_num = tbl_num_0 + 1 + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + role_info = table_roles.get(tbl_idx, table_roles.get(str(tbl_idx), {})) + col_headers = role_info.get("col_headers", []) + col_cnt = len(col_headers) if col_headers else tbl.get("colCnt", 0) + + # ── 헀더 placeholder ── + for c_idx, h_text in enumerate(col_headers): + ph = f"TABLE_{tbl_num}_H_C{c_idx+1}" + placeholders[ph] = { + "type": "table_header", "pattern": "표 ì—Ž 제목", + "example": h_text, "location": f"table_{tbl_num}" + } + + # ── BODY placeholder ── + placeholders[f"TABLE_{tbl_num}_BODY"] = { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML 반복)", + "example": "", + "location": f"table_{tbl_num}" + } + + # ── 표 가읎드 ── + table_guide[str(tbl_num)] = { + "col_headers": col_headers, + "col_count": col_cnt, + "row_count": tbl.get("rowCnt", 0), + "merge_pattern": _detect_merge_pattern(tbl), + "bullet_chars": _detect_bullet_chars(tbl), + "example_rows": _extract_example_rows(tbl, role_info), + "col_types": _classify_columns(col_headers), + "row_bf_pattern": _extract_row_bf_pattern(tbl, role_info), + } + + +def _detect_merge_pattern(tbl: dict) -> dict: + """셀 병합 팹턮 감지""" + pattern = {} + for row in tbl.get("rows", []): + for cell in row: + col = cell.get("colAddr", 0) + if cell.get("rowSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "row_group") + if cell.get("colSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "col_span") + return pattern + + +def _detect_bullet_chars(tbl: dict) -> list: + """표 셀 텍슀튞에서 불늿 묞자 감지""" + bullets = set() + pats = [ + (r'^-\s', '- '), (r'^·\s', '· '), (r'^•\s', '• '), + (r'^▾\s', '▾ '), (r'^▶\s', '▶ '), (r'^※\s', '※ '), + (r'^◈\s', '◈ '), (r'^○\s', '○ '), (r'^●\s', '● '), + ] + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + for pat, char in pats: + if re.match(pat, line.strip()): + bullets.add(char) + return sorted(bullets) + + +def _extract_example_rows(tbl: dict, role_info: dict) -> list: + """데읎터 행에서 예시 최대 3행 추출""" + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + examples = [] + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + row_data = [] + for cell in row: + text = cell.get("text", "").strip() + if len(text) > 80: + text = text[:77] + "..." + row_data.append(text) + examples.append(row_data) + if len(examples) >= 3: + break + return examples + + +def _classify_columns(col_headers: list) -> list: + """ì—Ž 헀더 킀워드로 용도 추론""" + type_map = { + "category": ['구분', '분류', '항목', '칎테고늬'], + "content": ['낎용', '섀명', '상섞', '섞부낎용'], + "note": ['비고', 'ì°žê³ ', 'Ʞ타', '메몚'], + "date": ['날짜', '음자', '음시', 'êž°ê°„'], + "person": ['닎당', '닎당자', '작성자', '책임'], + "number": ['수량', 'ꞈ액', '닚가', '합계'], + } + result = [] + for c_idx, header in enumerate(col_headers): + h = header.strip() + col_type = "text" + for t, keywords in type_map.items(): + if h in keywords: + col_type = t + break + result.append({"col": c_idx, "type": col_type, "header": h}) + return result + +def _extract_row_bf_pattern(tbl: dict, role_info: dict) -> list: + """첫 데읎터행의 셀별 borderFillIDRef → 엎별 bf class 팹턮. + + AI가 TABLE_BODY 생성 시 class="bf-{id}" 적용하도록 안낎. + 예: [{"col": 0, "bf_class": "bf-12"}, {"col": 1, "bf_class": "bf-8"}, ...] + """ + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + # 첫 데읎터행 ì°Ÿêž° + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + pattern = [] + for cell in row: + bf_id = cell.get("borderFillIDRef") + pattern.append({ + "col": cell.get("colAddr", len(pattern)), + "bf_class": f"bf-{bf_id}" if bf_id else "", + "colSpan": cell.get("colSpan", 1), + "rowSpan": cell.get("rowSpan", 1), + }) + return pattern + + return [] +# ================================================================ +# 작성 팹턮 분석 +# ================================================================ + +def _analyze_writing_patterns(template_info: dict, + semantic_map: dict) -> dict: + """묞서 전첎의 작성 팹턮 분석""" + result = { + "bullet_styles": [], + "numbering_patterns": [], + "avg_line_length": 0, + "font_primary": "", + "font_size_body": "" + } + + # ── 불늿 수집 (몚든 표 텍슀튞) ── + all_bullets = set() + tables = template_info.get("tables", []) + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if re.match(r'^[-·•▞▶※◈○●]\s', line.strip()): + all_bullets.add(line.strip()[0] + " ") + + # ── numbering tools 데읎터 ── + numbering = template_info.get("numbering", {}) + for num in numbering.get("numberings", []): + levels = num.get("levels", []) + patterns = [lv.get("pattern", "") for lv in levels[:3]] + if patterns: + result["numbering_patterns"].append(patterns) + + for b in numbering.get("bullets", []): + char = b.get("char", "") + if char: + all_bullets.add(char + " ") + + result["bullet_styles"] = sorted(all_bullets) + +# ── 평균 띌읞 Ꞟ읎 ── + lengths = [] + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if line.strip(): + lengths.append(len(line.strip())) + + # content_order 묞닚 텍슀튞도 포핚 + content_order = template_info.get("content_order", []) + for item in content_order: + if item['type'] == 'paragraph': + text = item.get('text', '').strip() + if text: + lengths.append(len(text)) + # 불늿 감지도 추가 + if re.match(r'^[-·•▞▶※◈○●]\s', text): + all_bullets.add(text[0] + " ") + + if lengths: + result["avg_line_length"] = round(sum(lengths) / len(lengths)) + + # ── 죌요 폰튾 ── + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + result["font_primary"] = hangul[0].get("face", "") + + # ── 볞묞 Ꞁ자 크Ʞ (char_styles id=0 Ʞ볞) ── + char_styles = template_info.get("char_styles", []) + if char_styles: + result["font_size_body"] = f"{char_styles[0].get('height_pt', 10)}pt" + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/doc/custom_doc_type.py b/03. Code/geulbeot_10th/handlers/doc/custom_doc_type.py new file mode 100644 index 0000000..488c40b --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/doc/custom_doc_type.py @@ -0,0 +1,555 @@ +# -*- coding: utf-8 -*- +""" +사용자 정의 묞서 유형 프로섞서 (v2.1 - 템플늿 êž°ë°˜) +- template.html 로드 +- config.json의 구조/가읎드 활용 +- 사용자 입력 낎용을 템플늿에 정늬하여 채움 +- 찜작 X, 정늬/재구성 O + +★ v2.1 변겜사항: +- 한Ꞁ 포핚 placeholder 지원 (TABLE_1_H_구분 등) +- TABLE_*_BODY / TABLE_*_H_* placeholder 구분 처늬 +- 개조식 항목
        래핑 +- 페읎지 분량 제한 프롬프튞 강화 +- 헀더/푾터 닀쀑행 placeholder 섀명 추가 +""" + +import json +import re +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from handlers.template.template_manager import TemplateManager +from pathlib import Path +from handlers.common import call_claude, extract_html + + +# ★ 한Ꞁ 포핚 placeholder 정규식 (영묞 + 숫자 + 얞더슀윔얎 + 한Ꞁ) +PH_PATTERN = re.compile(r'\{\{([A-Za-z0-9_\uAC00-\uD7AF]+)\}\}') + + +class CustomDocTypeProcessor: + """사용자 정의 묞서 유형 처늬Ʞ (템플늿 êž°ë°˜)""" + + def __init__(self): + self.doc_types_user = Path('templates/user/doc_types') + self.template_manager = TemplateManager() + + def load_config(self, doc_type_id: str) -> dict: + """config.json 로드""" + config_path = self.doc_types_user / doc_type_id / 'config.json' + if not config_path.exists(): + raise FileNotFoundError(f"묞서 유형을 찟을 수 없습니닀: {doc_type_id}") + + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + + def load_content_prompt(self, doc_type_id: str, template_id: str = None) -> dict: + """content_prompt.json 로드 (doc_type 우선 → template fallback)""" + # ① doc_type 폮더 + path = self.doc_types_user / doc_type_id / 'content_prompt.json' + if path.exists(): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + # ② template 폮더 fallback + if template_id: + tpl_path = Path('templates/user/templates') / template_id / 'content_prompt.json' + if tpl_path.exists(): + with open(tpl_path, 'r', encoding='utf-8') as f: + return json.load(f) + + return {} + + def load_template(self, doc_type_id: str) -> str: + """template.html 로드 — template_manager 겜유 (분늬 구조)""" + # ① config에서 template_id 확읞 + config = self.load_config(doc_type_id) + tpl_id = config.get("template_id") + + if tpl_id: + # ★ 새 구조: template_manager에서 로드 + tpl_data = self.template_manager.load_template(tpl_id) + if "html" in tpl_data: + return tpl_data["html"] + + # ★ 하위 혾환: 레거시 방식 (같은 폎더의 template.html) + template_path = self.doc_types_user / doc_type_id / 'template.html' + if template_path.exists(): + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + + return None + + def generate(self, content: str, doc_type_id: str, options: dict = None, + image_data: dict = None) -> dict: + """묞서 생성 - 템플늿 + 사용자 입력 + + Args: + content: 사용자 입력 텍슀튞 + doc_type_id: 묞서 유형 ID + options: 추가 옵션 (instruction 등) + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + None읎멎 템플늿 폎더에서 자동 로드 시도 + """ + try: + config = self.load_config(doc_type_id) + template = self.load_template(doc_type_id) + + if template: + # 읎믞지 데읎터 쀀비 + if image_data is None: + image_data = self._load_image_data(config) + result = self._generate_with_template( + content, config, template, options, image_data + ) + else: + result = self._generate_with_guide(content, config, options) + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _generate_with_template(self, content: str, config: dict, + template: str, options: dict, + image_data: dict = None) -> dict: + """템플늿 êž°ë°˜ 생성 — content_prompt.json 활용""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + instruction = options.get('instruction', '') if options else '' + + # ★ content_prompt 로드 + doc_type_id = config.get('id', '') + template_id = config.get('template_id', '') + cp = self.load_content_prompt(doc_type_id, template_id) + + placeholders_info = cp.get('placeholders', {}) + table_guide = cp.get('table_guide', {}) + writing_guide = cp.get('writing_guide', {}) + doc_info = cp.get('document', {}) + + # ★ placeholder 가읎드 생성 (type/pattern/example 포핚) + ph_guide_lines = [] + for ph_key, ph_info in placeholders_info.items(): + ph_type = ph_info.get('type', 'text') + pattern = ph_info.get('pattern', '') + example = ph_info.get('example', '') + location = ph_info.get('location', '') + + line = f" {ph_key}:" + line += f"\n type: {ph_type}" + line += f"\n pattern: {pattern}" + if example: + line += f"\n example: \"{example}\"" + line += f"\n location: {location}" + ph_guide_lines.append(line) + + ph_guide = "\n".join(ph_guide_lines) if ph_guide_lines else "(no guide available)" + + # ★ 표 가읎드 생성 + tbl_guide_lines = [] + for tbl_num, tbl_info in table_guide.items(): + headers = tbl_info.get('col_headers', []) + col_types = tbl_info.get('col_types', []) + merge = tbl_info.get('merge_pattern', {}) + bullets = tbl_info.get('bullet_chars', []) + examples = tbl_info.get('example_rows', []) + + tbl_guide_lines.append(f"\n### Table {tbl_num}:") + tbl_guide_lines.append(f" Columns: {json.dumps(headers, ensure_ascii=False)}") + if col_types: + for ct in col_types: + tbl_guide_lines.append( + f" Col {ct['col']} '{ct['header']}': {ct['type']}") + if merge: + tbl_guide_lines.append(f" Merge: {json.dumps(merge, ensure_ascii=False)}") + tbl_guide_lines.append( + f" → row_group means: use rowspan to group rows by that column") + if bullets: + tbl_guide_lines.append(f" Bullet chars: {bullets}") + + # ★ row_bf_pattern 추가 + bf_pattern = tbl_info.get('row_bf_pattern', []) + if bf_pattern: + tbl_guide_lines.append(f" Row cell classes (apply to each ):") + for bp in bf_pattern: + col = bp.get('col', '?') + bf_cls = bp.get('bf_class', '') + cs = bp.get('colSpan', 1) + rs = bp.get('rowSpan', 1) + span_info = "" + if cs > 1: span_info += f" colSpan={cs}" + if rs > 1: span_info += f" rowSpan={rs}" + tbl_guide_lines.append( + f' col_{col}: class="{bf_cls}"{span_info}') + + if examples: + tbl_guide_lines.append(f" Example rows:") + for ex in examples[:2]: + tbl_guide_lines.append( + f" {json.dumps(ex, ensure_ascii=False)}") + + tbl_guide = "\n".join(tbl_guide_lines) if tbl_guide_lines else "No table guide" + + # ★ 페읎지 추정 + page_estimate = structure.get('pageEstimate', 1) + + # ★ placeholder í‚€ 목록 (from template) + placeholders = PH_PATTERN.findall(template) + placeholders = list(dict.fromkeys(placeholders)) + + prompt = f"""Fill the template placeholders with reorganized content. + +## Document Definition +{context.get('documentDefinition', 'structured document')} + +## Context +- Type: {context.get('documentType', '')} +- Purpose: {context.get('purpose', '')} +- Audience: {context.get('audience', '')} +- Tone: {context.get('tone', '')} +- Layout: {doc_info.get('layout', 'portrait')} +- Page limit: {page_estimate} page(s). Be CONCISE. + +## Writing Style +- Bullet chars: {writing_guide.get('bullet_styles', ['- ', '· '])} +- Primary font: {writing_guide.get('font_primary', '')} +- Keep lines ~{writing_guide.get('avg_line_length', 25)} chars average + +## Placeholder Guide (type, pattern, example for each) +{ph_guide} + +## Table Structure Guide +{tbl_guide} + +## Input Content +{content[:6000] if content else '(empty)'} + +## Additional Instructions +{instruction if instruction else 'None'} + +## ALL Placeholders to fill (JSON keys): +{json.dumps(placeholders, ensure_ascii=False)} + +## ★ Critical Rules +1. Output ONLY valid JSON — every placeholder above as a key +2. HEADER/FOOTER: use the PATTERN and modify the EXAMPLE for new content + - department → user's department or keep example + - author → user's name or keep example + - date → today's date in same format + - slogan → keep exactly as example +3. TITLE: create title matching doc_title pattern from input content +4. TABLE_*_H_*: plain text column headers (use col_headers from guide) +5. TABLE_*_BODY: HTML rows only (no wrapper) + - Follow merge_pattern: row_group → use rowspan + - Use bullet_chars from guide inside cells + - Match example_rows structure +5b. TABLE_*_BODY \n +6. SECTION_*_CONTENT: use bullet style from writing guide +7. Empty string "" for inapplicable placeholders +8. Do NOT invent content — reorganize input only +9. PARA_*: reorganize input text for each paragraph placeholder + - Keep the meaning, improve clarity and structure + - PARA_n_RUN_m: if a paragraph has multiple runs, fill each run separately +10. IMAGE_*: output exactly "KEEP_ORIGINAL" (image is auto-inserted from source) +11. IMAGE_*_CAPTION: write a concise caption describing the image context +12. Total volume: {page_estimate} page(s) + +Output ONLY valid JSON:""" + + try: + response = call_claude( + "You fill document template placeholders with reorganized content. " + "Output valid JSON only. Respect the template structure exactly.", + prompt, + max_tokens=6000 + ) + + fill_data = self._extract_json(response) + + if not fill_data: + return {'error': 'JSON extraction failed', 'raw': response[:500]} + + html = self._fill_template(template, fill_data, image_data) + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _fill_template(self, template: str, data: dict, + image_data: dict = None) -> str: + """템플늿에 데읎터 채우Ʞ + + Args: + template: HTML 템플늿 + data: AI가 채욎 placeholder → value dict + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + """ + html = template + + # ★ content_prompt에서 IMAGE_n → binaryItemIDRef 맀핑 빌드 + image_ref_map = self._build_image_ref_map(data, image_data) + + for key, value in data.items(): + placeholder = '{{' + key + '}}' + + # ── IMAGE_n: 원볞 읎믞지 삜입 ── + if re.match(r'^IMAGE_\d+$', key): + img_tag = image_ref_map.get(key, '') + html = html.replace(placeholder, img_tag) + continue + + if isinstance(value, str) and value.strip(): + # ★ 개조식 낎용 처늬: · 또는 - 로 시작하는 항목 + lines = value.strip().split('\n') + is_bullet_list = sum( + 1 for l in lines + if l.strip().startswith('·') or l.strip().startswith('-') + ) > len(lines) * 0.5 + + if is_bullet_list and len(lines) > 1: + # ★ v2.2: inline context (

        안)에서는

          ꞈ지 + # PARA_*, SECTION_*_TITLE, HEADER_*, FOOTER_*, TITLE_*, *_RUN_* + # 읎듀은

          또는

        , 등의 Ʞ능 덕분에 표가 잘 표시되고 쉜게 슀타음링되며 액섞슀할 수 있습니닀. 불행히도 화멎에 렌더링할 때는 좋지 않습니닀(punk-bands-unstyled.html 에서 띌읎람 ì°žì¡°). + + + +Ʞ볞 람띌우저 슀타음만 사용하멎, 비좁고 읜Ʞ 얎렀우며 지룚핎 볎입니닀. 읎 묞제륌 핎결하렀멎 CSS 륌 사용핎알 합니닀. + +우늬의 표 슀타음링 +표 예제륌 핚께 슀타음링 핮 뎅시닀. + +시작하렀멎, sample markup 의 로컬 사볞을 만듀고 두 읎믞지 (녞읎슈 및 표범가죜) 륌 몚두 닀욎로드한 닀음, 섞 개의 결곌 파음을 로컬 컎퓚터의 작업 디렉토늬에 넣습니닀. + +닀음윌로, style.css 띌는 새 파음을 만듀고 닀륞 파음곌 같은 디렉토늬에 저장하십시였. + + 안에 닀음 HTML 행을 배치하여 CSS 륌 HTML 에 연결하십시였. + +html + +Copy + +간격 및 레읎아웃 +가장 뚌저 핎알할 음은 간격/레읎아웃을 정렬하는 것입니닀 — Ʞ볞 표 슀타음은 너묎 비좁습니닀! 읎렇게 하렀멎, style.css 파음에 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* 간격 */ + +table { + table-layout: fixed; + width: 100%; + border-collapse: collapse; + border: 3px solid purple; +} + +thead th:nth-child(1) { + width: 30%; +} + +thead th:nth-child(2) { + width: 20%; +} + +thead th:nth-child(3) { + width: 15%; +} + +thead th:nth-child(4) { + width: 35%; +} + +th, +td { + padding: 20px; +} +가장 쀑요한 부분은 닀음곌 같습니닀. + +fixed 의 table-layout 값은 음반적윌로 표가 Ʞ볞적윌로 좀 더 예잡 가능하게 작동하므로 표에 섀정하는 것읎 좋습니닀. 음반적윌로, 표의 엎은 낎용의 양에 따띌 크Ʞ가 정핎지는 겜향읎 있윌며, ê·ž 결곌 읎상한 결곌가 발생합니닀. table-layout: fixed 륌 사용하멎 제목의 너비에 따띌 엎의 크Ʞ륌 지정한 닀음 낎용을 적절하게 처늬할 수 있습니닀. 읎것읎 우늬가 thead th:nth-child(n) (:nth-child) 선택자 (" 요소 낎에서 륌 순서대로 선택합니닀") 요소륌 섀정하고 백분윚 너비륌 섀정했습니닀. 전첎 ì—Ž 너비는 제목 너비륌 따륎므로, 표 엎의 크Ʞ륌 정할 수 있습니닀. Chris Coyier 는 읎 Ʞ술에 대핮 고정 표 레읎아웃 에서 자섞히 섀명합니닀. + +읎륌 width 와 100% 결합했습니닀. 슉, 표에 넣은 container 륌 표에 채우고 반응성읎 뛰얎납니닀 (아직 더 많은 작업읎 필요하지만 좁은 화멎 너비에서 잘 볎임). + +collapse 의 border-collapse 값은 몚든 표 슀타음 작업에 대한 표쀀 몚범 사례입니닀. Ʞ볞적윌로, 표 요소에 테두늬륌 섀정하멎, 아래 읎믞지와 같읎 테두늬 사읎에 간격읎 있습니닀:읎것은 맀우 멋지게 볎읎지 않습니닀 (원하는 몚양음 수 있는지, 누가 알겠습니까?) border-collapse: collapse; 로 섀정하멎 테두늬가 하나로 축소되얎 훚씬 좋아 볎입니닀: + +우늬는 전첎 표 죌위에 border 륌 넣었습니닀. 나쀑에 표 뚞늬Ꞁ곌 바닥Ꞁ에 테두늬륌 씌욞 것입니닀 — 표 바깥쪜에 테두늬가 없고 틈새가 생Ʞ멎 정말 읎상하게 볎입니닀. + + 및 요소의 제목에 맞춀 Ꞁꌎ을 섀정하여 멋지고 펑킀한 몚양을 만듀었습니닀. +가독성을 높읎Ʞ 위핎 제목곌 셀에 letter-spacing 을 섀정했습니닀. 닀시 말하지만, 죌로 슀타음 선택입니닀. + 낎부의 표 셀에서 텍슀튞륌 가욎데 정렬하여 제목곌 음치하도록 했습니닀. Ʞ볞적윌로, 셀에는 text-align 에 left 값읎 제공되고, 뚞늬Ꞁ에는 center 값읎 제공되지만 둘 닀에 대핮 정렬을 동음하게 섀정하는것읎 좋습니닀. 제목 Ꞁꌎ의 Ʞ볞 굵은첎는 몚양을 구별하Ʞ에 충분합니닀. +데읎터와 시작적윌로 더 잘 연결되도록 낎부에서 제목을 였륞쪜 정렬했습니닀. +결곌는 조ꞈ 깔끔핎 볎입니닀. + + + +귞래픜 곌 색상 +읎제 귞래픜곌 색상윌로 넘얎가겠습니닀! 표에는 punk and attitude 가 가득하Ʞ 때묞에, 밝은 읞상적읞 슀타음링을 제공핎알합니닀. 걱정하지 마십시였. 표륌 크게 만듀 필요는 없습니닀 — 더 믞묘하고 섞렚된 것을 선택할 수 있습니닀. + +아래에서 닀시 CSS 륌 style.css 파음에 추가하고, 닀시 시작하십시였. + +css + +Copy +thead, +tfoot { + background: url(leopardskin.jpg); + color: white; + text-shadow: 1px 1px 1px black; +} + +thead th, +tfoot th, +tfoot td { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.5) + ); + border: 3px solid purple; +} +닀시 말하지만, 여Ʞ에는 표에만 핎당되는 것읎 없지만, 몇 가지 죌목할 가치가 있습니닀. + + 및 에 background-image 륌 추가하고, 뚞늬Ꞁ곌 바닥Ꞁ에 있는 몚든 텍슀튞의 color 륌 흰색윌로 (텍슀튞에 귞늌자 추가) 변겜하여 읜Ʞ 쉜게 했습니닀. 텍슀튞가 배겜곌 잘 대비되도록 핎알합니닀. 귞래알 잘 읜을 수 있습니닀. + +또한 뚞늬Ꞁ곌 바닥Ꞁ 낎부의 및 요소에 배겜 읎믞지와 선형 귞띌데읎션을 몚두 넣을 수는 있지만, 별도로 결정했습니닀. 여러 배겜 읎믞지 또는 선형 귞띌데읎션을 지원하지 않는 구형 람띌우저의 읎점을 위핎서입니닀. + +얌룩말 쀄묎늬 (Zebra striping) +우늬는 표의 닀륞 데읎터 행을 더 쉜게 구묞 분석하고 읜을 수 있도록 번갈아 가며 zebra stripes 륌 구현하는 방법을 볎여죌Ʞ 위핎 별도의 섹션을 제공하고자 했습니닀. style.css 파음의 ë§š 아래에 닀늄 CSS 륌 추가하십시였. + +css + +Copy +tbody tr:nth-child(odd) { + background-color: #ff33cc; +} + +tbody tr:nth-child(even) { + background-color: #e495e4; +} + +tbody tr { + background-image: url(noise.png); +} + +table { + background-color: #ff33cc; +} +읎전에는 :nth-child 선택자가 특정 자식 요소륌 선택하는 데 사용되는 것을 볎았습니닀. 수식을 맀개 변수로 제공할 수도 있윌므로 음렚의 요소륌 선택합니닀. 수식 2n-1 은 홀수 번짞 자식 (1, 3, 5 등) 을 몚두 선택하고 수식 2n 은 짝수 번짞 자식 (2, 4, 6 등) 을 몚두 선택합니닀. 윔드의 odd 및 even 킀워드 조찚도 앞에서 얞꞉한 공식곌 정확히 동음한 Ʞ능을 수행합니닀. 읎 겜우 홀수 및 짝수 행에 닀륞 색상 (선정적읞 색상) 을 부여합니닀. +또한 몚든 볞묞 행에 반복적읞 배겜 타음을 추가하여, 앜간의 녞읎슈 (앜간 시각적 왜곡읎 있는 반투명 .png) 륌 사용하여 질감을 제공했습니닀. +마지막윌로, :nth-child 선택자륌 지원하지 않는 람띌우저는 여전히 볞묞 행의 배겜을 갖도록 전첎 표에 닚색 배겜색을 지정했습니닀. +읎러한 색상은 닀음곌 같은 몚양을 만듭니닀. + + + +자, 읎것은 여러분의 췚향에 맞지 않을 수도 있습니닀. 하지만 , 우늬가 하렀고하는 요점은 표가 지룚하고 학묞적음 필요는 없닀는 것입니닀. + +caption 슀타음링 +표와 ꎀ렚하여 마지막윌로 핎알할 음읎 있습니닀 — caption 에 슀타음을 지정하는 음입니닀. 읎렇게 하렀멎, style.css 파음의 ë§š 아래에 닀늄을 추가하십시였. + +css + +Copy +caption { + font-family: "Rock Salt", cursive; + padding: 20px; + font-style: italic; + caption-side: bottom; + color: #666; + text-align: right; + letter-spacing: 1px; +} +bottom 값을 가진 caption-side 속성을 제왞하고는 여Ʞ서 죌목할만한 것읎 없습니닀. 읎로 읞핎 caption 읎 표의 ë§š 아래에 배치되고 닀륞 선얞곌 핚께 최종 몚양을 얻을 수 있습니닀(punk-bands-complete.html ì°žì¡°). + + + +적극적읞 학습: 나만의 표 슀타음 +읎 시점에서 표 HTML 예제 (또는 음부륌 사용하십시였!) 륌 가젞와서 표볎닀 훚씬 더 나은 디자읞곌 장식을 갖도록 슀타음을 지정하고 싶습니닀. + +표 슀타음링 빠륎게 하는 팁 +닀음 닚계로 넘얎가지 전에, 위에서 섀명한 가장 유용한 요점에 대한 간닚한 목록을 제공핎알 한닀고 생각했습니닀. + +표 마크 업을 가능한 한 간닚하게 만듀고, 유연한 작업을 유지하십시였 예: 백분유을 사용하여 디자읞의 반응속도륌 향상시킵니닀. +table-layout: fixed 륌 사용하여 제목 (, 및 륌 사용하여 표륌 녌늬적 덩얎늬로 나누고 CSS 륌 적용할 추가 위치륌 제공하십시였. 필요한 겜우 서로 위에 슀타음을 더 쉜게 레읎얎할 수 있습니닀. +alternative 행을 읜Ʞ 쉜게 하렀멎 얌룩말 쀄묎늬륌 사용합니닀. +text-align 을 사용하여 \n" + if col_pcts and len(col_pcts) == num_cols: + for pct in col_pcts: + colgroup += f' \n' + else: + for _ in range(num_cols): + colgroup += " \n" + colgroup += "\n" + + # 헀더 행 — ★ bf_id가 있윌멎 class 적용 + header_cells = [] + if header_row: + for c, cell in enumerate(header_row): + bf_id = cell.get("borderFillIDRef") + cs = cell.get("colSpan", 1) + + attrs = "" + if bf_id: + attrs += f' class="bf-{bf_id}"' + if cs > 1: + attrs += f' colspan="{cs}"' + + header_cells.append( + f' {{{{TABLE_{tbl_num}_H_C{c+1}}}}}' + ) + else: + # fallback: bf 없는 겜우 + for c in range(col_cnt): + header_cells.append( + f' ' + ) + + header_row_html = "\n".join(header_cells) + + return ( + f'
        : apply class from 'Row cell classes' guide\n + - e.g. content 안에 있얎 block 요소 삜입 시 HTML 깚짐 + _is_inline = re.match( + r'^(PARA_|SECTION_\d+_TITLE|HEADER_|FOOTER_|TITLE_|.*_RUN_)', + key + ) + if _is_inline: + #
        쀄바꿈윌로 구조 볎졎 + clean_lines = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + clean_lines.append(f'· {item}') + value = '
        \n'.join(clean_lines) + else: + #
        안 (SECTION_*_CONTENT 등) →
        • 허용 + li_items = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + li_items.append(f'
        • {item}
        • ') + value = '
            \n' + '\n'.join(li_items) + '\n
          ' + + html = html.replace(placeholder, str(value) if value else '') + + # ★ 낚은 placeholder 정늬 (한Ꞁ 포핚) + html = PH_PATTERN.sub('', html) + + return html + + def _build_image_ref_map(self, data: dict, image_data: dict = None) -> dict: + """IMAGE_n placeholder → 태귞 맀핑 생성. + + content_prompt.json의 placeholders에서 IMAGE_n의 example_ref + (= binaryItemIDRef)륌 ì°Ÿê³ , image_data에서 base64륌 가젞옎. + """ + ref_map = {} + if not image_data: + return ref_map + + # content_prompt placeholders에서 IMAGE_n → ref 맀핑 + # (generate 혞출 시 content_prompt륌 아직 안 가지고 있윌므로 + # template HTML의 data-ref 속성 또는 순서 맀칭윌로 핎결) + # 방법: template에서 IMAGE_1, IMAGE_2... 순서와 + # image_data의 í‚€ 순서륌 맀칭 + + # image_data í‚€ 목록 (BinData 등장 순서) + img_refs = sorted(image_data.keys()) + + img_num = 0 + for ref in img_refs: + img_num += 1 + key = f"IMAGE_{img_num}" + img_info = image_data[ref] + + b64 = img_info.get("base64", "") + mime = img_info.get("mime", "image/png") + + if b64: + ref_map[key] = ( + f'' + ) + else: + # base64 없윌멎 파음 겜로 ì°žì¡° + file_path = img_info.get("path", "") + if file_path: + ref_map[key] = ( + f'' + ) + else: + ref_map[key] = f'' + + return ref_map + + def _load_image_data(self, config: dict) -> dict: + """템플늿 폎더에서 images.json 로드 (BinData 추출 결곌). + + images.json 구조: + { + "IMG001": {"base64": "iVBOR...", "mime": "image/png"}, + "IMG002": {"base64": "...", "mime": "image/jpeg"} + } + + 또는 읎믞지 파음읎 직접 저장된 겜우 겜로륌 반환. + """ + tpl_id = config.get("template_id", "") + if not tpl_id: + return {} + + tpl_path = Path('templates/user/templates') / tpl_id + + # ① images.json (base64 저장 방식) + images_json = tpl_path / 'images.json' + if images_json.exists(): + try: + with open(images_json, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + + # ② images/ 폮더 (파음 저장 방식) + images_dir = tpl_path / 'images' + if images_dir.exists(): + result = {} + mime_map = { + '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.bmp': 'image/bmp', '.svg': 'image/svg+xml', + '.wmf': 'image/x-wmf', '.emf': 'image/x-emf', + } + for img_file in sorted(images_dir.iterdir()): + if img_file.suffix.lower() in mime_map: + ref = img_file.stem # 파음명 = binaryItemIDRef + result[ref] = { + "path": str(img_file), + "mime": mime_map.get(img_file.suffix.lower(), "image/png") + } + return result + + return {} + + def _extract_json(self, response: str) -> dict: + """응답에서 JSON 추출""" + # ```json ... ``` 랔록 ì°Ÿêž° + match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except: + pass + + # 가장 큰 { } 랔록 ì°Ÿêž° + brace_depth = 0 + start = -1 + for i, ch in enumerate(response): + if ch == '{': + if brace_depth == 0: + start = i + brace_depth += 1 + elif ch == '}': + brace_depth -= 1 + if brace_depth == 0 and start >= 0: + try: + return json.loads(response[start:i+1]) + except: + start = -1 + + return None + + def _generate_with_guide(self, content: str, config: dict, options: dict) -> dict: + """가읎드 êž°ë°˜ 생성 (템플늿 없을 때)""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + layout = config.get('layout', {}) + style = config.get('style', {}) + + instruction = options.get('instruction', '') if options else '' + + # 섹션 구조 섀명 + sections = layout.get('sections', []) + sections_desc = "" + for i, sec in enumerate(sections, 1): + sections_desc += f""" +{i}. {sec.get('name', f'섹션{i}')} + - 작성 슀타음: {sec.get('writingStyle', '혌합')} + - 불늿: {'있음' if sec.get('hasBulletIcon') else '없음'} + - 표: {'있음' if sec.get('hasTable') else '없음'} + - 낎용: {sec.get('contentDescription', '')} +""" + + page_estimate = structure.get('pageEstimate', 1) + + system_prompt = f"""당신은 "{context.get('documentType', '묞서')}" 작성 전묞가입니닀. + +## 묞서 특성 +- 목적: {context.get('purpose', '')} +- 대상: {context.get('audience', '')} +- 톀: {context.get('tone', '')} +- 전첎 슀타음: {structure.get('writingStyle', '혌합')} +- 분량: 앜 {page_estimate}페읎지 + +## 묞서 구조 +{sections_desc} + +## 작성 원칙 +{chr(10).join('- ' + p for p in structure.get('writingPrinciples', []))} + +## 죌의사항 +{chr(10).join('- ' + m for m in structure.get('commonMistakes', []))} + +## 핵심! +- 사용자 입력을 **정늬/재구성**하섞요 +- **새로 찜작하지 마섞요** +- 분석된 묞서 구조륌 귞대로 따륎섞요 +- 개조식 섹션은 "· " 불늿 사용 +- 분량을 {page_estimate}페읎지 낎로 제한하섞요""" + + user_prompt = f"""닀음 낎용을 "{context.get('documentType', '묞서')}" 양식윌로 정늬핎죌섞요. + +## 입력 낎용 +{content[:6000] if content else '(낎용 없음)'} + +## 추가 요청 +{instruction if instruction else '없음'} + +## 출력 형식 +완전한 A4 규격 HTML 묞서로 출력하섞요. +- 로 시작 +- UTF-8 읞윔딩 +- @page {{ size: A4 }} CSS 포핚 +- 폰튾: {style.get('font', {}).get('name', '맑은 고딕')} +- 뚞늿말/ꌬ늬말 포핚 +- 앜 {page_estimate}페읎지 분량 + +HTML만 출력하섞요.""" + + try: + response = call_claude(system_prompt, user_prompt, max_tokens=6000) + html = extract_html(response) + + if not html: + return {'error': 'HTML 생성 싀팚'} + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/doc/doc_type_analyzer.py b/03. Code/geulbeot_10th/handlers/doc/doc_type_analyzer.py new file mode 100644 index 0000000..145437b --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/doc/doc_type_analyzer.py @@ -0,0 +1,1058 @@ +# -*- coding: utf-8 -*- +""" +묞서 유형 분석 Agent (v3.1 - 윔드 êž°ë°˜ 레읎아웃 추출, hwpx_utils 연동) + +★ v3.1 변겜사항 (from v3.0): +- 레읎아웃 추출: AI 혞출 제거 → 윔드 êž°ë°˜ (HWPX 파싱 데읎터에서 직접 도출) + · headerLayout: 싀제 헀더 테읎랔 colCount/셀텍슀튞 귞대로 반영 + · footerLayout: 싀제 푾터 테읎랔 구조 귞대로 반영 + · sections: 묞닚 텍슀튞 팹턮 맀칭윌로 추출 + · overallStyle: 불늿/표 사용 팹턮 윔드 분석 +- 닚위 변환: hwpx_utils 연동 (hwpunit_to_mm, charsize_to_pt) +- AI는 맥띜(목적/묞서유형)곌 구조가읎드(섹션별 작성법)에만 사용 +- headerLayout 할룚시넀읎션 완전 제거 +- _generate_html_template (AI fallback) 제거 — template_manager가 전닎 +- _parse_tables_in_region: 태귞 속성에서도 병합/너비 추출 (도메읞 가읎드 §6.3) + +ì°žì¡°: +- domain/hwpx/hwpx_domain_guide.md (§1~§11) +- domain/hwpx/hwpx_utils.py (변환 핚수/상수) +""" + +import zipfile +import json +import time +import re +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from handlers.template.template_manager import TemplateManager +from pathlib import Path +from typing import Dict, List, Any, Optional +from handlers.common import call_claude, extract_json + +# ================================================================ +# hwpx_utils 연동 (fallback 포핚) +# ================================================================ +try: + from domain.hwpx.hwpx_utils import ( + hwpunit_to_mm, charsize_to_pt, mm_format, detect_paper_size + ) +except ImportError: + def hwpunit_to_mm(hu): return hu / 7200 * 25.4 + def charsize_to_pt(s): return s / 100 + def mm_format(hu, d=1): return f'{hwpunit_to_mm(hu):.{d}f}mm' + def detect_paper_size(w, h, t=200): return 'A4' + + +class DocTypeAnalyzer: + """묞서 유형 분석 Orchestrator (v3.1 - 윔드 êž°ë°˜ 레읎아웃)""" + + def __init__(self, progress_callback=None): + self.progress_callback = progress_callback + self.template_manager = TemplateManager() + self.steps = [ + {"id": 1, "name": "묞서 파싱", "status": "pending"}, + {"id": 2, "name": "레읎아웃 추출", "status": "pending"}, + {"id": 3, "name": "맥띜 분석", "status": "pending"}, + {"id": 4, "name": "구조 분석", "status": "pending"}, + {"id": 5, "name": "템플늿 추출", "status": "pending"}, + {"id": 6, "name": "최종 검슝", "status": "pending"}, + ] + + def _update_step(self, step_id: int, status: str, message: str = ""): + for step in self.steps: + if step["id"] == step_id: + step["status"] = status + break + if self.progress_callback: + self.progress_callback(step_id, status, message) + + # ================================================================ + # 메읞 분석 플로우 + # ================================================================ + + def analyze(self, file_path: str, doc_name: str, + description: str = "") -> dict: + """전첎 분석 싀행 + + Flow: + Step 1: HWPX 파싱 (윔드) + Step 2: 레읎아웃 추출 (윔드) — AI 믞사용 + Step 3: 맥띜 분석 (AI) — 묞서 유형/목적/대상 판정 + Step 4: 구조 분석 (AI) — 섹션별 작성 가읎드 생성 + Step 5: 템플늿 추출 (template_manager → doc_template_analyzer) + Step 6: config.json 생성 + """ + result = {"name": doc_name, "description": description} + + # Step 1: HWPX 파싱 + self._update_step(1, "running", "HWPX 파음 분석 쀑...") + parsed = self._parse_hwpx(file_path) + result["parsed"] = parsed + self._update_step( + 1, "done", + f"텍슀튞 {len(parsed.get('text', ''))}자, " + f"표 {len(parsed.get('tables', []))}개") + + # Step 2: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + self._update_step(2, "running", "묞서 레읎아웃 구조 추출 쀑...") + layout = self._extract_layout(parsed) + result["layout"] = layout + self._update_step( + 2, "done", + f"헀더:{layout.get('hasHeader')}, " + f"푾터:{layout.get('hasFooter')}, " + f"섹션:{len(layout.get('sections', []))}개") + + # Step 3: 맥띜 분석 (AI - 의믞 분석만) + self._update_step(3, "running", "묞서의 목적/배겜 분석 쀑...") + context = self._analyze_context(parsed, layout) + result["context"] = context + self._update_step( + 3, "done", + f"묞서 유형: {context.get('documentType', '알 수 없음')}") + + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + self._update_step(4, "running", "묞서 구조 상섞 분석 쀑...") + structure = self._analyze_structure(parsed, layout, context) + result["structure"] = structure + style = self._extract_style(parsed) + result["style"] = style + self._update_step(4, "done", "구조 분석 완료") + + # Step 5: 템플늿 추출 (template_manager 전닎) + self._update_step(5, "running", "HTML 템플늿 추출 쀑...") + try: + tpl_result = self.template_manager.extract_and_save( + parsed, + name=f"{doc_name} 양식", + source_file=os.path.basename(file_path), + description=f"{doc_name}에서 추출한 묞서 양식" + ) + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result: {tpl_result}") + except Exception as e: + import traceback + print(f"[ERROR] Step 5 싀팚: {e}") + traceback.print_exc() + tpl_result = {} + + # ★ template_id륌 result로 전달 ← 추가! + if tpl_result.get("success"): + result["template_id"] = tpl_result["template_id"] + + # Step 5-b: content_prompt 생성 (doc_type 소속) ← ★ 읎 랔록 추가 + content_prompt = {} + if tpl_result.get("success"): + try: + from . import content_analyzer + content_prompt = content_analyzer.generate( + tpl_result["template_info"], + tpl_result["semantic_map"], + parsed + ) + result["content_prompt"] = content_prompt + except Exception as e: + print(f"[WARN] content_prompt 생성 였류: {e}") + + # Step 6: Config 생성 + self._update_step(6, "running", "최종 섀정 파음 생성 쀑...") + config = self._generate_config(doc_name, description, result) + result["config"] = config + self._update_step(6, "done", "분석 완료!") + + return result + + # ================================================================ + # Step 1: HWPX 파싱 + # ================================================================ + + def _parse_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) + # ★ tc 태귞 속성 + 낎부 윘텐잠 몚두 캡처 + tc_pattern = re.compile( + r']*)>(.*?)', re.DOTALL) + + row_cells = [] + for cell_match in tc_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: + """뚞늿말 XML 추출""" + m = re.search( + r']*>(.*?)', xml, re.DOTALL) + return m.group(1) if m else "" + + def _extract_footer(self, xml: str) -> str: + """ꌬ늬말 XML 추출""" + 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: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + # ================================================================ + + def _extract_layout(self, parsed: dict) -> dict: + """윔드 êž°ë°˜ 레읎아웃 추출 - HWPX 파싱 데읎터에서 직접 도출 + + ★ v3.1: AI 혞출 완전 제거. 할룚시넀읎션 없읎 싀제 HWPX 데읎터만 반영. + + 읎전 v3.0 묞제: + - AI가 "columns": ["부서명", "묞서번혞", "작성음자"] 식윌로 지얎냄 + - hasHeader: True 하드윔딩 (HWPX에 헀더 없얎도) + - left/center/right 3ì—Ž 고정 슀킀마 강제 + + v3.1 핎결: + - headerLayout.cellTexts: 싀제 HWPX 셀 텍슀튞 귞대로 + - hasHeader: bool(header_xml.strip()) 싀제 졎재 여부 + - colCount: 싀제 ì—Ž 수 + """ + tables = parsed.get("tables", []) + paragraphs = parsed.get("paragraphs", []) + section_xml = parsed.get("section_xml", "") + header_xml = parsed.get("page_header_xml", "") + footer_xml = parsed.get("page_footer_xml", "") + + 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(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 데읎터 귞대로 반영 + + ★ AI가 "좌잡=부서명, ìš°ìž¡=날짜" 식윌로 지얎낎던 것을 + 싀제 테읎랔 셀 텍슀튞로 대첎. + """ + 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에서 묞닚 팹턮 맀칭 + + doc_template_analyzer.py v3.0곌 동음한 팹턮 감지: + 1. "1. 개요" (번혞+점) + 2. 아읎윘 + 짧은 텍슀튞 + 3. "Ⅰ.", "Ⅱ." (로마자) + 4. "제1장", "제2절" (한국식) + """ + sections = [] + data_tables = [t for t in body_tables + if not t.get("isLayoutTable")] + table_idx = 0 + current_section = None + + # header/footer 제거 + 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) + + # 묞첎 판정 + if bullet_total > prose_count * 2: + writing_style = "개조식" + elif prose_count > bullet_total * 2: + writing_style = "서술식" + else: + 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: 맥띜 분석 (AI - 의믞 분석만) + # ================================================================ + + 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": "음반", + "tone": "볎고형", + } + + # ================================================================ + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + # ================================================================ + + 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": "개조식/서술식", + "contentGuide": "작성 가읎드", + "hasTable": false + }}, + {{ + "name": "섹션명", + "role": "역할", + "writingStyle": "개조식", + "contentGuide": "가읎드", + "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, + } + + # ================================================================ + # 슀타음 추출 (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) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/report/__init__.py b/03. Code/geulbeot_10th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/report/processor.py b/03. Code/geulbeot_10th/handlers/report/processor.py new file mode 100644 index 0000000..19def30 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/report/processor.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # ⭐ 템플늿 슀타음 로드 + template_id = options.get('template_id') + if template_id: + from handlers.template import TemplateProcessor + template_processor = TemplateProcessor() + style = template_processor.get_style(template_id) + if style and style.get('css'): + options['template_css'] = style['css'] + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_10th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/__init__.py b/03. Code/geulbeot_10th/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/doc_template_analyzer.py b/03. Code/geulbeot_10th/handlers/template/doc_template_analyzer.py new file mode 100644 index 0000000..4ad0139 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/doc_template_analyzer.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +묞서 템플늿 분석Ʞ v5.1 (였쌀슀튞레읎터) + +역할: tools/ 몚듈을 조합하여 HWPX → 템플늿 정볎 추출 +- 직접 파싱 로직 없음 (몚두 tools에 위임) +- 디폎튞값 생성 없음 (tools가 None 반환하멎 결곌에서 제왞) +- 사용자 추가 사항(config.json) → 템플늿에도 반영 + +구조: + tools/ + page_setup.py §7 용지/여백 + font.py §3 Ꞁꌎ + char_style.py §4 Ꞁ자 몚양 + para_style.py §5 묞닚 몚양 + border_fill.py §2 테두늬/배겜 + table.py §6 표 + header_footer.py §8 뚞늬말/ꌬ늬말 + section.py §9 구역 정의 + style_def.py 슀타음 정의 + numbering.py 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image.py 읎믞지 +""" + +import json +from pathlib import Path +from typing import Optional + +from .tools import ( + page_setup, + font, + char_style, + para_style, + border_fill, + table, + header_footer, + section, + style_def, + numbering, + image, + content_order, +) + + +class DocTemplateAnalyzer: + """HWPX → 템플늿 추출 였쌀슀튞레읎터""" + + # ================================================================ + # Phase 1: 추출 (몚든 tools 혞출) + # ================================================================ + + def analyze(self, parsed: dict) -> dict: + """HWPX parsed 결곌에서 템플늿 구조 추출. + + Args: + parsed: processor.py가 HWPX륌 파싱한 결곌 dict. + raw_xml, section_xml, header_xml, footer_xml, + tables, paragraphs 등 포핚. + + Returns: + 추출된 항목만 포핚하는 dict (None읞 항목은 제왞). + """ + raw_xml = parsed.get("raw_xml", {}) + + extractors = { + "page": lambda: page_setup.extract(raw_xml, parsed), + "fonts": lambda: font.extract(raw_xml, parsed), + "char_styles": lambda: char_style.extract(raw_xml, parsed), + "para_styles": lambda: para_style.extract(raw_xml, parsed), + "border_fills": lambda: border_fill.extract(raw_xml, parsed), + "tables": lambda: table.extract(raw_xml, parsed), + "header": lambda: header_footer.extract_header(raw_xml, parsed), + "footer": lambda: header_footer.extract_footer(raw_xml, parsed), + "section": lambda: section.extract(raw_xml, parsed), + "styles": lambda: style_def.extract(raw_xml, parsed), + "numbering": lambda: numbering.extract(raw_xml, parsed), + "images": lambda: image.extract(raw_xml, parsed), + "content_order":lambda: content_order.extract(raw_xml, parsed), + } + + result = {} + for key, extractor in extractors.items(): + try: + value = extractor() + if value is not None: + result[key] = value + except Exception as e: + # 개별 tool 싀팚 시 로귞만, 전첎 쀑닚 안 핹 + result.setdefault("_errors", []).append( + f"{key}: {type(e).__name__}: {e}" + ) + + return result + + + # ================================================================ + # Phase 2: 사용자 추가 사항 병합 + # ================================================================ + + def merge_user_config(self, template_info: dict, + config: dict) -> dict: + """config.json의 사용자 요구사항을 template_info에 병합. + + 사용자가 묞서 유형 추가 시 지정한 컀슀텀 사항을 반영: + - 색상 였버띌읎드 + - Ꞁꌎ 였버띌읎드 + - 제목 크Ʞ 였버띌읎드 + - Ʞ타 레읎아웃 컀슀텀 + + 읎 병합 결곌는 style.json에 저장되고, + 읎후 template.html 생성 시에도 반영됚. + + Args: + template_info: analyze()의 결곌 + config: config.json 낎용 + + Returns: + 병합된 template_info (원볞 수정됚) + """ + user_overrides = config.get("user_overrides", {}) + if not user_overrides: + return template_info + + # 몚든 사용자 였버띌읎드륌 template_info에 Ʞ록 + template_info["user_overrides"] = user_overrides + + return template_info + + # ================================================================ + # Phase 3: template_info → style.json 저장 + # ================================================================ + + def save_style(self, template_info: dict, + save_path: Path) -> Path: + """template_info륌 style.json윌로 저장. + + Args: + template_info: analyze() + merge_user_config() 결곌 + save_path: 저장 겜로 (예: templates/user/{doc_type}/style.json) + + Returns: + 저장된 파음 겜로 + """ + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(template_info, f, ensure_ascii=False, indent=2) + + return save_path \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/html_table_template_css.txt b/03. Code/geulbeot_10th/handlers/template/html_table_template_css.txt new file mode 100644 index 0000000..1868522 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/html_table_template_css.txt @@ -0,0 +1,1442 @@ +- type 1 table + +1) HTML + +

          HTML table advanced features and accessibility

          + +

          Origin: HTML table advanced features and accessibility - Learn web development | MDN.

          + +

          Adding a <caption>, and structure with <thead>, <tfoot> and <tbody>

          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          How I chose to spend my money
          PurchaseLocationDateEvaluationCost (€)
          HaircutHairdresser12/09Great idea30
          LasagnaRestaurant12/09Regrets18
          ShoesShoeshop13/09Big regrets65
          ToothpasteSupermarket13/09Good5
          SUM118
          + +

          Using the scope attribute

          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          Items Sold August 2016
            ClothesAccessories
            TrousersSkirtsDressesBraceletsRings
          BelgiumAntwerp5622437223
          Gent4618506115
          Brussels5127386928
          The NetherlandsAmsterdam8934698538
          Utrecht8012433619
          + +

          Using the id and headers attributes

          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          Items Sold August 2016
            ClothesAccessories
            TrousersSkirtsDressesBraceletsRings
          BelgiumAntwerp5622437223
          Gent4618506115
          Brussels5127386928
          The NetherlandsAmsterdam8934698538
          Utrecht8012433619
          + + +2) CSS +@import "https://germanfrelo.github.io/base-css-stylesheet/base.css" layer(base); +@import "https://codepen.io/germanfrelo/pen/mdMYKza.css" layer(styles); + +:root { + --page-max-inline-size: 100%; +} + +body { + padding-block: 2rem; +} + +caption { + text-align: start; +} + + + +- type 2 table + +1) HTML +
          +

          CSS responsive table

          +

          ...with fixed column and row headers and scroll snap. - @scottjehl

          +
          +
          +
          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          Col HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol Header
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
          +
          + +
          +
          + Site footer +
          + +2) CSS +body { + font: 90%/1.4 system-ui; + margin: 0; + font-family: sans-serif; +} +header { + padding: 7vh 5vw; + border-bottom: 1px solid #ddd; +} +header h1, +header p { + margin: 0; +} +footer { + padding: 7vh 5vw; + border-top: 1px solid #ddd; +} +aside { + padding: 7vh 5vw; +} +.primary { + overflow: auto; + scroll-snap-type: both mandatory; + height: 80vh; +} +@media (min-width: 40em) { + main { + display: flex; + } + aside { + flex: 0 1 20vw; + order: 1; + border-right: 1px solid #ddd; + } + .primary { + order: 2; + } +} +table { + border-collapse: collapse; + border: 0; +} +th, +td { + border: 1px solid #aaa; + background-clip: padding-box; + scroll-snap-align: start; +} +tbody tr:last-child th, +tbody tr:last-child td { + border-bottom: 0; +} +thead { + z-index: 1000; + position: relative; +} +th, +td { + padding: 0.6rem; + min-width: 6rem; + text-align: left; + margin: 0; +} +thead th { + position: sticky; + top: 0; + border-top: 0; + background-clip: padding-box; +} +thead th.pin { + left: 0; + z-index: 1001; + border-left: 0; +} +tbody th { + background-clip: padding-box; + border-left: 0; +} +tbody { + z-index: 10; + position: relative; +} +tbody th { + position: sticky; + left: 0; +} +thead th, +tbody th { + background-color: #f8f8f8; +} + + +- type 3 tabel +1) HTML + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          Developers Rating
          AvatarGroupNamePointsControl
          imgNinjaAsma Ad120 + + +
          imgShadesDavid180 + + +
          imgAlex160 + + +
          imgValhala Kawtar190 + + +
          imgKatara110 + + +
          imgUnionAshraf90 + + +
          + +2) CSS +table { + width: 700px; + text-align: center; + border: 1px solid #fff; + border-spacing: 1px; + font-family: 'Cairo', sans-serif; + margin: auto; +} + +caption { + font-weight: bold; +} + +table td { + padding: 10px; + background-color: #eee; +} + +table th { + background-color: #333; + color: #fff; + padding: 10px; +} + +img { + width: 90px; + height: 90px; +} + +.view, +.delete { + border: none; + padding: 5px 10px; + color: #fff; + font-weight: bold; +} + +.view { + background-color: #03A9F4; +} + +.delete { + background-color: #E91E63; +} + +.tablefoot { + padding: 0; + border-bottom: 3px solid #009688; +} + +HTML 표 슀타음링은 섞계에서 가장 맀력적읞 음읎 아니지만, 때로는 우늬 몚두가 핎알할 음입니닀. 읎 Ʞ사에서는 특정 표 슀타음링 Ʞ술을 강조 표시하여 HTML 표륌 볎Ʞ좋게 만드는 방법에 대한 안낎서륌 제공합니닀. + +전제조걎: HTML Ʞ볞 사항 (HTML 소개 학습), HTML 표 에 대한 지식 및 CSS 작동 방식에 대한 읎핎 (CSS 첫 번짞 닚계 학습.) +목적: HTML 표륌 횚곌적윌로 슀타음링하는 방법 ë°°ìš°êž°. +In this article +전형적읞 HTML 표 +우늬의 표 슀타음링 +적극적읞 학습: 나만의 표 슀타음 +표 슀타음링 빠륎게 하는 팁 +요앜 +Auth0 +Make login our problem. Not yours. +Your time is valuable. Use it to focus on your app, and let us handle login (and much more). +Try it Free Now +Ad +전형적읞 HTML 표 +전형적읞 HTML 표륌 삎펎 뎅시닀. Ꞁ쎄요, 음반적읞 표의 예듀은 — 신발, 날씚 또는 직원듀에 ꎀ한 것입니닀; 우늬는 영국의 유명한 펑크 밎드에 ꎀ한것을 만듀얎서 더 흥믞롭게 만듀Ʞ로 결정했습니닀. 윔드는 닀음곌 같습니닀. + +html + +Copy + + + + + + + + + + + + + + + + + + + + + + + + + ... some rows removed for brevity + + + + + + + + + + + + + + +
          + A summary of the UK's most famous punk bands +
          BandYear formedNo. of AlbumsMost famous song
          Buzzcocks19769Ever fallen in love (with someone you shouldn't've)
          The Clash19766London Calling
          The Stranglers197417No More Heroes
          Total albums77
          +scope,
        ,
        요소읞 n번짞 자식 요소
        및 요소에 padding 을 섀정했습니닀 — 읎렇게 하멎 데읎터 항목에 숚을 공간읎 생깁니닀. 표륌 훚씬 읜Ʞ 쉜게 볎읎게합니닀. + +읎 시점에서, 우늬 표는 읎믞 훚씬 좋아볎입니닀. + + + +간닚한 typography +읎제 텍슀튞륌 앜간 정늬핎 볎겠습니닀. + +우선, Google Fonts 에서 펑크 밮드 ꎀ렚 표에 적합한 Ꞁꌎ을 찟았습니닀. 원하는 겜우 거Ʞ에 가서 닀륞 것을 찟을 수 있습니닀. 제공된 요소 및 custom font-family 선얞을 Google Fonts 에서 제공하는 선얞윌로 바꟞멎 됩니닀. + +뚌저, 닀음 요소륌 Ʞ졎 요소 바로 위의 HTML head 에 추가하십시였. + +html + +Copy + +읎제 읎전 CSS 아래의 style.css 파음에, 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* typography */ + +html { + font-family: "helvetica neue", helvetica, arial, sans-serif; +} + +thead th, +tfoot th { + font-family: "Rock Salt", cursive; +} + +th { + letter-spacing: 2px; +} + +td { + letter-spacing: 1px; +} + +tbody td { + text-align: center; +} + +tfoot th { + text-align: right; +} +여Ʞ에서는 표에 특별한 것은 없습니닀. 우늬는 음반적윌로 쉜게 읜을 수 있도록 Ꞁꌎ 슀타음을 조정합니닀. + +전역 sans-serif Ꞁꌎ 슀택을 섀정했습니닀; 읎것은 순전히 묞첎 선택입니닀. 또한
        및 요소에 선형 귞띌데읎션을 추가하여 앜간의 질감을 개선하였, 밝은 볎띌색 테두늬륌 부여했습니닀. 쀑첩된 요소륌 여러 개의 쀑첩된 요소륌 사용하여 슀타음을 서로 겹칠 수 있는 것읎 유용합니닀. 예, 여러 배겜 읎믞지륌 사용하여
        ) 에서 width 륌 섀정하여 ì—Ž 너비륌 쉜게 섀정할 수 있는 예잡 가능한 표 레읎아웃을 작성합니닀. +border-collapse: collapse 륌 사용하여 표 요소 테두늬륌 서로 접얎서 깔끔하게 만듀 수 있습니닀. +
        및 텍슀튞륌 정렬하여, 더 깜끔하고 쉜게 따띌할 수 있도록 하십시였. \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/processor.py b/03. Code/geulbeot_10th/handlers/template/processor.py new file mode 100644 index 0000000..ff49306 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/processor.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +""" +템플늿 처늬 로직 (v3 - 싀제 구조 정확 분석) +- HWPX 파음의 싀제 표 구조, 읎믞지 배겜, 테두늬 정확히 추출 +- ARGB 8자늬 색상 정규화 +- NONE 테두늬 색상 제왞 +""" + +import os +import json +import uuid +import shutil +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from collections import Counter, defaultdict + +# 템플늿 저장 겜로 +TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates' / 'user' / 'templates' +TEMPLATES_DIR.mkdir(exist_ok=True) + +# HWP 명섞서 êž°ë°˜ 상수 +LINE_TYPES = { + 'NONE': '없음', + 'SOLID': '싀선', + 'DASH': 'ꞎ 점선', + 'DOT': '점선', + 'DASH_DOT': '-.-.-.-.', + 'DASH_DOT_DOT': '-..-..-..', + 'DOUBLE_SLIM': '2쀑선', + 'SLIM_THICK': '가는선+굵은선', + 'THICK_SLIM': '굵은선+가는선', + 'SLIM_THICK_SLIM': '가는선+굵은선+가는선', + 'WAVE': '묌결', + 'DOUBLE_WAVE': '묌결 2쀑선', +} + + +class TemplateProcessor: + """템플늿 처늬 큎래슀 (v3)""" + + NS = { + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'hs': 'http://www.hancom.co.kr/hwpml/2011/section', + } + + def __init__(self): + self.templates_dir = TEMPLATES_DIR + self.templates_dir.mkdir(exist_ok=True) + + # ========================================================================= + # 공개 API + # ========================================================================= + + def get_list(self) -> Dict[str, Any]: + """저장된 템플늿 목록""" + templates = [] + for item in self.templates_dir.iterdir(): + if item.is_dir(): + meta_path = item / 'meta.json' + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + templates.append({ + 'id': meta.get('id', item.name), + 'name': meta.get('name', item.name), + 'features': meta.get('features', []), + 'created_at': meta.get('created_at', '') + }) + except: + pass + templates.sort(key=lambda x: x.get('created_at', ''), reverse=True) + return {'templates': templates} + + def analyze(self, file, name: str) -> Dict[str, Any]: + """템플늿 파음 분석 및 저장""" + filename = file.filename + ext = Path(filename).suffix.lower() + + if ext not in ['.hwpx', '.hwp', '.pdf']: + return {'error': f'지원하지 않는 파음 형식: {ext}'} + + template_id = str(uuid.uuid4())[:8] + template_dir = self.templates_dir / template_id + template_dir.mkdir(exist_ok=True) + + try: + original_path = template_dir / f'original{ext}' + file.save(str(original_path)) + + if ext == '.hwpx': + style_data = self._analyze_hwpx(original_path, template_dir) + else: + style_data = self._analyze_fallback(ext) + + if 'error' in style_data: + shutil.rmtree(template_dir) + return style_data + + # 특징 추출 + features = self._extract_features(style_data) + + # 메타 저장 + meta = { + 'id': template_id, + 'name': name, + 'original_file': filename, + 'file_type': ext, + 'features': features, + 'created_at': datetime.now().isoformat() + } + (template_dir / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # 슀타음 저장 + (template_dir / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # CSS 저장 + css = style_data.get('css', '') + css_dir = template_dir / 'css' + css_dir.mkdir(exist_ok=True) + (css_dir / 'template.css').write_text(css, encoding='utf-8') + + return { + 'success': True, + 'template': { + 'id': template_id, + 'name': name, + 'features': features, + 'created_at': meta['created_at'] + } + } + except Exception as e: + if template_dir.exists(): + shutil.rmtree(template_dir) + raise e + + def delete(self, template_id: str) -> Dict[str, Any]: + """템플늿 삭제""" + template_dir = self.templates_dir / template_id + if not template_dir.exists(): + return {'error': '템플늿을 찟을 수 없습니닀'} + shutil.rmtree(template_dir) + return {'success': True, 'deleted': template_id} + + def get_style(self, template_id: str) -> Optional[Dict[str, Any]]: + """템플늿 슀타음 반환""" + style_path = self.templates_dir / template_id / 'style.json' + if not style_path.exists(): + return None + return json.loads(style_path.read_text(encoding='utf-8')) + + # ========================================================================= + # HWPX 분석 (핵심) + # ========================================================================= + + def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]: + """HWPX 분석 - 싀제 구조 정확히 추출""" + extract_dir = template_dir / 'extracted' + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + zf.extractall(extract_dir) + + result = { + 'version': 'v3', + 'fonts': {}, + 'colors': { + 'background': [], + 'border': [], + 'text': [] + }, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': {}, + 'css': '' + } + + # 1. header.xml 분석 + header_path = extract_dir / 'Contents' / 'header.xml' + if header_path.exists(): + self._parse_header(header_path, result) + + # 2. section0.xml 분석 + section_path = extract_dir / 'Contents' / 'section0.xml' + if section_path.exists(): + self._parse_section(section_path, result) + + # 3. 슀타음 요앜 생성 + result['style_summary'] = self._create_style_summary(result) + + # 4. CSS 생성 + result['css'] = self._generate_css(result) + + return result + + finally: + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + def _parse_header(self, header_path: Path, result: Dict): + """header.xml 파싱 - 폰튾, borderFill""" + tree = ET.parse(header_path) + root = tree.getroot() + + # 폰튾 + for fontface in root.findall('.//hh:fontface', self.NS): + if fontface.get('lang') == 'HANGUL': + for font in fontface.findall('hh:font', self.NS): + result['fonts'][font.get('id')] = font.get('face') + + # borderFill + for bf in root.findall('.//hh:borderFill', self.NS): + bf_id = bf.get('id') + bf_data = self._parse_border_fill(bf, result) + result['border_fills'][bf_id] = bf_data + + def _parse_border_fill(self, bf, result: Dict) -> Dict: + """개별 borderFill 파싱""" + bf_id = bf.get('id') + data = { + 'id': bf_id, + 'type': 'empty', + 'background': None, + 'image': None, + 'borders': {} + } + + # 읎믞지 배겜 + img_brush = bf.find('.//hc:imgBrush', self.NS) + if img_brush is not None: + img = img_brush.find('hc:img', self.NS) + if img is not None: + data['type'] = 'image' + data['image'] = { + 'ref': img.get('binaryItemIDRef'), + 'effect': img.get('effect') + } + + # 닚색 배겜 + win_brush = bf.find('.//hc:winBrush', self.NS) + if win_brush is not None: + face_color = self._normalize_color(win_brush.get('faceColor')) + if face_color and face_color != 'none': + if data['type'] == 'empty': + data['type'] = 'solid' + data['background'] = face_color + if face_color not in result['colors']['background']: + result['colors']['background'].append(face_color) + + # 4방향 테두늬 + for side in ['top', 'bottom', 'left', 'right']: + border = bf.find(f'hh:{side}Border', self.NS) + if border is not None: + border_type = border.get('type', 'NONE') + width = border.get('width', '0.1 mm') + color = self._normalize_color(border.get('color', '#000000')) + + data['borders'][side] = { + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'width_mm': self._parse_width(width), + 'color': color + } + + # 볎읎는 테두늬만 색상 수집 + if border_type != 'NONE': + if data['type'] == 'empty': + data['type'] = 'border_only' + if color and color not in result['colors']['border']: + result['colors']['border'].append(color) + + # 특수 테두늬 수집 + if border_type not in ['SOLID', 'NONE']: + result['special_borders'].append({ + 'bf_id': bf_id, + 'side': side, + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'color': color + }) + + return data + + def _parse_section(self, section_path: Path, result: Dict): + """section0.xml 파싱 - 표 구조""" + tree = ET.parse(section_path) + root = tree.getroot() + + border_fills = result['border_fills'] + + for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'): + table_data = { + 'rows': int(tbl.get('rowCnt', 0)), + 'cols': int(tbl.get('colCnt', 0)), + 'cells': [], + 'structure': { + 'header_row_style': None, + 'first_col_style': None, + 'body_style': None, + 'has_image_cells': False + } + } + + # 셀별 분석 + cell_by_position = {} + for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'): + cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr') + if cell_addr is None: + continue + + row = int(cell_addr.get('rowAddr', 0)) + col = int(cell_addr.get('colAddr', 0)) + bf_id = tc.get('borderFillIDRef') + bf_info = border_fills.get(bf_id, {}) + + # 텍슀튞 추출 + text = '' + for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'): + if t.text: + text += t.text + + cell_data = { + 'row': row, + 'col': col, + 'bf_id': bf_id, + 'bf_type': bf_info.get('type'), + 'background': bf_info.get('background'), + 'image': bf_info.get('image'), + 'text_preview': text[:30] if text else '' + } + + table_data['cells'].append(cell_data) + cell_by_position[(row, col)] = cell_data + + if bf_info.get('type') == 'image': + table_data['structure']['has_image_cells'] = True + + # 구조 분석: 헀더행, 첫엎 슀타음 + self._analyze_table_structure(table_data, cell_by_position, border_fills) + + result['tables'].append(table_data) + + def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict): + """표 구조 분석 - 헀더행/첫엎 슀타음 파악""" + rows = table_data['rows'] + cols = table_data['cols'] + + if rows == 0 or cols == 0: + return + + # 첫 행 (헀더) 분석 + header_styles = [] + for c in range(cols): + cell = cells.get((0, c)) + if cell: + header_styles.append(cell.get('bf_id')) + + if header_styles: + # 가장 많읎 쓰읞 슀타음 + most_common = Counter(header_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['header_row_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background'), + 'borders': bf.get('borders', {}) + } + + # 첫 ì—Ž 분석 (행 1부터) + first_col_styles = [] + for r in range(1, rows): + cell = cells.get((r, 0)) + if cell: + first_col_styles.append(cell.get('bf_id')) + + if first_col_styles: + most_common = Counter(first_col_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['first_col_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') + } + + # 볞묞 셀 슀타음 (첫엎 제왞) + body_styles = [] + for r in range(1, rows): + for c in range(1, cols): + cell = cells.get((r, c)) + if cell: + body_styles.append(cell.get('bf_id')) + + if body_styles: + most_common = Counter(body_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + table_data['structure']['body_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') if bf else None + } + + def _create_style_summary(self, result: Dict) -> Dict: + """AI 프롬프튞용 슀타음 요앜""" + summary = { + '폰튾': list(result['fonts'].values())[:3], + '색상': { + '배겜색': result['colors']['background'], + '테두늬색': result['colors']['border'] + }, + '표_슀타음': [], + '특수_테두늬': [] + } + + # 표별 슀타음 요앜 + for i, tbl in enumerate(result['tables']): + tbl_summary = { + '표번혞': i + 1, + '크Ʞ': f"{tbl['rows']}행 × {tbl['cols']}ì—Ž", + '읎믞지셀': tbl['structure']['has_image_cells'] + } + + header = tbl['structure'].get('header_row_style') + if header: + tbl_summary['헀더행'] = f"배겜={header.get('background')}" + + first_col = tbl['structure'].get('first_col_style') + if first_col: + tbl_summary['첫엎'] = f"배겜={first_col.get('background')}" + + body = tbl['structure'].get('body_style') + if body: + tbl_summary['볞묞'] = f"배겜={body.get('background') or '없음'}" + + summary['표_슀타음'].append(tbl_summary) + + # 특수 테두늬 요앜 + seen = set() + for sb in result['special_borders']: + key = f"{sb['type_name']} {sb['width']} {sb['color']}" + if key not in seen: + seen.add(key) + summary['특수_테두늬'].append(key) + + return summary + + def _generate_css(self, result: Dict) -> str: + """CSS 생성 - 싀제 구조 반영""" + fonts = list(result['fonts'].values())[:2] + font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'" + + bg_colors = result['colors']['background'] + header_bg = bg_colors[0] if bg_colors else '#D6D6D6' + + # 특수 테두늬에서 2쀑선 ì°Ÿêž° + double_border = None + for sb in result['special_borders']: + if 'DOUBLE' in sb['type']: + double_border = sb + break + + css = f"""/* 템플늿 슀타음 v3 - HWPX 구조 êž°ë°˜ */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +:root {{ + --font-primary: 'Noto Sans KR', {font_family}, sans-serif; + --color-header-bg: {header_bg}; + --color-border: #000000; +}} + +body {{ + font-family: var(--font-primary); + font-size: 10pt; + line-height: 1.6; + color: #000000; +}} + +.sheet {{ + width: 210mm; + min-height: 297mm; + padding: 20mm; + margin: 10px auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +}} + +@media print {{ + .sheet {{ margin: 0; box-shadow: none; page-break-after: always; }} +}} + +/* 표 Ʞ볞 */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 9pt; +}} + +th, td {{ + border: 0.12mm solid var(--color-border); + padding: 6px 8px; + vertical-align: middle; +}} + +/* 헀더 행 */ +thead th, tr:first-child th, tr:first-child td {{ + background-color: var(--color-header-bg); + font-weight: bold; + text-align: center; +}} + +/* 첫 ì—Ž (구분 ì—Ž) - 배겜색 */ +td:first-child {{ + background-color: var(--color-header-bg); + text-align: center; + font-weight: 500; +}} + +/* 볞묞 셀 - 배겜 없음 */ +td:not(:first-child) {{ + background-color: transparent; +}} + +/* 2쀑선 테두늬 (헀더 하당) */ +thead tr:last-child th, +thead tr:last-child td, +tr:first-child th, +tr:first-child td {{ + border-bottom: 0.5mm double var(--color-border); +}} +""" + return css + + # ========================================================================= + # 유틞늬티 + # ========================================================================= + + def _normalize_color(self, color: str) -> str: + """ARGB 8자늬 → RGB 6자늬""" + if not color or color == 'none': + return color + color = color.strip() + # #AARRGGBB → #RRGGBB + if color.startswith('#') and len(color) == 9: + return '#' + color[3:] + return color + + def _parse_width(self, width_str: str) -> float: + """너비 묞자엎 → mm""" + if not width_str: + return 0.1 + try: + return float(width_str.split()[0]) + except: + return 0.1 + + def _extract_features(self, data: Dict) -> List[str]: + """특징 목록""" + features = [] + + fonts = list(data.get('fonts', {}).values()) + if fonts: + features.append(f"폰튾: {', '.join(fonts[:2])}") + + bg_colors = data.get('colors', {}).get('background', []) + if bg_colors: + features.append(f"배겜색: {', '.join(bg_colors[:2])}") + + tables = data.get('tables', []) + if tables: + has_img = any(t['structure']['has_image_cells'] for t in tables) + if has_img: + features.append("읎믞지 배겜 셀") + + special = data.get('special_borders', []) + if special: + types = set(s['type_name'] for s in special) + features.append(f"특수 테두늬: {', '.join(list(types)[:2])}") + + return features if features else ['Ʞ볞 템플늿'] + + def _analyze_fallback(self, ext: str) -> Dict: + """HWP, PDF Ʞ볞 분석""" + return { + 'version': 'v3', + 'fonts': {'0': '맑은 고딕'}, + 'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']}, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': { + '폰튾': ['맑은 고딕'], + '색상': {'배겜색': [], '테두늬색': ['#000000']}, + '표_슀타음': [], + '특수_테두늬': [] + }, + 'css': self._get_default_css(), + 'note': f'{ext} 파음은 Ʞ볞 분석만 지원. HWPX 권장.' + } + + def _get_default_css(self) -> str: + return """/* Ʞ볞 슀타음 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; } +.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; } +table { width: 100%; border-collapse: collapse; } +th, td { border: 0.5pt solid #000; padding: 8px; } +th { background: #D6D6D6; } +""" \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/prompts/analyze_template.txt b/03. Code/geulbeot_10th/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 묞서 템플늿 분석 전묞가입니닀. + +죌얎진 HWPX/HWP/PDF 템플늿의 구조륌 분석하여 닀음 정볎륌 추출핎죌섞요: + +1. 제목 슀타음 (H1~H6) + - 폰튞명, 크Ʞ(pt), 굵Ʞ, 색상 + - 정렬 방식 + - 번혞 첎계 (제1장, 1.1, 가. 등) + +2. 볞묞 슀타음 + - Ʞ볞 폰튾, 크Ʞ, 쀄간격 + - 듀여쓰Ʞ + +3. 표 슀타음 + - 헀더 배겜색 + - 테두늬 슀타음 (선 두께, 색상) + - 읎쀑선 사용 여부 + +4. 귞늌/캡션 슀타음 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페읎지 구성 + - 표지 유묎 + - 목찚 유묎 + - 뚞늬말/ꌬ늬말 + +분석 결곌륌 JSON 형식윌로 출력핎죌섞요. \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/semantic_mapper.py b/03. Code/geulbeot_10th/handlers/template/semantic_mapper.py new file mode 100644 index 0000000..8d6b2b5 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/semantic_mapper.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Semantic Mapper v1.0 + +HWPX tools 추출 결곌(template_info)에서 각 요소의 "의믞"륌 판별. + +역할: + - 표 분류: 헀더표 / 푾터표 / 제목랔록 / 데읎터표 + - 섹션 감지: 볞묞 텍슀튞에서 섹션 팹턮 탐색 + - 슀타음 맀핑 쀀비: charPr→HTML태귞, borderFill→CSS큎래슀 (Phase 2에서 구현) + +입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결곌) +출력: semantic_map dict → semantic_map.json윌로 저장 + +★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토늬 +★ 혞출: template_manager.extract_and_save() 낎에서 analyze() 직후 +""" + +import re + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate(template_info: dict, parsed: dict) -> dict: + """semantic_map 생성 — 몚든 판별 로직 조합. + + Args: + template_info: DocTemplateAnalyzer.analyze() 결곌 + parsed: HWPX 파서 결곌 (raw_xml, section_xml, paragraphs 등) + + Returns: + { + "version": "1.0", + "table_roles": { "0": {"role": "footer_table", ...}, ... }, + "body_tables": [3], # 볞묞에 듀얎갈 표 index 목록 + "title_table": 2, # 제목 랔록 index (없윌멎 None) + "sections": [...], # 감지된 섹션 목록 + "style_mappings": {...}, # Phase 2용 슀타음 맀핑 (현재 빈 구조) + } + """ + tables = template_info.get("tables", []) + header = template_info.get("header") + footer = template_info.get("footer") + + # ① 표 역할 분류 + table_roles = _classify_tables(tables, header, footer) + + # ② 볞묞 전용 표 / 제목 랔록 추출 + body_tables = sorted( + idx for idx, info in table_roles.items() + if info["role"] == "data_table" + ) + title_table = next( + (idx for idx, info in table_roles.items() + if info["role"] == "title_block"), + None + ) + + # ③ 섹션 감지 + sections = _detect_sections(parsed) + + # ④ 슀타음 맀핑 (Phase 2에서 구현, 현재는 빈 구조) + style_mappings = _prepare_style_mappings(template_info) + + return { + "version": "1.0", + "table_roles": table_roles, + "body_tables": body_tables, + "title_table": title_table, + "sections": sections, + "style_mappings": style_mappings, + } + + +# ================================================================ +# 표 분류 +# ================================================================ + +def _classify_tables(tables: list, header: dict | None, + footer: dict | None) -> dict: + """각 표의 역할 판별: header_table / footer_table / title_block / data_table + + 판별 순서: + Pass 1 — header/footer 텍슀튞 맀칭 + Pass 2 — 제목 랔록 팹턮 (1행, 좁은+넓은 ì—Ž 구조) + Pass 3 — 나뚞지 → 데읎터 표 + """ + header_texts = _collect_hf_texts(header) + footer_texts = _collect_hf_texts(footer) + + roles = {} + classified = set() + + # ── Pass 1: header/footer 맀칭 ── + for tbl in tables: + idx = tbl["index"] + tbl_texts = _collect_table_texts(tbl) + if not tbl_texts: + continue + + # header 맀칭 + if header_texts: + overlap = len(tbl_texts & header_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "header_table", + "match_source": "header", + "matched_texts": list(tbl_texts & header_texts), + } + classified.add(idx) + continue + + # footer 맀칭 + if footer_texts: + overlap = len(tbl_texts & footer_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "footer_table", + "match_source": "footer", + "matched_texts": list(tbl_texts & footer_texts), + } + classified.add(idx) + continue + + # ── Pass 2: 제목 랔록 탐지 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + if _is_title_block(tbl): + title_text = _extract_longest_text(tbl) + roles[idx] = { + "role": "title_block", + "title_text": title_text, + } + classified.add(idx) + continue + + # ── Pass 3: 나뚞지 → 데읎터 표 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + col_headers = _detect_table_headers(tbl) + roles[idx] = { + "role": "data_table", + "header_row": 0 if col_headers else None, + "col_headers": col_headers, + "row_count": tbl.get("rowCnt", 0), + "col_count": tbl.get("colCnt", 0), + } + + return roles + + +# ── 표 분류 볎조 핚수 ── + +def _collect_hf_texts(hf_info: dict | None) -> set: + """header/footer의 table 셀 텍슀튞 수집""" + if not hf_info or not hf_info.get("table"): + return set() + texts = set() + for row in hf_info["table"].get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _collect_table_texts(tbl: dict) -> set: + """표의 몚든 셀 텍슀튞 수집""" + texts = set() + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _extract_longest_text(tbl: dict) -> str: + """표에서 가장 ꞎ 텍슀튞 추출 (제목 랔록용)""" + longest = "" + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if len(t) > len(longest): + longest = t + return longest + + +def _is_title_block(tbl: dict) -> bool: + """제목 랔록 팹턮 판별. + + 조걎 (하나띌도 충족): + A) 1행 2ì—Ž, 왌쪜 ì—Ž 비윚 ≀ 10% (불늿아읎윘 + 제목) + B) 1행 1ì—Ž, 텍슀튞 Ꞟ읎 5~100자 (제목 당독) + """ + if tbl.get("rowCnt", 0) != 1: + return False + + col_cnt = tbl.get("colCnt", 0) + col_pcts = tbl.get("colWidths_pct", []) + + # 팹턮 A: 좁은 왌쪜 + 넓은 였륞쪜 + if col_cnt == 2 and len(col_pcts) >= 2: + if col_pcts[0] <= 10: + return True + + # 팹턮 B: 닚음 셀 제목 + if col_cnt == 1: + rows = tbl.get("rows", []) + if rows and rows[0]: + text = rows[0][0].get("text", "") + if 5 < len(text) < 100: + return True + + return False + + +def _detect_table_headers(tbl: dict) -> list: + """표 첫 행의 컬럌 헀더 텍슀튞 반환. + + 헀더 판별: 첫 행의 몚든 텍슀튞가 짧음 (20자 읎하) + """ + rows = tbl.get("rows", []) + if not rows or len(rows) < 2: + return [] + + first_row = rows[0] + headers = [] + for cell in first_row: + t = cell.get("text", "").strip() + headers.append(t) + + # 전부 짧은 텍슀튞읎멎 헀더행 + if headers and all(len(h) <= 20 for h in headers if h): + non_empty = [h for h in headers if h] + if non_empty: # 최소 1개는 텍슀튞가 있얎알 + return headers + + return [] + + +# ================================================================ +# 섹션 감지 +# ================================================================ + +_SECTION_PATTERNS = [ + (r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요" + (r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ][\.\s]+(.+)', "roman"), # "Ⅰ. 개요" + (r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요" + (r'^[▶►▞●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요" +] + + +def _detect_sections(parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색. + + Returns: + [ + {"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"}, + {"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"}, + ... + ] + """ + paragraphs = _extract_paragraphs(parsed) + sections = [] + sec_idx = 0 + + for text in paragraphs: + text = text.strip() + if not text or len(text) > 100: + # 너묎 ꞎ 텍슀튞는 제목읎 아님 + continue + + for pat, pat_type in _SECTION_PATTERNS: + m = re.match(pat, text) + if m: + # numbered 팹턮: 숫자가 100 읎상읎멎 섹션 번혞가 아님 (연도 등 제왞) + if pat_type == "numbered" and int(m.group(1)) > 99: + continue + sec_idx += 1 + sections.append({ + "index": sec_idx, + "title": text, + "pattern_type": pat_type, + }) + break + + return sections + + +def _extract_paragraphs(parsed: dict) -> list: + """parsed에서 텍슀튞 닚띜 추출. + + 우선순위: + 1. parsed["paragraphs"] (파서가 직접 제공) + 2. section_xml의 태귞에서 추출 + """ + paragraphs = parsed.get("paragraphs", []) + if paragraphs: + return [ + p.get("text", "") if isinstance(p, dict) else str(p) + for p in paragraphs + ] + + # section_xml에서 추출 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower() and isinstance(val, str): + section_xml = val + break + + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + return [ + t.strip() + for t in re.findall(r'([^<]+)', section_xml) + if t.strip() + ] + + return [] + + +# ================================================================ +# 슀타음 맀핑 (Phase 2에서 확장) +# ================================================================ + +def _prepare_style_mappings(template_info: dict) -> dict: + """슀타음 맀핑 빈 구조 생성. + + Phase 2에서 읎 구조륌 채움: + - char_styles → CSS font/color rules + - border_fills → CSS border/background rules + - para_styles → CSS margin/alignment rules + """ + mappings = { + "char_pr": {}, + "border_fill": {}, + "para_pr": {}, + } + + # border_fills가 있윌멎 Ʞ볞 맀핑 생성 + border_fills = template_info.get("border_fills", {}) + for bf_id, bf_data in border_fills.items(): + # ★ 싀제 í‚€ 구조 대응 (bg→background, sides→css/직접킀) + bg = bf_data.get("background", bf_data.get("bg", "")) + + # borders: css dict 또는 직접 킀에서 추출 + borders = {} + css_dict = bf_data.get("css", {}) + if css_dict: + for prop, val in css_dict.items(): + if prop.startswith("border-") and val and val != "none": + borders[prop] = val + else: + # fallback: 직접 side í‚€ + for side in ("top", "bottom", "left", "right"): + si = bf_data.get(side, {}) + if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE": + borders[f"border-{side}"] = ( + f"{si.get('width','0.1mm')} " + f"{si.get('type','solid').lower()} " + f"{si.get('color','#000')}" + ) + + mappings["border_fill"][str(bf_id)] = { + "css_class": f"bf-{bf_id}", + "bg": bg, + "borders": borders, + } + + return mappings \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/style_generator.py b/03. Code/geulbeot_10th/handlers/template/style_generator.py new file mode 100644 index 0000000..29f5579 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/style_generator.py @@ -0,0 +1,824 @@ +# -*- coding: utf-8 -*- +""" +Style Generator v2.1 (Phase 4 — 하드윔딩 제거) + +template_info의 tools 추출값 → CSS 묞자엎 생성. + +★ v2.1 변겜사항: + - 하드윔딩 간격 → 추출값 대첎: + · .doc-header margin-bottom → page.margins.header에서 계산 + · .doc-footer margin-top → page.margins.footer에서 계산 + · .title-block margin/padding → title paraPr spacing에서 유도 + - .img-wrap, .img-caption CSS 추가 (content_order 읎믞지 지원) + +★ v2.0 변겜사항 (v1.0 대비): + - charPr 28개 전첎 → .cpr-{id} CSS 큎래슀 생성 + - paraPr 23개 전첎 → .ppr-{id} CSS 큎래슀 생성 + - styles 12개 → .sty-{id} CSS 큎래슀 (charPr + paraPr 조합) + - fontRef → 싀제 폰튞명 핎석 (font_map 빌드) + - 제목 랔록: 하드윔딩 제거 → 싀제 추출 데읎터 사용 + - 쀄간격: paraPr별 line-height 개별 적용 + - 여백: @page는 읞쇄용, .page는 화멎용 (읎쀑 적용 제거) + - bf CSS: NONE-only borderFill도 큎래슀 생성 (border: none 명시) + - 텍슀튞 색상: charPr별 color 반영 + - 폰튾: charPr별 fontRef → 싀제 font-family 핎석 + +★ 원칙: hwpx_domain_guide.md §1~§8 맀핑 규칙 100% 쀀수 +★ 원칙: 하드윔딩 값 0개. 몚든 CSS 값은 template_info에서 유래. +""" + +HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate_css(template_info: dict, semantic_map: dict = None) -> str: + """template_info + semantic_map → CSS 묞자엎 전첎 생성.""" + # font_map 빌드 (charPr CSS에서 재사용) + fm = _build_font_map(template_info) + + parts = [ + _page_css(template_info), + _body_css(template_info, fm), + _layout_css(template_info), + _header_footer_css(template_info), + _title_block_css(template_info, fm, semantic_map), + _section_css(template_info), + _table_base_css(template_info), + _border_fill_css(template_info), + _char_pr_css(template_info, fm), + _para_pr_css(template_info), + _named_style_css(template_info), + _table_detail_css(template_info, semantic_map), + ] + return "\n\n".join(p for p in parts if p) + + +# ================================================================ +# @page (읞쇄 전용) +# ================================================================ + +def _page_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + h = paper.get("height_mm", 297) + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + ml = margins.get("left", "20mm") + mr = margins.get("right", "20mm") + + return ( + "@page {\n" + f" size: {w}mm {h}mm;\n" + f" margin: {mt} {mr} {mb} {ml};\n" + "}\n" + "@media screen {\n" + " @page { margin: 0; }\n" # 화멎에서는 .page padding만 사용 + "}" + ) + + +# ================================================================ +# body +# ================================================================ + +def _body_css(ti: dict, fm: dict) -> str: + """바탕Ꞁ 슀타음 Ʞ쀀 body CSS""" + # '바탕Ꞁ' 슀타음 → charPr → fontRef → 싀제 폰튾 + base_charpr = _resolve_style_charpr(ti, "바탕Ꞁ") + base_parapr = _resolve_style_parapr(ti, "바탕Ꞁ") + + # 폰튾 + font_family = _charpr_font_family(base_charpr, fm) + # 크Ʞ + size_pt = base_charpr.get("height_pt", 10.0) + # 색상 + color = base_charpr.get("textColor", "#000000") + # 쀄간격 + line_height = _parapr_line_height(base_parapr) + # 정렬 + # body에는 정렬 넣지 않음 (paraPr별로) + + return ( + "body {\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" color: {color};\n" + " margin: 0; padding: 0;\n" + "}" + ) + + +# ================================================================ +# .page 레읎아웃 (화멎 전용 — 여백은 여Ʞ서만) +# ================================================================ + +def _layout_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + ml = _mm(margins.get("left", "20mm")) + mr = _mm(margins.get("right", "20mm")) + body_w = w - ml - mr + + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + m_left = margins.get("left", "20mm") + m_right = margins.get("right", "20mm") + + return ( + ".page {\n" + f" width: {body_w:.0f}mm;\n" + " margin: 0 auto;\n" + f" padding: {mt} {m_right} {mb} {m_left};\n" + "}" + ) + + +# ================================================================ +# 헀더 / 푾터 +# ================================================================ + +def _header_footer_css(ti: dict) -> str: + page = ti.get("page", {}) + margins = page.get("margins", {}) + + # 헀더 margin-bottom: page.margins.header에서 유도 + # 푾터 margin-top: page.margins.footer에서 유도 + hdr_margin = margins.get("header", "") + ftr_margin = margins.get("footer", "") + + hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm" + ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm" + + lines = [ + "/* 헀더/푾터 */", + f".doc-header {{ margin-bottom: {hdr_mb}; }}", + f".doc-footer {{ margin-top: {ftr_mt}; }}", + ".doc-header table, .doc-footer table {", + " width: 100%; border-collapse: collapse;", + "}", + ] + + hdr_padding = _hf_cell_padding(ti.get("header")) + ftr_padding = _hf_cell_padding(ti.get("footer")) + + lines.append( + f".doc-header td {{ {hdr_padding} vertical-align: middle; }}" + ) + lines.append( + f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}" + ) + return "\n".join(lines) + + +# ================================================================ +# 제목 랔록 — ★ 하드윔딩 제거, 싀제 데읎터 사용 +# ================================================================ + +def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str: + """제목 랔록 CSS — title_table의 싀제 셀 데읎터에서 추출""" + tables = ti.get("tables", []) + + # semantic_map에서 title_table 읞덱슀 가젞였Ʞ + title_idx = None + if sm: + title_idx = sm.get("title_table") + + title_tbl = None + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + + # 못 찟윌멎 1행 표 쀑 텍슀튞 있는 것 검색 + if not title_tbl: + for t in tables: + rows = t.get("rows", []) + if rows and len(rows) == 1: + for cell in rows[0]: + if cell.get("text", "").strip(): + title_tbl = t + break + if title_tbl: + break + + lines = ["/* 제목 랔록 */"] + + if title_tbl: + # 텍슀튞 있는 셀에서 charPr, paraPr, bf 추출 + title_charpr = None + title_parapr = None + title_bf_id = None + + for row in title_tbl.get("rows", []): + for cell in row: + if cell.get("text", "").strip(): + # ★ primaryCharPrIDRef 사용 (table_v2 추출) + cpr_id = cell.get("primaryCharPrIDRef") + if cpr_id is not None: + title_charpr = next( + (c for c in ti.get("char_styles", []) + if c.get("id") == cpr_id), None + ) + ppr_id = cell.get("primaryParaPrIDRef") + if ppr_id is not None: + title_parapr = next( + (p for p in ti.get("para_styles", []) + if p.get("id") == ppr_id), None + ) + title_bf_id = cell.get("borderFillIDRef") + break + if title_charpr: + break + + # charPr 못 찟윌멎 폎백 (charPrIDRef가 없는 구버전 table.py) + if not title_charpr: + title_charpr = _find_title_charpr(ti) + + # CSS 생성 + font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif" + size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0 + bold = title_charpr.get("bold", False) if title_charpr else False + color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000" + + # 쀄간격 + line_height = _parapr_line_height(title_parapr) if title_parapr else "180%" + align = _parapr_align(title_parapr) if title_parapr else "center" + + # ★ margin/padding — paraPr 또는 page.margins에서 유도 + title_after_mm = "4mm" # Ʞ볞값 + title_padding = "4mm 0" # Ʞ볞값 + if title_parapr: + margin_info = title_parapr.get("margin", {}) + after_hu = margin_info.get("after_hu", 0) + if after_hu: + title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm" + before_hu = margin_info.get("before_hu", 0) + if before_hu or after_hu: + b_mm = before_hu * HU_TO_MM if before_hu else 4 + a_mm = after_hu * HU_TO_MM if after_hu else 0 + title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0" + + lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + f".title-block h1 {{\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" text-align: {align};\n" + f" line-height: {line_height};\n" + f" margin: 0; padding: {title_padding};\n" + f"}}" + ) + + # bf 적용 (파란 하닚선 등) + if title_bf_id: + bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {}) + css_dict = bf_data.get("css", {}) + bf_rules = [] + for prop, val in css_dict.items(): + if val and val.lower() != "none": + bf_rules.append(f" {prop}: {val};") + if bf_rules: + lines.append( + f".title-block {{\n" + + "\n".join(bf_rules) + + "\n}" + ) + else: + lines.append(".title-block { margin-bottom: 4mm; }") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + ".title-block h1 {\n" + " font-size: 15pt; font-weight: normal;\n" + " text-align: center; margin: 0; padding: 4mm 0;\n" + "}" + ) + + return "\n".join(lines) + + +# ================================================================ +# 섹션 — 하드윔딩 제거 +# ================================================================ + +def _section_css(ti: dict) -> str: + """섹션 CSS — '#큰아읎윘' 또는 '개요1' 슀타음에서 추출""" + lines = ["/* 섹션 */"] + + # 섹션 제목: '#큰아읎윘' 또는 가장 큰 bold charPr + title_charpr = _resolve_style_charpr(ti, "#큰아읎윘") + if not title_charpr or title_charpr.get("id") == 0: + title_charpr = _resolve_style_charpr(ti, "개요1") + if not title_charpr or title_charpr.get("id") == 0: + # 폎백: bold읞 charPr 쀑 가장 큰 것 + for cs in sorted(ti.get("char_styles", []), + key=lambda x: x.get("height_pt", 0), reverse=True): + if cs.get("bold"): + title_charpr = cs + break + + if title_charpr: + size = title_charpr.get("height_pt", 11) + bold = title_charpr.get("bold", True) + color = title_charpr.get("textColor", "#000000") + lines.append( + f".section-title {{\n" + f" font-size: {size}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" margin-bottom: 3mm;\n" + f"}}" + ) + else: + lines.append( + ".section-title { font-weight: bold; margin-bottom: 3mm; }" + ) + + lines.append(".section { margin-bottom: 6mm; }") + lines.append(".section-content { text-align: justify; }") + + # content_order êž°ë°˜ 볞묞용 슀타음 + lines.append("/* 읎믞지/묞닚 (content_order) */") + lines.append( + ".img-wrap { text-align: center; margin: 3mm 0; }" + ) + lines.append( + ".img-wrap img { max-width: 100%; height: auto; }" + ) + lines.append( + ".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }" + ) + + return "\n".join(lines) + + +# ================================================================ +# 데읎터 표 Ʞ볞 CSS +# ================================================================ + +def _table_base_css(ti: dict) -> str: + """표 Ʞ볞 — '표낎용' 슀타음 charPr에서 추출""" + tbl_charpr = _resolve_style_charpr(ti, "표낎용") + tbl_parapr = _resolve_style_parapr(ti, "표낎용") + + size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0 + line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%" + align = _parapr_align(tbl_parapr) if tbl_parapr else "justify" + + border_fills = ti.get("border_fills", {}) + if border_fills: + # bf-{id} 큎래슀가 셀별 테두늬륌 닎당 → Ʞ볞값은 none + # (하드윔딩 border륌 넣윌멎 bf 큎래슀볎닀 specificity가 높아 덮얎씀) + border_rule = "border: none;" + else: + # border_fills 추출 싀팚 시에만 폎백 + border_rule = "border: 1px solid #000;" + + return ( + "/* 데읎터 표 */\n" + ".data-table {\n" + " width: 100%; border-collapse: collapse; margin: 4mm 0;\n" + "}\n" + ".data-table th, .data-table td {\n" + f" {border_rule}\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" text-align: {align};\n" + " vertical-align: middle;\n" + "}\n" + ".data-table th {\n" + " font-weight: bold; text-align: center;\n" + "}" + ) + + +# ================================================================ +# borderFill → .bf-{id} CSS 큎래슀 +# ================================================================ + +def _border_fill_css(ti: dict) -> str: + """★ v2.0: NONE-only bf도 큎래슀 생성 (border: none 명시)""" + border_fills = ti.get("border_fills", {}) + if not border_fills: + return "" + + parts = ["/* borderFill → CSS 큎래슀 */"] + + for bf_id, bf in border_fills.items(): + rules = [] + + css_dict = bf.get("css", {}) + for prop, val in css_dict.items(): + if val: + # NONE도 포핚 (border: none 명시) + rules.append(f" {prop}: {val};") + + # background + if "background-color" not in css_dict: + bg = bf.get("background", "") + if bg and bg.lower() not in ("", "none", "transparent", + "#ffffff", "#fff"): + rules.append(f" background-color: {bg};") + + if rules: + parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: charPr → .cpr-{id} CSS 큎래슀 +# ================================================================ + +def _char_pr_css(ti: dict, fm: dict) -> str: + """charPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포핚. + HTML에서 등윌로 ì°žì¡°. + """ + char_styles = ti.get("char_styles", []) + if not char_styles: + return "" + + parts = ["/* charPr → CSS 큎래슀 (Ꞁ자 몚양) */"] + + for cs in char_styles: + cid = cs.get("id") + rules = [] + + # font-family + ff = _charpr_font_family(cs, fm) + if ff: + rules.append(f" font-family: {ff};") + + # font-size + pt = cs.get("height_pt") + if pt: + rules.append(f" font-size: {pt}pt;") + + # bold + if cs.get("bold"): + rules.append(" font-weight: bold;") + + # italic + if cs.get("italic"): + rules.append(" font-style: italic;") + + # color + color = cs.get("textColor", "#000000") + if color and color.lower() != "#000000": + rules.append(f" color: {color};") + + # underline — type읎 NONE읎 아닌 싀제 밑쀄만 + underline = cs.get("underline", "NONE") + ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"} + if underline in ACTIVE_UNDERLINE: + rules.append(" text-decoration: underline;") + + # strikeout — shape="NONE" 또는 "3D"는 췚소선 아님 + # 싀제 췚소선: CONTINUOUS, DASH, DOT 등 선 슀타음만 + strikeout = cs.get("strikeout", "NONE") + ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT", + "DASH_DOT_DOT", "LONG_DASH", "DOUBLE"} + if strikeout in ACTIVE_STRIKEOUT: + rules.append(" text-decoration: line-through;") + + # ── 자간 (letter-spacing) ── + # HWPX spacing은 % 닚위: letter-spacing = height_pt × spacing / 100 + spacing_pct = cs.get("spacing", {}).get("hangul", 0) + if spacing_pct != 0 and pt: + ls_val = round(pt * spacing_pct / 100, 2) + rules.append(f" letter-spacing: {ls_val}pt;") + + # ── 장평 (scaleX) ── + # HWPX ratio는 Ꞁ자 폭 비윚 (100=Ʞ볞). CSS transform윌로 변환 + ratio_pct = cs.get("ratio", {}).get("hangul", 100) + if ratio_pct != 100: + rules.append(f" transform: scaleX({ratio_pct / 100});") + rules.append(" display: inline-block;") # scaleX 적용 필수 + + if rules: + parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: paraPr → .ppr-{id} CSS 큎래슀 +# ================================================================ + +def _para_pr_css(ti: dict) -> str: + """paraPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포핚. + HTML에서

        등윌로 ì°žì¡°. + """ + para_styles = ti.get("para_styles", []) + if not para_styles: + return "" + + parts = ["/* paraPr → CSS 큎래슀 (묞닚 몚양) */"] + + for ps in para_styles: + pid = ps.get("id") + rules = [] + + # text-align + align = _parapr_align(ps) + if align: + rules.append(f" text-align: {align};") + + # line-height + lh = _parapr_line_height(ps) + if lh: + rules.append(f" line-height: {lh};") + + # text-indent + margin = ps.get("margin", {}) + indent_hu = margin.get("indent_hu", 0) + if indent_hu: + indent_mm = indent_hu * HU_TO_MM + rules.append(f" text-indent: {indent_mm:.1f}mm;") + + # margin-left + left_hu = margin.get("left_hu", 0) + if left_hu: + left_mm = left_hu * HU_TO_MM + rules.append(f" margin-left: {left_mm:.1f}mm;") + + # margin-right + right_hu = margin.get("right_hu", 0) + if right_hu: + right_mm = right_hu * HU_TO_MM + rules.append(f" margin-right: {right_mm:.1f}mm;") + + # spacing before/after + before = margin.get("before_hu", 0) + if before: + rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;") + after = margin.get("after_hu", 0) + if after: + rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;") + + if rules: + parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: named style → .sty-{id} CSS 큎래슀 +# ================================================================ + +def _named_style_css(ti: dict) -> str: + """styles 목록 → .sty-{id} CSS 큎래슀. + + 각 style은 charPrIDRef + paraPrIDRef 조합. + → .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의믞. + HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 ì°žì¡°. + """ + styles = ti.get("styles", []) + if not styles: + return "" + + parts = ["/* named styles */"] + + for s in styles: + sid = s.get("id") + name = s.get("name", "") + cpr_id = s.get("charPrIDRef") + ppr_id = s.get("paraPrIDRef") + + # 죌석윌로 맀핑 Ʞ록 + parts.append( + f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */" + ) + + return "\n".join(parts) + + +# ================================================================ +# 표 상섞 CSS (ì—Ž 너비, 셀 팚딩) +# ================================================================ + +def _table_detail_css(ti: dict, sm: dict = None) -> str: + if not sm: + return "" + + body_indices = sm.get("body_tables", []) + tables = ti.get("tables", []) + if not body_indices or not tables: + return "" + + parts = ["/* 표 상섞 (tools 추출값) */"] + + for tbl_num, tbl_idx in enumerate(body_indices, 1): + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + cls = f"tbl-{tbl_num}" + + # ì—Ž 너비 + col_pcts = tbl.get("colWidths_pct", []) + if col_pcts: + for c_idx, pct in enumerate(col_pcts): + parts.append( + f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}" + ) + + # 셀 팚딩 + cm = _first_cell_margin(tbl) + if cm: + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + parts.append( + f".{cls} td, .{cls} th {{\n" + f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n" + f"}}" + ) + + # 헀더행 높읎 + first_row = tbl.get("rows", [[]])[0] + if first_row: + h_hu = first_row[0].get("height_hu", 0) + if h_hu > 0: + h_mm = h_hu * HU_TO_MM + parts.append( + f".{cls} thead th {{ height: {h_mm:.1f}mm; }}" + ) + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# 볎조 핚수 +# ================================================================ + +def _build_font_map(ti: dict) -> dict: + """fonts → {(lang, id): face_name} 딕셔너늬""" + fm = {} + for lang, flist in ti.get("fonts", {}).items(): + if isinstance(flist, list): + for f in flist: + fm[(lang, f.get("id", 0))] = f.get("face", "") + return fm + + +def _charpr_font_family(charpr: dict, fm: dict) -> str: + """charPr의 fontRef → 싀제 font-family CSS 값""" + if not charpr: + return "'맑은 고딕', sans-serif" + + fr = charpr.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + latin_id = fr.get("latin", 0) + + hangul_face = fm.get(("HANGUL", hangul_id), "") + latin_face = fm.get(("LATIN", latin_id), "") + + faces = [] + if hangul_face: + faces.append(f"'{hangul_face}'") + if latin_face and latin_face != hangul_face: + faces.append(f"'{latin_face}'") + faces.append("sans-serif") + + return ", ".join(faces) + + +def _resolve_style_charpr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → charPr dict 핎석""" + styles = ti.get("styles", []) + char_styles = ti.get("char_styles", []) + + for s in styles: + if s.get("name") == style_name: + cpr_id = s.get("charPrIDRef") + for cs in char_styles: + if cs.get("id") == cpr_id: + return cs + + # 못 찟윌멎 charPr[0] (바탕Ꞁ Ʞ볞) + return char_styles[0] if char_styles else {} + + +def _resolve_style_parapr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → paraPr dict 핎석""" + styles = ti.get("styles", []) + para_styles = ti.get("para_styles", []) + + for s in styles: + if s.get("name") == style_name: + ppr_id = s.get("paraPrIDRef") + for ps in para_styles: + if ps.get("id") == ppr_id: + return ps + + return para_styles[0] if para_styles else {} + + +def _find_title_charpr(ti: dict) -> dict: + """제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폎백). + + 헀드띌읞 폰튾 or 가장 큰 크Ʞ Ʞ쀀. + """ + headline_keywords = ["헀드띌읞", "headline", "제목", "title"] + fm = _build_font_map(ti) + + best = {} + best_pt = 0 + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + fr = cs.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + face = fm.get(("HANGUL", hangul_id), "").lower() + + # 헀드띌읞 폰튞멎 우선 + if any(kw in face for kw in headline_keywords): + if pt > best_pt: + best_pt = pt + best = cs + + # 헀드띌읞 폰튾 못 찟윌멎 가장 큰 것 + if not best: + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + if pt > best_pt: + best_pt = pt + best = cs + + return best + + +def _parapr_line_height(parapr: dict) -> str: + """paraPr → CSS line-height""" + if not parapr: + return "160%" + ls = parapr.get("lineSpacing", {}) + ls_type = ls.get("type", "PERCENT") + ls_val = ls.get("value", 160) + if ls_type == "PERCENT": + return f"{ls_val}%" + elif ls_type == "FIXED": + return f"{ls_val / 100:.1f}pt" + else: + return f"{ls_val}%" + + +def _parapr_align(parapr: dict) -> str: + """paraPr → CSS text-align""" + if not parapr: + return "justify" + align = parapr.get("align", "JUSTIFY") + return { + "JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right", + "CENTER": "center", "DISTRIBUTE": "justify", + "DISTRIBUTE_SPACE": "justify" + }.get(align, "justify") + + +def _hf_cell_padding(hf_info: dict | None) -> str: + if not hf_info or not hf_info.get("table"): + return "padding: 2px 4px;" + rows = hf_info["table"].get("rows", []) + if not rows or not rows[0]: + return "padding: 2px 4px;" + cm = rows[0][0].get("cellMargin", {}) + if not cm: + return "padding: 2px 4px;" + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;" + + +def _first_cell_margin(tbl: dict) -> dict | None: + for row in tbl.get("rows", []): + for cell in row: + cm = cell.get("cellMargin") + if cm: + return cm + return None + + +def _mm(val) -> float: + if isinstance(val, (int, float)): + return float(val) + try: + return float(str(val).replace("mm", "").strip()) + except (ValueError, TypeError): + return 20.0 \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/template_manager.py b/03. Code/geulbeot_10th/handlers/template/template_manager.py new file mode 100644 index 0000000..8894bd7 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/template_manager.py @@ -0,0 +1,1010 @@ +# -*- coding: utf-8 -*- +""" +템플늿 ꎀ늬자 (Template Manager) v5.2 +- 템플늿의 독늜적 CRUD (생성/조회/삭제/교첎) +- 묞서 유형(DocType)곌 분늬된 저장 구조 +- HWPX에서 템플늿 추출 → templates/user/templates/{tpl_id}/ 에 저장 + +★ v5.2 변겜: + - _build_body_html() 재섀계: content_order êž°ë°˜ 볞묞 생성 + → 묞닚·읎믞지·표륌 원볞 순서 귞대로 HTML 조늜 + → content_order 없윌멎 Ʞ졎 legacy 방식 자동 fallback + - _build_title_block_html() 분늬 (재사용성) + +★ 저장 구조: +templates/user/ +├── doc_types/{type_id}/ +│ ├── config.json ← 유형 정볎 (맥띜/구조/가읎드) +│ └── template_id: "tpl_xxx" ← ì–Žë–€ 템플늿 찞조하는지 +│ +└── templates/{tpl_id}/ + ├── template.html ← HTML 곚격 + placeholder + ├── style.json ← 테두늬/폰튾/색상/여백/borderFill + └── meta.json ← 읎늄, 출처, 생성음 + +★ 사용 흐멄: +1) "템플늿 추가" → extract_and_save(hwpx_path, name) → tpl_id +2) "묞서 유형 추가" → doc_type_analyzer가 낎부적윌로 extract_and_save 혞출 +3) "템플늿 교첎" → change_template(type_id, new_tpl_id) +4) "묞서 생성" → load_template(tpl_id) → template.html + style.json +""" + +import json +import time +import shutil +from pathlib import Path +from typing import Optional + + +class TemplateManager: + """템플늿 독늜 ꎀ늬""" + + # Ʞ볞 겜로 + TEMPLATES_USER = Path('templates/user/templates') + TEMPLATES_DEFAULT = Path('templates/default/templates') + DOC_TYPES_USER = Path('templates/user/doc_types') + + def __init__(self, base_path: str = None): + if base_path: + self.TEMPLATES_USER = Path(base_path) / 'user' / 'templates' + self.TEMPLATES_DEFAULT = Path(base_path) / 'default' / 'templates' + self.DOC_TYPES_USER = Path(base_path) / 'user' / 'doc_types' + + # ================================================================ + # 핵심 API + # ================================================================ + + def extract_and_save(self, parsed: dict, name: str, + source_file: str = "", description: str = "") -> dict: + """ + HWPX 파싱 결곌에서 템플늿 추출 후 저장 + + Args: + parsed: HWPX 파서 결곌 (raw_xml, tables, section_xml, header_xml, footer_xml) + name: 템플늿 읎늄 (예: "GPD 발표Ʞ획서 양식") + source_file: 원볞 파음명 + description: 섀명 + + Returns: + {"success": True, "template_id": "tpl_xxx", "path": "...", "template_info": {...}} + """ + from .doc_template_analyzer import DocTemplateAnalyzer + + try: + analyzer = DocTemplateAnalyzer() + + # ① 구조 추출 (template_info) + template_info = analyzer.analyze(parsed) + + # ①-b semantic_map 생성 (표 역할 분류, 섹션 감지) + from . import semantic_mapper + semantic_map = semantic_mapper.generate(template_info, parsed) + + # ② HTML 생성 (semantic_map윌로 표 필터링) + template_html = self._generate_basic_html(template_info, parsed, semantic_map) + + # 저장 + tpl_id = f"tpl_{int(time.time())}" + tpl_path = self.TEMPLATES_USER / tpl_id + tpl_path.mkdir(parents=True, exist_ok=True) + + # template.html + (tpl_path / 'template.html').write_text(template_html, encoding='utf-8') + + # style.json (template_info + 추출된 슀타음) + style_data = { + "version": "v4", + "source": "doc_template_analyzer", + "template_info": template_info, + "css": "", # 추후 컀슀텀 CSS 였버띌읎드용 + "fonts": {}, + "colors": self._extract_colors(template_info), + "border_fills": template_info.get("border_fills", {}), + "tables": [], + "style_summary": {} + } + (tpl_path / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # meta.json + meta = { + "id": tpl_id, + "name": name, + "original_file": source_file, + "file_type": Path(source_file).suffix if source_file else ".hwpx", + "description": description, + "features": self._summarize_features(template_info, semantic_map), + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": "doc_template_analyzer" + } + (tpl_path / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # semantic_map.json + (tpl_path / 'semantic_map.json').write_text( + json.dumps(semantic_map, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "template_id": tpl_id, + "path": str(tpl_path), + "template_info": template_info, + "semantic_map": semantic_map, + "meta": meta + } + + except Exception as e: + import traceback + return {"error": str(e), "trace": traceback.format_exc()} + + def load_template(self, tpl_id: str) -> dict: + """ + 템플늿 로드 (template.html + style.json) + + Returns: + {"html": "...", "style": {...}, "meta": {...}} + """ + # 사용자 템플늿 → Ʞ볞 템플늿 순서로 탐색 + for base in [self.TEMPLATES_USER, self.TEMPLATES_DEFAULT]: + tpl_path = base / tpl_id + if tpl_path.exists(): + result = {} + + html_file = tpl_path / 'template.html' + if html_file.exists(): + result["html"] = html_file.read_text(encoding='utf-8') + + style_file = tpl_path / 'style.json' + if style_file.exists(): + result["style"] = json.loads(style_file.read_text(encoding='utf-8')) + + meta_file = tpl_path / 'meta.json' + if meta_file.exists(): + result["meta"] = json.loads(meta_file.read_text(encoding='utf-8')) + + result["template_id"] = tpl_id + result["path"] = str(tpl_path) + return result + + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + def list_templates(self) -> list: + """몚든 템플늿 목록 조회""" + templates = [] + + for base, is_default in [(self.TEMPLATES_DEFAULT, True), (self.TEMPLATES_USER, False)]: + if not base.exists(): + continue + for folder in sorted(base.iterdir()): + if not folder.is_dir(): + continue + meta_file = folder / 'meta.json' + if meta_file.exists(): + try: + meta = json.loads(meta_file.read_text(encoding='utf-8')) + meta["is_default"] = is_default + templates.append(meta) + except: + templates.append({ + "id": folder.name, + "name": folder.name, + "is_default": is_default + }) + + return templates + + def delete_template(self, tpl_id: str) -> dict: + """템플늿 삭제 (사용자 템플늿만)""" + tpl_path = self.TEMPLATES_USER / tpl_id + + if not tpl_path.exists(): + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + # 읎 템플늿을 찞조하는 DocType읎 있는지 확읞 + referencing = self._find_referencing_doc_types(tpl_id) + if referencing: + names = ', '.join(r['name'] for r in referencing[:3]) + return { + "error": f"읎 템플늿을 사용 쀑읞 묞서 유형읎 있습니닀: {names}", + "referencing_types": referencing + } + + shutil.rmtree(tpl_path) + return {"success": True, "deleted": tpl_id} + + def change_template(self, type_id: str, new_tpl_id: str) -> dict: + """ + 묞서 유형의 템플늿 교첎 + + Args: + type_id: 묞서 유형 ID + new_tpl_id: 새 템플늿 ID + """ + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + # 새 템플늿 졎재 확읞 + new_tpl = self.load_template(new_tpl_id) + if "error" in new_tpl: + return new_tpl + + # config 업데읎튞 + config = json.loads(config_path.read_text(encoding='utf-8')) + old_tpl_id = config.get("template_id", "") + config["template_id"] = new_tpl_id + config["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ") + + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "type_id": type_id, + "old_template_id": old_tpl_id, + "new_template_id": new_tpl_id + } + + def get_template_for_doctype(self, type_id: str) -> dict: + """묞서 유형에 연결된 템플늿 로드""" + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + # default에서도 탐색 + config_path = self.TEMPLATES_DEFAULT.parent / 'doc_types' / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + config = json.loads(config_path.read_text(encoding='utf-8')) + tpl_id = config.get("template_id") + + if not tpl_id: + # ★ 하위 혾환: template_id가 없윌멎 같은 폎더의 template.html 사용 + legacy_path = config_path.parent / 'template.html' + if legacy_path.exists(): + return { + "html": legacy_path.read_text(encoding='utf-8'), + "style": {}, + "meta": {"id": type_id, "name": "레거시 템플늿"}, + "template_id": None, + "legacy": True + } + return {"error": "연결된 템플늿읎 없습니닀"} + + return self.load_template(tpl_id) + + # ================================================================ + # 낎부 유틞 + # ================================================================ + + def _find_referencing_doc_types(self, tpl_id: str) -> list: + """특정 템플늿을 찞조하는 DocType 목록""" + result = [] + if not self.DOC_TYPES_USER.exists(): + return result + + for folder in self.DOC_TYPES_USER.iterdir(): + config_file = folder / 'config.json' + if config_file.exists(): + try: + config = json.loads(config_file.read_text(encoding='utf-8')) + if config.get("template_id") == tpl_id: + result.append({ + "id": config.get("id", folder.name), + "name": config.get("name", folder.name) + }) + except: + pass + return result + + + def _generate_basic_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """tools 추출 결곌 + style_generator → template.html 생성""" + # ① CSS 생성 (style_generator) + from . import style_generator + css = style_generator.generate_css(template_info, semantic_map) + + # ② 헀더 HTML + header_html = self._build_header_html(template_info.get("header")) + + # ③ 푾터 HTML + footer_html = self._build_footer_html(template_info.get("footer")) + + # ④ 볞묞 HTML (섹션 + 표) + body_html = self._build_body_html(template_info, parsed, semantic_map) + + # â‘€ 조늜 + html = f""" + + + +Template + + + +

        + +{header_html} + +{body_html} + +{footer_html} + +
        + +""" + return html + + # ── 볎조 메서드듀 ── + def _build_header_html(self, header_info: dict | None) -> str: + """header tools 추출값 → HTML + placeholder""" + if not header_info or not header_info.get("exists"): + return "" + + html = '
        \n' + + if header_info.get("type") == "table" and header_info.get("table"): + tbl = header_info["table"] + rows = tbl.get("rows", []) + col_pcts = tbl.get("colWidths_pct", []) + + # ★ 추가: colWidths_pct 없윌멎 셀 width_hu에서 계산 + if not col_pcts and rows: + widths = [c.get("width_hu", 0) for c in rows[0]] + total = sum(widths) + if total > 0: + col_pcts = [round(w / total * 100) for w in widths] + + html += '\n' + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + cell_text = cell.get("text", "").strip() # ★ 추가 + ph_name = f"HEADER_R{r_idx+1}_C{c_idx+1}" + + # ★ 수정: 텍슀튞 없는 셀은 비움 + if not cell_text and not lines: + content = "" + elif len(lines) > 1: + # 멀티띌읞 셀 → 각 띌읞별 placeholder + line_phs = [] + for l_idx in range(len(lines)): + line_phs.append(f"{{{{{ph_name}_LINE_{l_idx+1}}}}}") + content = "
        ".join(line_phs) + else: + content = f"{{{{{ph_name}}}}}" + + # colSpan/rowSpan + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + if cell.get("colSpan", 1) > 1: + attrs += f' colspan="{cell["colSpan"]}"' + if cell.get("rowSpan", 1) > 1: + attrs += f' rowspan="{cell["rowSpan"]}"' + + html += f' {content}\n' + html += '\n' + + html += '
        \n' + else: + # 텍슀튞형 헀더 + texts = header_info.get("texts", []) + for i in range(max(len(texts), 1)): + html += f'
        {{{{{f"HEADER_TEXT_{i+1}"}}}}}
        \n' + + html += '
        ' + return html + + def _build_footer_html(self, footer_info: dict | None) -> str: + """footer tools 추출값 → HTML + placeholder""" + if not footer_info or not footer_info.get("exists"): + return "" + + html = '' + return html + + def _build_body_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """볞묞 영역 HTML 생성. + + ★ v5.2: content_order가 있윌멎 원볞 순서 귞대로 조늜. + content_order 없윌멎 Ʞ졎 섹션+표 방식 (하위 혾환). + """ + content_order = template_info.get("content_order") + + if content_order and self._has_paragraph_content(content_order): + return self._build_body_from_content_order( + template_info, content_order, semantic_map + ) + else: + return self._build_body_legacy( + template_info, parsed, semantic_map + ) + + # ── content_order êž°ë°˜ 볞묞 생성 (v5.2+) ── + + def _has_paragraph_content(self, content_order: list) -> bool: + """content_order에 묞닚읎 있는지 (표만 있윌멎 legacy 사용)""" + return any( + c['type'] == 'paragraph' for c in content_order + ) + + def _build_body_from_content_order(self, template_info: dict, + content_order: list, + semantic_map: dict = None) -> str: + """content_order êž°ë°˜ — 원볞 묞서 순서 귞대로 HTML 조늜. + + 윘텐잠 유형별 처늬: + paragraph →

        {{CONTENT_n}}

        + table → data-table placeholder (title_table 제왞) + image →
        {{IMAGE_n}}
        + empty → 생략 (연속 빈 묞닚 의믞 없음) + """ + import re + + tables = template_info.get("tables", []) + + # semantic_map에서 title/body 읞덱슀 + title_table_idx = None + body_table_indices = [] + if semantic_map: + title_table_idx = semantic_map.get("title_table") + body_table_indices = semantic_map.get("body_tables", []) + else: + body_table_indices = [t["index"] for t in tables] + + # ★ v5.4: content_order 순서대로 표 맀칭 + # content_order.table_idx는 section 볞묞에서 만난 순서읎지만, + # tables 늬슀튞 순서와 닀륌 수 있윌므로 순찚 컀서 방식윌로 맀칭 + # header/footer/title 제왞한 순수 body_table만 추출 + exclude_indices = set() + if semantic_map: + # header_table, footer_table 제왞 + for idx_key, role_info in semantic_map.get("table_roles", {}).items(): + role = role_info.get("role", "") + if role in ("header_table", "footer_table"): + try: + exclude_indices.add(int(idx_key)) + except (ValueError, TypeError): + pass + # title_table도 제왞 (별도 처늬됚) + if title_table_idx is not None: + exclude_indices.add(title_table_idx) + + body_visible_tables = [ + t for t in tables + if t["index"] not in exclude_indices + ] + + # 순찚 맀칭을 위한 컀서 + body_tbl_cursor = 0 + + body_parts = [] + + # ── 제목 랔록 (title_table읎 있윌멎) ── + if title_table_idx is not None: + title_tbl = next( + (t for t in tables if t["index"] == title_table_idx), None + ) + if title_tbl: + body_parts.append( + self._build_title_block_html(title_tbl) + ) + + # ── content_order 순회 ── + para_num = 0 # 묞닚 placeholder 번혞 + tbl_num = 0 # 데읎터 표 번혞 (1-based) + img_num = 0 # 읎믞지 placeholder 번혞 + in_section = False + section_num = 0 + + # 섹션 제목 팹턮 + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + def _is_section_title(text: str) -> bool: + return any(p.match(text) for p in sec_patterns) + + for item in content_order: + itype = item['type'] + + # ── 빈 묞닚: 생략 ── + if itype == 'empty': + continue + + # ── 표: title_table은 읎믞 처늬, body_table만 ── + # ★ v5.4: 순찚 컀서 방식윌로 맀칭 (table_idx 의졎 제거) + if itype == 'table': + # body_visible_tables에서 닀음 표 가젞였Ʞ + if body_tbl_cursor >= len(body_visible_tables): + continue # 더 읎상 표가 없음 + + tbl_data = body_visible_tables[body_tbl_cursor] + body_tbl_cursor += 1 + + # body 데읎터 표가 아니멎 걎너뛰Ʞ + if tbl_data["index"] not in body_table_indices: + continue + + tbl_num += 1 + col_cnt = item.get('colCnt', '3') + try: + col_cnt = int(col_cnt) + except (ValueError, TypeError): + col_cnt = 3 + + # semantic_map에서 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(tbl_data["index"], + _roles.get(str(tbl_data["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + rows = tbl_data.get("rows", []) + header_row_data = rows[0] if rows else None + col_pcts = tbl_data.get("colWidths_pct", []) + + body_parts.append( + self._build_table_placeholder( + tbl_num, actual_col_cnt, col_pcts, + header_row=header_row_data + ) + ) + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + ppr = item.get('paraPrIDRef', '0') + caption = item.get('text', '') + ref = item.get('binaryItemIDRef', '') + + img_html = f'
        \n' + img_html += f' {{{{IMAGE_{img_num}}}}}\n' + if caption: + img_html += f'

        {{{{IMAGE_{img_num}_CAPTION}}}}

        \n' + img_html += '
        ' + body_parts.append(img_html) + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + ppr = item.get('paraPrIDRef', '0') + cpr = item.get('charPrIDRef', '0') + + # 섹션 제목 감지 + if _is_section_title(text): + # 읎전 섹션 ë‹«êž° + if in_section: + body_parts.append('\n') + + section_num += 1 + in_section = True + body_parts.append( + f'
        \n' + f'

        ' + f'{{{{SECTION_{section_num}_TITLE}}}}

        ' + ) + continue + + # 음반 묞닚 + para_num += 1 + + # runs가 여러 개멎 닀쀑 span + runs = item.get('runs', []) + if len(runs) > 1: + spans = [] + for r_idx, run in enumerate(runs): + r_cpr = run.get('charPrIDRef', cpr) + spans.append( + f'' + f'{{{{PARA_{para_num}_RUN_{r_idx+1}}}}}' + ) + inner = ''.join(spans) + else: + inner = ( + f'' + f'{{{{PARA_{para_num}}}}}' + ) + + body_parts.append( + f'

        {inner}

        ' + ) + + # 마지막 섹션 ë‹«êž° + if in_section: + body_parts.append('
        \n') + + return "\n\n".join(body_parts) + + def _build_title_block_html(self, title_tbl: dict) -> str: + """제목표 → title-block HTML (Ʞ졎 로직 분늬)""" + rows = title_tbl.get("rows", []) + col_pcts = title_tbl.get("colWidths_pct", []) + + html = '
        \n\n' + + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + cs = cell.get("colSpan", 1) + if cs > 1: + attrs += f' colspan="{cs}"' + rs = cell.get("rowSpan", 1) + if rs > 1: + attrs += f' rowspan="{rs}"' + + cell_text = cell.get("text", "").strip() + if cell_text: + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + html += f' {{{{{ph_name}}}}}\n' + else: + html += f' \n' + html += '\n' + + html += '
        \n
        \n' + return html + + # ── Ʞ졎 섹션+표 방식 (하위 혾환) ── + + def _build_body_legacy(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """content_order 없을 때 — Ʞ졎 v5.1 방식 유지""" + body_parts = [] + tables = template_info.get("tables", []) + + # ── semantic_map읎 있윌멎 활용 ── + if semantic_map: + body_table_indices = semantic_map.get("body_tables", []) + title_idx = semantic_map.get("title_table") + else: + # semantic_map 없윌멎 전첎 표 사용 (하위 혾환) + body_table_indices = [t["index"] for t in tables] + title_idx = None + + # ── 제목 랔록 ── + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if title_tbl: + body_parts.append(self._build_title_block_html(title_tbl)) + + # ── 볞묞 데읎터 표만 필터링 ── + body_tables = [t for t in tables if t["index"] in body_table_indices] + + # ── 섹션 감지 ── + section_titles = self._detect_section_titles(parsed) + + if not section_titles and not body_tables: + # 구조 정볎 부족 → Ʞ볞 1섹션 + body_parts.append( + '
        \n' + '
        {{SECTION_1_TITLE}}
        \n' + '
        {{SECTION_1_CONTENT}}
        \n' + '
        ' + ) + else: + sec_count = max(len(section_titles), 1) + tbl_idx = 0 + + for s in range(sec_count): + s_num = s + 1 + body_parts.append( + f'
        \n' + f'
        {{{{SECTION_{s_num}_TITLE}}}}
        \n' + f'
        {{{{SECTION_{s_num}_CONTENT}}}}
        \n' + ) + + # 읎 섹션에 표 배분 + if tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + + # semantic_map에서 싀제 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + # 헀더행 셀 데읎터 (bf_id 포핚) + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data # ★ 헀더행 전달 + ) + ) + tbl_idx += 1 + + body_parts.append('
        \n') + + # 낚은 표 + while tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data + ) + ) + tbl_idx += 1 + + return "\n".join(body_parts) + + def _build_table_placeholder(self, tbl_num: int, col_cnt: int, + col_pcts: list = None, + header_row: list = None) -> str: + """표 1개의 placeholder HTML 생성 + + Args: + tbl_num: 표 번혞 (1-based) + col_cnt: ì—Ž 수 + col_pcts: ì—Ž 너비 % 늬슀튞 + header_row: 헀더행 셀 늬슀튞 [{bf_id, colSpan, ...}, ...] + """ + # colgroup + colgroup = "" + num_cols = len(col_pcts) if col_pcts else col_cnt + if num_cols > 0: + colgroup = "
        {{{{TABLE_{tbl_num}_H_C{c+1}}}}}
        \n' + f'{colgroup}' + f'\n' + f' \n{header_row_html}\n \n' + f'\n' + f'\n' + f' {{{{TABLE_{tbl_num}_BODY}}}}\n' + f'\n' + f'
        ' + ) + + def _detect_section_titles(self, parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색""" + import re + titles = [] + + # parsed에서 텍슀튞 추출 + paragraphs = parsed.get("paragraphs", []) + if not paragraphs: + # raw_xml에서 태귞 텍슀튞 추출 시도 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower(): + section_xml = val if isinstance(val, str) else "" + break + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + t_matches = re.findall(r'([^<]+)', section_xml) + paragraphs = [t.strip() for t in t_matches if t.strip()] + + # 섹션 제목 팹턮 + patterns = [ + r'^(\d+)\.\s+\S', # "1. 제목" + r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S', # "Ⅰ. 제목" + r'^제\s*\d+\s*[장절항]\s*\S', # "제1장 제목" + ] + + for text in paragraphs: + if isinstance(text, dict): + text = text.get("text", "") + text = str(text).strip() + if not text: + continue + for pat in patterns: + if re.match(pat, text): + titles.append(text) + break + + return titles + + def _extract_colors(self, template_info: dict) -> dict: + """template_info에서 색상 정볎 추출""" + colors = {"background": [], "border": [], "text": []} + + bf = template_info.get("border_fills", {}) + for fill_id, fill_data in bf.items(): + # ★ background í‚€ 사용 (bg → background) + bg = fill_data.get("background", fill_data.get("bg", "")) + if bg and bg.lower() not in ("", "none", "transparent") \ + and bg not in colors["background"]: + colors["background"].append(bg) + + # ★ css dict에서 border 색상 추출 + css_dict = fill_data.get("css", {}) + for prop, val in css_dict.items(): + if "border" in prop and val and val != "none": + # "0.12mm solid #999999" → "#999999" + parts = val.split() + if len(parts) >= 3: + c = parts[-1] + if c.startswith("#") and c not in colors["border"]: + colors["border"].append(c) + + # fallback: 직접 side í‚€ (top/bottom/left/right) + for side_key in ("top", "bottom", "left", "right"): + side = fill_data.get(side_key, {}) + if isinstance(side, dict): + c = side.get("color", "") + if c and c not in colors["border"]: + colors["border"].append(c) + + return colors + + def _summarize_features(self, template_info: dict, + semantic_map: dict = None) -> list: + """template_info에서 특징 요앜""" + features = [] + + header = template_info.get("header", {}) + footer = template_info.get("footer", {}) + tables = template_info.get("tables", []) + + # 폰튾 (fonts 구조: {"HANGUL": [{"face": "맑은 고딕"}], ...}) + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + features.append(f"폰튾: {hangul[0].get('face', '?')}") + + # 뚞늿말 (header.table.colCnt) + if header.get("exists"): + col_cnt = header.get("table", {}).get("colCnt", "?") + features.append(f"뚞늿말: {col_cnt}ì—Ž") + + # ꌬ늿말 (footer.table.colCnt) + if footer.get("exists"): + col_cnt = footer.get("table", {}).get("colCnt", "?") + features.append(f"ꌬ늿말: {col_cnt}ì—Ž") + + # 표 — semantic_map읎 있윌멎 데읎터 표만 + if semantic_map and semantic_map.get("body_tables"): + for idx in semantic_map["body_tables"]: + t = next((tb for tb in tables if tb["index"] == idx), None) + if t: + features.append( + f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}" + ) + elif tables: + t = tables[0] + features.append(f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}") + + return features \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/__init__.py b/03. Code/geulbeot_10th/handlers/template/tools/__init__.py new file mode 100644 index 0000000..14b8b13 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +HWPX 템플늿 추출 도구 몚음 + +각 몚듈은 HWPX XML에서 특정 항목을 윔드 Ʞ반윌로 추출한닀. +- 추출 싀팚 시 None 반환 (디폎튞값 절대 생성 안 핹) +- 몚든 닚위 변환은 hwpx_utils 사용 +- hwpx_domain_guide.md Ʞ쀀 쀀수 + +몚듈 목록: + page_setup : §7 용지/여백 (pagePr + margin) + font : §3 Ꞁꌎ (fontface → font) + char_style : §4 Ꞁ자 몚양 (charPr) + para_style : §5 묞닚 몚양 (paraPr) + border_fill : §2 테두늬/배겜 (borderFill) + table : §6 표 (tbl, tc) + header_footer: §8 뚞늬말/ꌬ늬말 (headerFooter) + section : §9 구역 정의 (secPr) + style_def : 슀타음 정의 (styles) + numbering : 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image : 읎믞지/귞늬Ʞ 객첎 + content_order: 볞묞 윘텐잠 순서 (section*.xml) +""" + +from . import page_setup +from . import font +from . import char_style +from . import para_style +from . import border_fill +from . import table +from . import header_footer +from . import section +from . import style_def +from . import numbering +from . import image +from . import content_order + +__all__ = [ + "page_setup", + "font", + "char_style", + "para_style", + "border_fill", + "table", + "header_footer", + "section", + "style_def", + "numbering", + "image", + "content_order" +] \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/border_fill.py b/03. Code/geulbeot_10th/handlers/template/tools/border_fill.py new file mode 100644 index 0000000..1f72936 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/border_fill.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +""" +§2 테두늬/배겜(BorderFill) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import BORDER_TYPE_TO_CSS, hwpx_border_to_css + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§2 borderFill 전첎 추출 → id별 dict. + + Returns: + { + 3: { + "id": 3, + "left": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "right": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "top": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "bottom": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "diagonal": {"type": "SOLID", "width": "0.1 mm", "color": "#000000"}, + "background": "#EDEDED", # fillBrush faceColor + "css": { # 펞의: 믞늬 변환된 CSS + "border-left": "0.12mm solid #000000", + ... + "background-color": "#EDEDED", + } + }, + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = {} + for attrs_str, inner in blocks: + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if not id_m: + continue + bf_id = int(id_m.group(1)) + + item = {"id": bf_id} + + # 4방향 + diagonal + for side, tag in [ + ("left", "leftBorder"), + ("right", "rightBorder"), + ("top", "topBorder"), + ("bottom", "bottomBorder"), + ("diagonal", "diagonal"), + ]: + # 태귞 전첎륌 뚌저 ì°Ÿê³ , 속성을 개별 추출 (순서 묎ꎀ) + tag_m = re.search(rf'', inner) + if tag_m: + tag_attrs = tag_m.group(1) + t = re.search(r'\btype="([^"]+)"', tag_attrs) + w = re.search(r'\bwidth="([^"]+)"', tag_attrs) + c = re.search(r'\bcolor="([^"]+)"', tag_attrs) + item[side] = { + "type": t.group(1) if t else "NONE", + "width": w.group(1).replace(" ", "") if w else "0.12mm", + "color": c.group(1) if c else "#000000", + } + + # 배겜 (fillBrush > winBrush faceColor) + bg_m = re.search( + r']*\bfaceColor="([^"]+)"', inner + ) + if bg_m: + face = bg_m.group(1) + if face and face.lower() != "none": + item["background"] = face + + # CSS 펞의 변환 + css = {} + for side in ["left", "right", "top", "bottom"]: + border_data = item.get(side) + if border_data: + css[f"border-{side}"] = hwpx_border_to_css(border_data) + else: + css[f"border-{side}"] = "none" + # border_data가 없윌멎 CSS에도 넣지 않음 + + if "background" in item: + css["background-color"] = item["background"] + + if css: + item["css"] = css + + result[bf_id] = item + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/char_style.py b/03. Code/geulbeot_10th/handlers/template/tools/char_style.py new file mode 100644 index 0000000..52b9c9f --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/char_style.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +§4 Ꞁ자 몚양(CharShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import charsize_to_pt + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§4 charPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "bold": False, + "italic": False, + "underline": "NONE", + "strikeout": "NONE", + "fontRef": {"hangul": 7, "latin": 6, ...}, + "ratio": {"hangul": 100, "latin": 100, ...}, + "spacing": {"hangul": 0, "latin": 0, ...}, + "borderFillIDRef": 2, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + # charPr 랔록 추출 (self-closing읎 아닌 랔록) + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # 속성 파싱 + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + height_m = re.search(r'\bheight="(\d+)"', attrs_str) + if height_m: + item["height_pt"] = charsize_to_pt(int(height_m.group(1))) + + color_m = re.search(r'\btextColor="([^"]+)"', attrs_str) + if color_m: + item["textColor"] = color_m.group(1) + + shade_m = re.search(r'\bshadeColor="([^"]+)"', attrs_str) + if shade_m and shade_m.group(1) != "none": + item["shadeColor"] = shade_m.group(1) + + bf_m = re.search(r'\bborderFillIDRef="(\d+)"', attrs_str) + if bf_m: + item["borderFillIDRef"] = int(bf_m.group(1)) + + # bold / italic (태귞 졎재 여부로 판당) + item["bold"] = bool(re.search(r'', inner)) + item["italic"] = bool(re.search(r'', inner)) + + # fontRef + fr = re.search(r'', inner) + if fr: + item["fontRef"] = _parse_lang_attrs(fr.group(1)) + + # ratio + ra = re.search(r'', inner) + if ra: + item["ratio"] = _parse_lang_attrs(ra.group(1)) + + # spacing + sp = re.search(r'', inner) + if sp: + item["spacing"] = _parse_lang_attrs(sp.group(1)) + + # underline + ul = re.search(r']*\btype="([^"]+)"', inner) + if ul: + item["underline"] = ul.group(1) + + # strikeout + so = re.search(r']*\bshape="([^"]+)"', inner) + if so: + item["strikeout"] = so.group(1) + + result.append(item) + + return result if result else None + + +def _parse_lang_attrs(attrs_str: str) -> dict: + """hangul="7" latin="6" ... → {"hangul": 7, "latin": 6, ...}""" + pairs = re.findall(r'(\w+)="(-?\d+)"', attrs_str) + return {k: int(v) for k, v in pairs} + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/content_order.py b/03. Code/geulbeot_10th/handlers/template/tools/content_order.py new file mode 100644 index 0000000..2252b02 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/content_order.py @@ -0,0 +1,550 @@ +# -*- coding: utf-8 -*- +""" +content_order.py — HWPX section*.xml 볞묞 윘텐잠 순서 추출 + +Ʞ졎 12개 tool읎 header.xml의 "정의(definition)"륌 추출하는 반멎, +읎 tool은 section0.xml의 "볞묞(content)" 순서륌 추출한닀. + +추출 결곌는 template_manager._build_body_html()읎 +원볞 순서 귞대로 HTML을 조늜하는 데 사용된닀. + +윘텐잠 유형: + - paragraph : 음반 텍슀튞 묞닚 + - table : 표 () + - image : 읎믞지 () + - empty : 빈 묞닚 (쀄바꿈 역할) + +ì°žì¡°: hwpx_domain_guide.md §6(표), §7(볞묞 구조) +""" + +import re +import logging + +logger = logging.getLogger(__name__) + +# ================================================================ +# 넀임슀페읎슀 +# ================================================================ +# HWPX는 여러 넀임슀페읎슀륌 사용한닀. +# section*.xml: hp: (볞묞), ha: (속성) +# header.xml: hh: (헀더 정의) +# 싀제 파음에서 넀임슀페읎슀 URI가 닀륌 수 있윌므로 로컬명 êž°ë°˜ 탐색도 병행한닀. + +DEFAULT_NS = { + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes', + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', +} + + +# ================================================================ +# 공개 API +# ================================================================ + +def extract(raw_xml, parsed, ns=None): + """section*.xml에서 볞묞 윘텐잠 순서륌 추출한닀. + + Args: + raw_xml (dict): 원볞 XML 묞자엎 딕셔너늬. + raw_xml.get("section0") 등윌로 section XML에 ì ‘ê·Œ. + parsed (dict): processor.py가 HWPX륌 파싱한 전첎 결곌 dict. + parsed.get("section_xml") 등윌로 parsed Element에 ì ‘ê·Œ. + ns (dict, optional): 넀임슀페읎슀 맀핑. None읎멎 자동 감지. + + Returns: + list[dict]: 윘텐잠 순서 늬슀튞. 각 항목은 닀음 킀륌 포핚: + - type: "paragraph" | "table" | "image" | "empty" + - index: 전첎 순서 낮 읞덱슀 (0부터) + - paraPrIDRef: 묞닚몚양 ì°žì¡° ID (str or None) + - styleIDRef: 슀타음 ì°žì¡° ID (str or None) + + type별 추가 í‚€ (아래 ì°žì¡°) + 추출 싀팚 시 None 반환 (analyzer가 결곌에서 제왞핚). + """ + # ── section XML ì°Ÿêž° ── + # raw_xml dict에서 section 원볞 묞자엎 추출 + section_raw = None + if isinstance(raw_xml, dict): + # í‚€ 읎늄은 프로젝튞마닀 닀륌 수 있음: section0, section_xml 등 + for key in ['section0', 'section_xml', 'section0.xml']: + if key in raw_xml: + section_raw = raw_xml[key] + break + # 못 찟윌멎 "section"윌로 시작하는 첫 번짞 í‚€ + if section_raw is None: + for key, val in raw_xml.items(): + if key.startswith('section') and isinstance(val, str): + section_raw = val + break + elif isinstance(raw_xml, str): + section_raw = raw_xml + + # parsed dict에서 section Element 또는 묞자엎 추출 + section_parsed = None + if isinstance(parsed, dict): + for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']: + val = parsed.get(key) + if val is None: + continue + if isinstance(val, str): + # 묞자엎읎멎 section_raw로 활용 (table.py와 동음) + if section_raw is None: + section_raw = val + elif not isinstance(val, dict): + # Element 객첎로 추정 + section_parsed = val + break + # fallback: raw_xml 묞자엎을 직접 파싱 + if section_parsed is None and section_raw: + import xml.etree.ElementTree as ET + try: + section_parsed = ET.fromstring(section_raw) + except ET.ParseError: + logger.warning("section XML 파싱 싀팚") + return None + else: + # parsed 자첎가 Element음 수 있음 (직접 혞출 시) + section_parsed = parsed + + if section_parsed is None: + logger.warning("section XML을 찟을 수 없음 — content_order 추출 생략") + return None + + if ns is None: + ns = _detect_namespaces(section_raw or '', section_parsed) + + # 엘늬뚌튞 수집 — secPr 낎부는 제왞 + paragraphs = _collect_body_paragraphs(section_parsed, ns) + + content_order = [] + table_idx = 0 + image_idx = 0 + + for p_elem in paragraphs: + para_pr_id = _get_attr(p_elem, 'paraPrIDRef') + style_id = _get_attr(p_elem, 'styleIDRef') + + base = { + 'index': len(content_order), + 'paraPrIDRef': para_pr_id, + 'styleIDRef': style_id, + } + + # ── (1) 표 확읞 ── + tbl = _find_element(p_elem, 'tbl', ns) + if tbl is not None: + tbl_info = _extract_table_info(tbl, ns) + content_order.append({ + **base, + 'type': 'table', + 'table_idx': table_idx, + **tbl_info, + }) + table_idx += 1 + continue + + # ── (2) 읎믞지 확읞 ── + pic = _find_element(p_elem, 'pic', ns) + if pic is not None: + img_info = _extract_image_info(pic, p_elem, ns) + + # ★ 읞띌읞 아읎윘읎멎 텍슀튞와 합쳐서 paragraph로 처늬 + if img_info.get('is_inline_icon') and img_info.get('text'): + content_order.append({ + **base, + 'type': 'paragraph', + 'text': img_info['text'].strip(), + 'charPrIDRef': None, + 'has_icon': True, + }) + continue + + # 음반 읎믞지 + content_order.append({ + **base, + 'type': 'image', + 'image_idx': image_idx, + **img_info, + }) + image_idx += 1 + continue + + # ── (3) 텍슀튞 묞닚 / 빈 묞닚 ── + text = _collect_text(p_elem, ns) + runs_info = _extract_runs_info(p_elem, ns) + + if not text.strip(): + content_order.append({ + **base, + 'type': 'empty', + }) + else: + content_order.append({ + **base, + 'type': 'paragraph', + 'text': text, + 'charPrIDRef': runs_info.get('first_charPrIDRef'), + 'runs': runs_info.get('runs', []), + }) + + logger.info( + "content_order 추출 완료: %d items " + "(paragraphs=%d, tables=%d, images=%d, empty=%d)", + len(content_order), + sum(1 for c in content_order if c['type'] == 'paragraph'), + table_idx, + image_idx, + sum(1 for c in content_order if c['type'] == 'empty'), + ) + + return content_order + + +# ================================================================ +# 볞묞 수집 — secPr 낎부 제왞 +# ================================================================ + +def _collect_body_paragraphs(root, ns): + """ 직계 만 수집한닀. + + secPr, headerFooter 낎부의 는 볞묞읎 아니므로 제왞. + subList 낎부(셀 안 묞닚)도 제왞 — 표는 통짞로 하나의 항목. + """ + paragraphs = [] + + # 방법 1: sec 직계 자식 쀑 p 태귞만 + sec = _find_element(root, 'sec', ns) + if sec is None: + # 룚튞 자첎가 sec음 수 있음 + sec = root + + for child in sec: + tag = _local_tag(child) + if tag == 'p': + paragraphs.append(child) + + # 직계 자식에서 못 찟았윌멎 fallback: 전첎 탐색 (but secPr/subList 제왞) + if not paragraphs: + paragraphs = _collect_paragraphs_fallback(root, ns) + + return paragraphs + + +def _collect_paragraphs_fallback(root, ns): + """fallback: 전첎에서 륌 찟되, secPr/headerFooter/subList 낎부는 제왞""" + skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'} + result = [] + + def _walk(elem, skip=False): + if skip: + return + tag = _local_tag(elem) + if tag in skip_tags: + return + if tag == 'p': + # 부몚가 sec읎거나 룚튞 직계읞 겜우만 + result.append(elem) + return # p 낎부의 하위 p는 수집하지 않음 + for child in elem: + _walk(child) + + _walk(root) + return result + + +# ================================================================ +# 표 정볎 추출 +# ================================================================ + +def _extract_table_info(tbl, ns): + """ 에서 Ʞ볞 메타 정볎 추출""" + info = { + 'rowCnt': _get_attr(tbl, 'rowCnt'), + 'colCnt': _get_attr(tbl, 'colCnt'), + 'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'), + } + + # ì—Ž 너비 + col_sz = _find_element(tbl, 'colSz', ns) + if col_sz is not None: + width_list_elem = _find_element(col_sz, 'widthList', ns) + if width_list_elem is not None and width_list_elem.text: + info['colWidths'] = width_list_elem.text.strip().split() + + return info + + +# ================================================================ +# 읎믞지 정볎 추출 +# ================================================================ + +def _extract_image_info(pic, p_elem, ns): + """ 에서 읎믞지 ì°žì¡° 정볎 추출""" + info = { + 'binaryItemIDRef': None, + 'text': '', + 'is_inline_icon': False, # ★ 추가 + } + + # img 태귞에서 binaryItemIDRef + img = _find_element(pic, 'img', ns) + if img is not None: + info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef') + + # ★ pos 태귞에서 treatAsChar 확읞 — 읞띌읞 아읎윘 판별 + pos = _find_element(pic, 'pos', ns) + if pos is not None: + treat_as_char = _get_attr(pos, 'treatAsChar') + if treat_as_char == '1': + info['is_inline_icon'] = True + + # imgRect에서 크Ʞ 정볎 + img_rect = _find_element(pic, 'imgRect', ns) + if img_rect is not None: + info['imgRect'] = { + 'x': _get_attr(img_rect, 'x'), + 'y': _get_attr(img_rect, 'y'), + 'w': _get_attr(img_rect, 'w'), + 'h': _get_attr(img_rect, 'h'), + } + + # 같은 묞닚 낮 텍슀튞 (pic 바깥의 runë“€) + info['text'] = _collect_text_outside(p_elem, pic, ns) + + return info + + +# ================================================================ +# 텍슀튞 수집 +# ================================================================ + +def _collect_text(p_elem, ns): + """ 낮 몚든 텍슀튞륌 순서대로 합칚 + + 죌의: t.tail은 XML 듀여쓰Ʞ 공백읎므로 수집하지 않는닀. + HWPX에서 싀제 텍슀튞는 항상 ... 안에 있닀. + """ + parts = [] + for t in _find_all_elements(p_elem, 't', ns): + if t.text: + parts.append(t.text) + return ''.join(parts) + + +def _collect_text_outside(p_elem, exclude_elem, ns): + """p_elem 낎에서 exclude_elem(예: pic) 바깥의 텍슀튞만 수집""" + parts = [] + + def _walk(elem): + if elem is exclude_elem: + return + tag = _local_tag(elem) + if tag == 't' and elem.text: + parts.append(elem.text) + for child in elem: + _walk(child) + + _walk(p_elem) + return ''.join(parts) + + +# ================================================================ +# Run 정볎 추출 +# ================================================================ + +def _extract_runs_info(p_elem, ns): + """ 낮 듀의 charPrIDRef와 텍슀튞 추출 + + Returns: + { + 'first_charPrIDRef': str or None, + 'runs': [ + {'charPrIDRef': '8', 'text': '1. SamanPro...'}, + {'charPrIDRef': '24', 'text': '포장섀계...'}, + ] + } + """ + runs = [] + first_char_pr = None + + for run_elem in _find_direct_runs(p_elem, ns): + char_pr = _get_attr(run_elem, 'charPrIDRef') + if first_char_pr is None and char_pr is not None: + first_char_pr = char_pr + + text_parts = [] + for t in _find_all_elements(run_elem, 't', ns): + if t.text: + text_parts.append(t.text) + + if text_parts: + runs.append({ + 'charPrIDRef': char_pr, + 'text': ''.join(text_parts), + }) + + return { + 'first_charPrIDRef': first_char_pr, + 'runs': runs, + } + + +def _find_direct_runs(p_elem, ns): + """ 직계 만 찟음 (subList 낎부 제왞)""" + results = [] + for child in p_elem: + tag = _local_tag(child) + if tag == 'run': + results.append(child) + return results + + +# ================================================================ +# 넀임슀페읎슀 감지 +# ================================================================ + +def _detect_namespaces(raw_xml, parsed): + """XML에서 싀제 사용된 넀임슀페읎슀 URI륌 감지한닀. + + HWPX 버전에 따띌 넀임슀페읎슀 URI가 닀륌 수 있닀: + - 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph + - 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (음부) + """ + ns = dict(DEFAULT_NS) + + if raw_xml: + # xmlns:hp="..." 팚턎윌로 싀제 URI 추출 + for prefix in ['hp', 'ha', 'hh', 'hc']: + pattern = rf'xmlns:{prefix}="([^"]+)"' + match = re.search(pattern, raw_xml) + if match: + ns[prefix] = match.group(1) + + return ns + + +# ================================================================ +# XML 유틞늬티 — 넀임슀페읎슀 불가지론적 탐색 +# ================================================================ + +def _local_tag(elem): + """'{namespace}localname' → 'localname'""" + tag = elem.tag + if '}' in tag: + return tag.split('}', 1)[1] + return tag + + +def _get_attr(elem, attr_name): + """속성값 가젞였Ʞ. 넀임슀페읎슀 유묎 몚두 시도.""" + # 직접 속성명 + val = elem.get(attr_name) + if val is not None: + return val + + # 넀임슀페읎슀 접두사가 붙은 속성 시도 + for full_attr in elem.attrib: + if full_attr.endswith(attr_name): + return elem.attrib[full_attr] + + return None + + +def _find_element(parent, local_name, ns): + """자식 쀑 로컬명읎 음치하는 첫 번짞 엘늬뚌튞륌 찟는닀. + + 넀임슀페읎슀 prefix 시도 후, 싀팚하멎 로컬명 직접 비교. + """ + # 1ì°š: 넀임슀페읎슀 prefix로 탐색 + for prefix in ['hp', 'hh', 'hc', 'ha']: + uri = ns.get(prefix, '') + found = parent.find(f'{{{uri}}}{local_name}') + if found is not None: + return found + + # 2ì°š: 직계 자식 로컬명 비교 + for child in parent: + if _local_tag(child) == local_name: + return child + + # 3ì°š: 재귀 탐색 (1닚계만) + for child in parent: + for grandchild in child: + if _local_tag(grandchild) == local_name: + return grandchild + + return None + + +def _find_all_elements(parent, local_name, ns): + """하위 전첎에서 로컬명읎 음치하는 몚든 엘늬뚌튞륌 찟는닀.""" + results = [] + + def _walk(elem): + if _local_tag(elem) == local_name: + results.append(elem) + for child in elem: + _walk(child) + + _walk(parent) + return results + + +# ================================================================ +# 펞의 핚수 +# ================================================================ + +def summarize(content_order): + """content_order 늬슀튞륌 사람읎 읜Ʞ 쉬욎 요앜윌로 변환""" + lines = [] + for item in content_order: + idx = item['index'] + t = item['type'] + + if t == 'paragraph': + text_preview = item['text'][:50] + if len(item['text']) > 50: + text_preview += '...' + lines.append( + f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} " + f"charPr={item.get('charPrIDRef', '-'):<4s} " + f"\"{text_preview}\"" + ) + elif t == 'table': + lines.append( + f"[{idx:3d}] T table_idx={item['table_idx']} " + f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})" + ) + elif t == 'image': + ref = item.get('binaryItemIDRef', '?') + caption = item.get('text', '')[:30] + lines.append( + f"[{idx:3d}] I image_idx={item['image_idx']} " + f"ref={ref} \"{caption}\"" + ) + elif t == 'empty': + lines.append(f"[{idx:3d}] _ (empty)") + + return '\n'.join(lines) + + +def get_stats(content_order): + """content_order 통계 반환""" + type_map = { + 'paragraph': 'paragraphs', + 'table': 'tables', + 'image': 'images', + 'empty': 'empty', + } + stats = { + 'total': len(content_order), + 'paragraphs': 0, + 'tables': 0, + 'images': 0, + 'empty': 0, + } + for item in content_order: + key = type_map.get(item['type']) + if key: + stats[key] += 1 + return stats \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/font.py b/03. Code/geulbeot_10th/handlers/template/tools/font.py new file mode 100644 index 0000000..a4ea867 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/font.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +§3 Ꞁꌎ(FaceName) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§3 fontface에서 얞얎별 Ꞁꌎ 정의 추출. + + Returns: + { + "HANGUL": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "LATIN": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "HANJA": [...], + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # fontface 랔록을 lang별로 추출 + fontface_blocks = re.findall( + r']*\blang="([^"]+)"[^>]*>(.*?)', + header_xml, re.DOTALL + ) + + if not fontface_blocks: + return None + + for lang, block_content in fontface_blocks: + fonts = [] + font_matches = re.finditer( + r']*' + r'\bid="(\d+)"[^>]*' + r'\bface="([^"]+)"[^>]*' + r'\btype="([^"]+)"', + block_content + ) + for fm in font_matches: + fonts.append({ + "id": int(fm.group(1)), + "face": fm.group(2), + "type": fm.group(3), + }) + + if fonts: + result[lang] = fonts + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """header.xml 묞자엎을 가젞옚닀.""" + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/header_footer.py b/03. Code/geulbeot_10th/handlers/template/tools/header_footer.py new file mode 100644 index 0000000..7dc9b30 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/header_footer.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +§8 뚞늬말/ꌬ늬말(HeaderFooter) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + 뚞늬말/ꌬ늬말 안에 표가 있는 겜우: + - 표의 셀에 닀쀑행 텍슀튞가 포핚될 수 있음 + - 각 셀의 colSpan, rowSpan, width, borderFillIDRef 등 추출 필요 + +secPr 낮 속성: + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract_header(raw_xml: dict, parsed: dict = None) -> dict | None: + """뚞늬말 구조 추출. + + Returns: + { + "exists": True, + "type": "table" | "text", + "hidden": False, + "table": { ... } | None, # 표가 있는 겜우 + "texts": ["부서명", ...], + } + """ + return _extract_hf(raw_xml, parsed, "header") + + +def extract_footer(raw_xml: dict, parsed: dict = None) -> dict | None: + """ꌬ늬말 구조 추출.""" + return _extract_hf(raw_xml, parsed, "footer") + + +def _extract_hf(raw_xml: dict, parsed: dict, hf_type: str) -> dict | None: + """header 또는 footer 추출 공통 로직""" + # 1) parsed에서 직접 제공된 header/footer XML + hf_xml = None + if parsed: + key = f"page_{hf_type}_xml" + hf_xml = parsed.get(key, "") + + # 2) section XML에서 headerFooter 랔록 탐색 + section_xml = _get_section_xml(raw_xml, parsed) + + if not hf_xml and section_xml: + # headerFooter 태귞에서 header/footer 구분 + hf_blocks = re.findall( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for attrs, inner in hf_blocks: + # type 속성윌로 구분 (HEADER / FOOTER) + type_m = re.search(r'\btype="([^"]+)"', attrs) + if type_m: + if type_m.group(1).upper() == hf_type.upper(): + hf_xml = inner + break + + if not hf_xml or not hf_xml.strip(): + return None # 핎당 뚞늬말/ꌬ늬말 없음 + + result = {"exists": True} + + # hidden 여부 + if section_xml: + hide_key = f"hideFirst{'Header' if hf_type == 'header' else 'Footer'}" + hide_m = re.search(rf'\b{hide_key}="(\d+)"', section_xml) + if hide_m: + result["hidden"] = bool(int(hide_m.group(1))) + + # 텍슀튞 추출 + texts = re.findall(r'([^<]*)', hf_xml) + clean_texts = [t.strip() for t in texts if t.strip()] + if clean_texts: + result["texts"] = clean_texts + + # 표 졎재 여부 + tbl_match = re.search( + r']*)>(.*?)', + hf_xml, re.DOTALL + ) + if tbl_match: + result["type"] = "table" + result["table"] = _parse_hf_table(tbl_match.group(1), tbl_match.group(2)) + else: + result["type"] = "text" + + return result + + +def _parse_hf_table(tbl_attrs: str, tbl_inner: str) -> dict: + """뚞늬말/ꌬ늬말 낮 표 파싱""" + table = {} + + # rowCnt, colCnt + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + table[attr] = int(m.group(1)) + + # ì—Ž 너비 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + widths = [int(w) for w in wl.group(1).strip().split()] + table["colWidths_hu"] = widths + total = sum(widths) or 1 + table["colWidths_pct"] = [round(w / total * 100) for w in widths] + except ValueError: + pass + + # 행/셀 + rows = [] + tr_blocks = re.findall(r']*>(.*?)', tbl_inner, re.DOTALL) + for tr in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr, re.DOTALL + ) + for tc in tc_blocks: + cell = _parse_hf_cell(tc.group(1), tc.group(2)) + cells.append(cell) + rows.append(cells) + + if rows: + table["rows"] = rows + + return table + + +def _parse_hf_cell(tc_attrs: str, tc_inner: str) -> dict: + """뚞늬말/ꌬ늬말 셀 파싱""" + cell = {} + + # borderFillIDRef + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + + # 셀 텍슀튞 (닀쀑행) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + + if lines: + cell["text"] = " ".join(lines) + cell["lines"] = lines + + return cell + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/image.py b/03. Code/geulbeot_10th/handlers/template/tools/image.py new file mode 100644 index 0000000..d989ccb --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/image.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +읎믞지/귞늬Ʞ 객첎(ShapeObject) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + + + + 또는 귞늬Ʞ 객첎: + + + ... + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """읎믞지/귞늬Ʞ 객첎 추출. + + Returns: + [ + { + "type": "image", + "binaryItemRef": "image1.JPG", + "width_hu": 28346, "height_hu": 14173, + "width_mm": 100.0, "height_mm": 50.0, + "offset": {"x": 0, "y": 0}, + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = [] + + # 랔록 + pic_blocks = re.finditer( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for pm in pic_blocks: + pic_inner = pm.group(2) + item = {"type": "image"} + + # binaryItemRef + img = re.search(r']*\bbinaryItemIDRef="([^"]+)"', pic_inner) + if img: + item["binaryItemRef"] = img.group(1) + + # curSz (현재 크Ʞ) + csz = re.search( + r']*\bwidth="(\d+)"[^>]*\bheight="(\d+)"', + pic_inner + ) + if csz: + w, h = int(csz.group(1)), int(csz.group(2)) + item["width_hu"] = w + item["height_hu"] = h + item["width_mm"] = round(hwpunit_to_mm(w), 1) + item["height_mm"] = round(hwpunit_to_mm(h), 1) + + # offset + off = re.search( + r']*\bx="(-?\d+)"[^>]*\by="(-?\d+)"', + pic_inner + ) + if off: + item["offset"] = {"x": int(off.group(1)), "y": int(off.group(2))} + + result.append(item) + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/numbering.py b/03. Code/geulbeot_10th/handlers/template/tools/numbering.py new file mode 100644 index 0000000..b6e048d --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/numbering.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +번혞맀ꞰꞰ(Numbering) / Ꞁ뚞늬표(Bullet) 추출 + +HWPX 싀제 태귞 (header.xml): + + ^1. + ^2. + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """번혞맀ꞰꞰ + Ꞁ뚞늬표 정의 추출. + + Returns: + { + "numberings": [ + { + "id": 1, "start": 0, + "levels": [ + {"level": 1, "numFormat": "DIGIT", "pattern": "^1.", + "align": "LEFT"}, + {"level": 2, "numFormat": "HANGUL_SYLLABLE", "pattern": "^2."}, + ... + ] + } + ], + "bullets": [ + {"id": 1, "char": "-", "useImage": False} + ] + } + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # ── 번혞맀ꞰꞰ ── + numbering_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if numbering_blocks: + nums = [] + for attrs, inner in numbering_blocks: + num = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + num["id"] = int(id_m.group(1)) + start_m = re.search(r'\bstart="(\d+)"', attrs) + if start_m: + num["start"] = int(start_m.group(1)) + + # paraHead 레벚듀 + levels = [] + heads = re.finditer( + r']*)>([^<]*)', + inner + ) + for h in heads: + h_attrs = h.group(1) + h_pattern = h.group(2).strip() + level = {} + + lv = re.search(r'\blevel="(\d+)"', h_attrs) + if lv: + level["level"] = int(lv.group(1)) + + fmt = re.search(r'\bnumFormat="([^"]+)"', h_attrs) + if fmt: + level["numFormat"] = fmt.group(1) + + al = re.search(r'\balign="([^"]+)"', h_attrs) + if al: + level["align"] = al.group(1) + + if h_pattern: + level["pattern"] = h_pattern + + if level: + levels.append(level) + + if levels: + num["levels"] = levels + nums.append(num) + + if nums: + result["numberings"] = nums + + # ── Ꞁ뚞늬표 ── + bullet_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if bullet_blocks: + bullets = [] + for attrs, inner in bullet_blocks: + bullet = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + bullet["id"] = int(id_m.group(1)) + char_m = re.search(r'\bchar="([^"]*)"', attrs) + if char_m: + bullet["char"] = char_m.group(1) + img_m = re.search(r'\buseImage="(\d+)"', attrs) + if img_m: + bullet["useImage"] = bool(int(img_m.group(1))) + bullets.append(bullet) + + if bullets: + result["bullets"] = bullets + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/page_setup.py b/03. Code/geulbeot_10th/handlers/template/tools/page_setup.py new file mode 100644 index 0000000..b31994a --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/page_setup.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +""" +§7 용지 섀정 추출 (pagePr + margin) + +HWPX 싀제 태귞: + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm, mm_format, detect_paper_size + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§7 pagePr + margin에서 용지/여백 정볎 추출. + + Returns: + { + "paper": {"name": "A4", "width_mm": 210.0, "height_mm": 297.0, + "landscape": True/False}, + "margins": {"top": "10.0mm", "bottom": "10.0mm", + "left": "20.0mm", "right": "20.0mm", + "header": "15.0mm", "footer": "15.0mm", + "gutter": "0.0mm"} + } + 또는 추출 싀팚 시 None + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = {} + + # ── 용지 크Ʞ ───────────────────────────────── + page_match = re.search( + r']*' + r'\bwidth="(\d+)"[^>]*' + r'\bheight="(\d+)"', + section_xml + ) + if not page_match: + # 속성 순서가 닀륌 수 있음 + page_match = re.search( + r']*' + r'\bheight="(\d+)"[^>]*' + r'\bwidth="(\d+)"', + section_xml + ) + if page_match: + h_hu, w_hu = int(page_match.group(1)), int(page_match.group(2)) + else: + return None + else: + w_hu, h_hu = int(page_match.group(1)), int(page_match.group(2)) + + landscape_match = re.search( + r']*\blandscape="([^"]+)"', section_xml + ) + is_landscape = False + if landscape_match: + is_landscape = landscape_match.group(1) == "WIDELY" + + paper_name = detect_paper_size(w_hu, h_hu) + + result["paper"] = { + "name": paper_name, + "width_mm": round(hwpunit_to_mm(w_hu), 1), + "height_mm": round(hwpunit_to_mm(h_hu), 1), + "landscape": is_landscape, + } + + # ── 여백 ────────────────────────────────────── + margin_match = re.search(r'', section_xml) + if not margin_match: + return result # 용지 크Ʞ는 있윌나 여백은 없을 수 있음 + + attrs_str = margin_match.group(1) + margins = {} + for key in ["top", "bottom", "left", "right", "header", "footer", "gutter"]: + m = re.search(rf'\b{key}="(\d+)"', attrs_str) + if m: + margins[key] = mm_format(int(m.group(1))) + + if margins: + result["margins"] = margins + + return result + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """section XML 묞자엎을 가젞옚닀.""" + # parsed에서 직접 제공 + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + + # raw_xml dict에서 section 파음 ì°Ÿêž° + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + + # raw_xml읎 묞자엎읎멎 귞대로 + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/para_style.py b/03. Code/geulbeot_10th/handlers/template/tools/para_style.py new file mode 100644 index 0000000..2b6dd3a --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/para_style.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +""" +§5 묞닚 몚양(ParaShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§5 paraPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": {"type": "NONE", "idRef": 0, "level": 0}, + "breakSetting": { + "widowOrphan": False, "keepWithNext": False, + "keepLines": False, "pageBreakBefore": False, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, "left_hu": 0, "right_hu": 0, + "before_hu": 0, "after_hu": 0, + }, + "lineSpacing": {"type": "PERCENT", "value": 130}, + "borderFillIDRef": 2, + "tabPrIDRef": 1, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # id + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + # tabPrIDRef + tab_m = re.search(r'\btabPrIDRef="(\d+)"', attrs_str) + if tab_m: + item["tabPrIDRef"] = int(tab_m.group(1)) + + # align + al = re.search(r']*\bhorizontal="([^"]+)"', inner) + if al: + item["align"] = al.group(1) + + val = re.search(r']*\bvertical="([^"]+)"', inner) + if val: + item["verticalAlign"] = val.group(1) + + # heading + hd = re.search( + r']*\btype="([^"]+)"[^>]*' + r'\bidRef="(\d+)"[^>]*\blevel="(\d+)"', inner + ) + if hd: + item["heading"] = { + "type": hd.group(1), + "idRef": int(hd.group(2)), + "level": int(hd.group(3)), + } + + # breakSetting + bs = re.search(r'', inner) + if bs: + bstr = bs.group(1) + item["breakSetting"] = { + "widowOrphan": _bool_attr(bstr, "widowOrphan"), + "keepWithNext": _bool_attr(bstr, "keepWithNext"), + "keepLines": _bool_attr(bstr, "keepLines"), + "pageBreakBefore": _bool_attr(bstr, "pageBreakBefore"), + "lineWrap": _str_attr(bstr, "lineWrap"), + "breakLatinWord": _str_attr(bstr, "breakLatinWord"), + "breakNonLatinWord": _str_attr(bstr, "breakNonLatinWord"), + } + + # margin (hp:case 랔록 낮 첫 번짞 사용 — HwpUnitChar case 우선) + case_block = re.search( + r']*required-namespace="[^"]*HwpUnitChar[^"]*"[^>]*>' + r'(.*?)', + inner, re.DOTALL + ) + margin_src = case_block.group(1) if case_block else inner + + margin = {} + for tag, key in [ + ("intent", "indent_hu"), + ("left", "left_hu"), + ("right", "right_hu"), + ("prev", "before_hu"), + ("next", "after_hu"), + ]: + m = re.search( + rf']*\bvalue="(-?\d+)"', margin_src + ) + if m: + margin[key] = int(m.group(1)) + + if margin: + item["margin"] = margin + + # lineSpacing + ls = re.search( + r']*\btype="([^"]+)"[^>]*\bvalue="(\d+)"', + margin_src + ) + if ls: + item["lineSpacing"] = { + "type": ls.group(1), + "value": int(ls.group(2)), + } + + # borderFillIDRef + bf = re.search(r']*\bborderFillIDRef="(\d+)"', inner) + if bf: + item["borderFillIDRef"] = int(bf.group(1)) + + result.append(item) + + return result if result else None + + +def _bool_attr(s: str, name: str) -> bool | None: + m = re.search(rf'\b{name}="(\d+)"', s) + return bool(int(m.group(1))) if m else None + + +def _str_attr(s: str, name: str) -> str | None: + m = re.search(rf'\b{name}="([^"]+)"', s) + return m.group(1) if m else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/section.py b/03. Code/geulbeot_10th/handlers/template/tools/section.py new file mode 100644 index 0000000..c93e2b0 --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/section.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +§9 구역 정의(Section) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§9 구역 속성 추출. + + Returns: + { + "textDirection": "HORIZONTAL", + "hideFirstHeader": False, + "hideFirstFooter": False, + "pageNum": {"pos": "BOTTOM_CENTER", "formatType": "DIGIT", + "sideChar": "-"}, + "startNum": {"page": 0}, + "colDef": None, + } + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + sec_match = re.search( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + if not sec_match: + return None + + attrs_str = sec_match.group(1) + inner = sec_match.group(2) + + result = {} + + # textDirection + td = re.search(r'\btextDirection="([^"]+)"', attrs_str) + if td: + result["textDirection"] = td.group(1) + + # visibility + vis = re.search(r'', inner) + if vis: + v = vis.group(1) + for attr in ["hideFirstHeader", "hideFirstFooter", + "hideFirstMasterPage", "hideFirstPageNum", + "hideFirstEmptyLine"]: + m = re.search(rf'\b{attr}="(\d+)"', v) + if m: + result[attr] = bool(int(m.group(1))) + + # startNum + sn = re.search(r'', inner) + if sn: + sns = sn.group(1) + start = {} + pso = re.search(r'\bpageStartsOn="([^"]+)"', sns) + if pso: + start["pageStartsOn"] = pso.group(1) + pg = re.search(r'\bpage="(\d+)"', sns) + if pg: + start["page"] = int(pg.group(1)) + if start: + result["startNum"] = start + + # pageNum + pn = re.search(r'', inner) + if pn: + pns = pn.group(1) + pagenum = {} + for attr in ["pos", "formatType", "sideChar"]: + m = re.search(rf'\b{attr}="([^"]*)"', pns) + if m: + pagenum[attr] = m.group(1) + if pagenum: + result["pageNum"] = pagenum + + # colDef (당 섀정) + cd = re.search(r']*)>(.*?)', inner, re.DOTALL) + if cd: + cds = cd.group(1) + coldef = {} + cnt = re.search(r'\bcount="(\d+)"', cds) + if cnt: + coldef["count"] = int(cnt.group(1)) + layout = re.search(r'\blayout="([^"]+)"', cds) + if layout: + coldef["layout"] = layout.group(1) + if coldef: + result["colDef"] = coldef + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/style_def.py b/03. Code/geulbeot_10th/handlers/template/tools/style_def.py new file mode 100644 index 0000000..f055bdd --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/style_def.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +슀타음 정의(Style) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + +charPrIDRef → charPr(Ꞁ자몚양), paraPrIDRef → paraPr(묞닚몚양) 연결. +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """슀타음 정의 추출. + + Returns: + [ + { + "id": 0, "type": "PARA", + "name": "바탕Ꞁ", "engName": "Normal", + "paraPrIDRef": 3, "charPrIDRef": 0, + "nextStyleIDRef": 0, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + styles = re.findall(r'', header_xml) + if not styles: + return None + + result = [] + for s in styles: + item = {} + for attr in ["id", "paraPrIDRef", "charPrIDRef", "nextStyleIDRef"]: + m = re.search(rf'\b{attr}="(\d+)"', s) + if m: + item[attr] = int(m.group(1)) + + for attr in ["type", "name", "engName"]: + m = re.search(rf'\b{attr}="([^"]*)"', s) + if m: + item[attr] = m.group(1) + + result.append(item) + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/handlers/template/tools/table.py b/03. Code/geulbeot_10th/handlers/template/tools/table.py new file mode 100644 index 0000000..d1f160c --- /dev/null +++ b/03. Code/geulbeot_10th/handlers/template/tools/table.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +§6 표(Table) 구조 추출 + +HWPX 싀제 태귞 (section0.xml): + + 8504 8504 8504 + 또는 ì—Ž 수에 맞는 hp:colSz 형태 + + + + + + + + 셀 텍슀튞 + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§6 몚든 표 추출. + + Returns: + [ + { + "index": 0, + "rowCnt": 5, "colCnt": 3, + "repeatHeader": True, + "pageBreak": "CELL", + "colWidths_hu": [8504, 8504, 8504], + "colWidths_pct": [33, 34, 33], + "rows": [ + [ # row 0 + { + "colAddr": 0, "rowAddr": 0, + "colSpan": 2, "rowSpan": 1, + "width_hu": 17008, "height_hu": 2400, + "borderFillIDRef": 5, + "cellMargin": {"left": 510, "right": 510, + "top": 142, "bottom": 142}, + "text": "셀 텍슀튞", + "lines": ["셀 텍슀튞"], + }, + ... + ], + ... + ], + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + # tbl 랔록 전첎 추출 + tbl_blocks = _find_tbl_blocks(section_xml) + if not tbl_blocks: + return None + + result = [] + for idx, (tbl_attrs, tbl_inner) in enumerate(tbl_blocks): + tbl = {"index": idx} + + # 표 속성 + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + tbl[attr] = int(m.group(1)) + + rh = re.search(r'\brepeatHeader="(\d+)"', tbl_attrs) + if rh: + tbl["repeatHeader"] = bool(int(rh.group(1))) + + pb = re.search(r'\bpageBreak="([^"]+)"', tbl_attrs) + if pb: + tbl["pageBreak"] = pb.group(1) + + # 행/셀 (ì—Ž 너비볎닀 뚌저 — 첫 행에서 ì—Ž 너비 추출 가능) + rows = _extract_rows(tbl_inner) + if rows: + tbl["rows"] = rows + + # ì—Ž 너비 + col_widths = _extract_col_widths(tbl_inner) + if not col_widths and rows: + # colSz 없윌멎 행 데읎터에서 추출 (colspan ê³ ë €) + col_cnt = tbl.get("colCnt", 0) + col_widths = _col_widths_from_rows(rows, col_cnt) + if not col_widths: + col_widths = _col_widths_from_first_row(rows[0]) + if col_widths: + tbl["colWidths_hu"] = col_widths + total = sum(col_widths) or 1 + tbl["colWidths_pct"] = [round(w / total * 100) for w in col_widths] + + result.append(tbl) + + return result if result else None + + +def _find_tbl_blocks(xml: str) -> list: + """쀑첩 표륌 고렀하여 최상위 tbl 랔록 추출""" + blocks = [] + start = 0 + while True: + # ]*)>', xml[start:]) + if not m: + break + + attrs = m.group(1) + tag_start = start + m.start() + content_start = start + m.end() + + # 쀑첩 칎욎튞로 닫는 태귞 ì°Ÿêž° + depth = 1 + pos = content_start + while depth > 0 and pos < len(xml): + open_m = re.search(r'', xml[pos:]) + + if close_m is None: + break + + if open_m and open_m.start() < close_m.start(): + depth += 1 + pos += open_m.end() + else: + depth -= 1 + if depth == 0: + inner = xml[content_start:pos + close_m.start()] + blocks.append((attrs, inner)) + pos += close_m.end() + + start = pos + + return blocks + + +def _extract_col_widths(tbl_inner: str) -> list | None: + """ì—Ž 너비 HWPUNIT 추출""" + # 팹턮 1: 8504 8504 8504 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + return [int(w) for w in wl.group(1).strip().split()] + except ValueError: + pass + + # 팹턮 2: 개별 colSz 태귞 + cols = re.findall(r']*\bwidth="(\d+)"', tbl_inner) + if cols: + return [int(c) for c in cols] + + return None + + +def _extract_rows(tbl_inner: str) -> list: + """tr/tc 파싱하여 2D 셀 ë°°ì—Ž 반환""" + rows = [] + + tr_blocks = re.findall( + r']*>(.*?)', tbl_inner, re.DOTALL + ) + + for tr_inner in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr_inner, re.DOTALL + ) + + for tc_match in tc_blocks: + tc_attrs = tc_match.group(1) + tc_inner = tc_match.group(2) + cell = _parse_cell(tc_attrs, tc_inner) + cells.append(cell) + + rows.append(cells) + + return rows + + +def _parse_cell(tc_attrs: str, tc_inner: str) -> dict: + """개별 셀 파싱""" + cell = {} + + # borderFillIDRef on tc tag + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # header flag + hd = re.search(r'\bheader="(\d+)"', tc_attrs) + if hd: + cell["isHeader"] = bool(int(hd.group(1))) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + h = re.search(r'\bheight="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + if h: + cell["height_hu"] = int(h.group(1)) + + # cellMargin + cm = re.search(r'', tc_inner) + if cm: + margin = {} + for side in ["left", "right", "top", "bottom"]: + m = re.search(rf'\b{side}="(\d+)"', cm.group(1)) + if m: + margin[side] = int(m.group(1)) + if margin: + cell["cellMargin"] = margin + + # 셀 텍슀튞 + texts = re.findall(r'([^<]*)', tc_inner) + all_text = " ".join(t.strip() for t in texts if t.strip()) + if all_text: + cell["text"] = all_text + + # ★ v2: 셀 낮 run의 charPrIDRef 추출 (슀타음 연결용) + run_cprs = re.findall(r']*\bcharPrIDRef="(\d+)"', tc_inner) + if run_cprs: + cell["charPrIDRefs"] = [int(c) for c in run_cprs] + cell["primaryCharPrIDRef"] = int(run_cprs[0]) + + # ★ v2: 셀 낮 p의 paraPrIDRef, styleIDRef 추출 + para_pprs = re.findall(r']*\bparaPrIDRef="(\d+)"', tc_inner) + if para_pprs: + cell["paraPrIDRefs"] = [int(p) for p in para_pprs] + cell["primaryParaPrIDRef"] = int(para_pprs[0]) + + para_stys = re.findall(r']*\bstyleIDRef="(\d+)"', tc_inner) + if para_stys: + cell["styleIDRefs"] = [int(s) for s in para_stys] + + # 닀쀑행 (p 태귞 Ʞ쀀) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + if lines: + cell["lines"] = lines + + return cell + + +def _col_widths_from_first_row(first_row: list) -> list | None: + """첫 행 셀의 width_hu에서 ì—Ž 너비 추출 (colSz 없을 때 대첎)""" + widths = [] + for cell in first_row: + w = cell.get("width_hu") + if w: + widths.append(w) + return widths if widths else None + + +def _col_widths_from_rows(rows: list, col_cnt: int) -> list | None: + """★ v2: 몚든 행을 순회하여 colspan=1읞 행에서 정확한 ì—Ž 너비 추출. + + 첫 행에 colspan읎 있윌멎 ì—Ž 너비가 부정확하므로, + 몚든 엎읎 colspan=1읞 행을 ì°Ÿì•„ 사용. + """ + if not rows or not col_cnt: + return None + + # colspan=1읞 셀만 있는 행 ì°Ÿêž° (몚든 ì—Ž 졎재) + for row in rows: + # 읎 행의 몚든 셀읎 colspan=1읎고, 셀 수 == col_cnt읞지 + all_single = all(cell.get("colSpan", 1) == 1 for cell in row) + if all_single and len(row) == col_cnt: + widths = [] + for cell in sorted(row, key=lambda c: c.get("colAddr", 0)): + w = cell.get("width_hu") + if w: + widths.append(w) + if len(widths) == col_cnt: + return widths + + # 못 찟윌멎 첫 행 폎백 + return _col_widths_from_first_row(rows[0]) if rows else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/css/editor.css b/03. Code/geulbeot_10th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_10th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/css/main.css b/03. Code/geulbeot_10th/static/css/main.css new file mode 100644 index 0000000..bb01446 --- /dev/null +++ b/03. Code/geulbeot_10th/static/css/main.css @@ -0,0 +1,1826 @@ + :root { + --ui-bg: #1a1d21; + --ui-nav: #12151a; + --ui-panel: #1e2228; + --ui-hover: #282d35; + --ui-border: #2d333b; + --ui-text: #e6edf3; + --ui-dim: #8b949e; + --ui-accent: #00C853; + --ui-warning: #FF9800; + --ui-error: #f85149; + --ui-info: #58a6ff; + } + + /* ===== 사용자 템플늿 영역 ===== */ + .user-templates-section { + max-height: 200px; + overflow-y: auto; + margin: 10px 0; + padding-right: 5px; + } + + .user-templates-section::-webkit-scrollbar { + width: 4px; + } + + .user-templates-section::-webkit-scrollbar-thumb { + background: var(--ui-border); + border-radius: 2px; + } + + .user-templates-section::-webkit-scrollbar-thumb:hover { + background: var(--ui-dim); + } + + .template-divider { + height: 1px; + background: var(--ui-border); + margin: 10px 0; + position: relative; + } + + .template-divider::after { + content: '사용자 템플늿'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--ui-nav); + padding: 0 8px; + font-size: 9px; + color: var(--ui-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* 사용자 템플늿 아읎템 */ + .user-template-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + position: relative; + margin-bottom: 4px; + } + + .user-template-item:hover { + background: var(--ui-hover); + border-color: var(--ui-dim); + } + + .user-template-item.selected { + border-color: var(--ui-accent); + background: rgba(0, 200, 83, 0.1); + } + + .user-template-item .label { + flex: 1; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-template-item .delete-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--ui-dim); + font-size: 14px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.15s; + } + + .user-template-item:hover .delete-btn { + opacity: 1; + } + + .user-template-item .delete-btn:hover { + background: var(--ui-error); + color: white; + } + + /* 템플늿 추가 몚달 */ + .template-modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + align-items: center; + justify-content: center; + z-index: 2000; + } + + .template-modal.active { + display: flex; + } + + .template-modal-content { + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 12px; + padding: 24px; + width: 420px; + box-shadow: 0 15px 50px rgba(0,0,0,0.5); + } + + .template-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .template-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-accent); + display: flex; + align-items: center; + gap: 8px; + } + + .template-modal-close { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--ui-dim); + font-size: 18px; + cursor: pointer; + border-radius: 6px; + } + + .template-modal-close:hover { + background: var(--ui-hover); + color: var(--ui-text); + } + + .template-input-group { + margin-bottom: 16px; + } + + .template-input-label { + font-size: 12px; + font-weight: 600; + color: var(--ui-dim); + margin-bottom: 8px; + display: block; + } + + .template-name-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 13px; + } + + .template-name-input:focus { + outline: none; + border-color: var(--ui-accent); + } + + .template-dropzone { + border: 2px dashed var(--ui-border); + border-radius: 8px; + padding: 30px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + } + + .template-dropzone:hover, + .template-dropzone.dragover { + border-color: var(--ui-accent); + background: rgba(0, 200, 83, 0.05); + } + + .template-dropzone-icon { + font-size: 36px; + margin-bottom: 10px; + } + + .template-dropzone-text { + font-size: 13px; + color: var(--ui-text); + margin-bottom: 5px; + } + + .template-dropzone-hint { + font-size: 11px; + color: var(--ui-dim); + } + + .template-dropzone-file { + display: none; + align-items: center; + gap: 10px; + padding: 10px; + background: var(--ui-bg); + border-radius: 6px; + margin-top: 10px; + } + + .template-dropzone-file.show { + display: flex; + } + + .template-dropzone-file .filename { + flex: 1; + font-size: 12px; + color: var(--ui-accent); + } + + .template-dropzone-file .remove { + color: var(--ui-dim); + cursor: pointer; + } + + .template-dropzone-file .remove:hover { + color: var(--ui-error); + } + + .template-submit-btn { + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + background: linear-gradient(135deg, var(--ui-accent), #00a844); + color: #003300; + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 20px; + transition: all 0.2s; + } + + .template-submit-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 15px rgba(0, 200, 83, 0.3); + } + + .template-submit-btn:disabled { + background: var(--ui-border); + color: var(--ui-dim); + cursor: not-allowed; + } + + .template-submit-btn .spinner { + display: none; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + /* 템플늿 늬슀튞 */ + .template-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + } + + .template-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + } + + .template-item:hover { + background: var(--ui-hover); + border-color: var(--ui-dim); + } + + .template-item.selected { + border-color: var(--ui-accent); + background: rgba(0, 200, 83, 0.1); + } + + .template-item input[type="radio"] { + accent-color: var(--ui-accent); + } + + .template-item .label { + flex: 1; + font-size: 13px; + font-weight: 500; + } + + .template-item .delete-btn { + opacity: 0; + background: transparent; + border: none; + color: var(--ui-dim); + cursor: pointer; + font-size: 14px; + } + + .template-item:hover .delete-btn { + opacity: 1; + } + + .template-item .delete-btn:hover { + color: var(--ui-error); + } + + /* 템플늿 요소 첎크박슀 */ + .template-elements { + margin-top: 12px; + padding: 12px; + background: var(--ui-bg); + border: 1px solid var(--ui-border); + border-radius: 6px; + } + + .elements-title { + font-size: 10px; + font-weight: 600; + color: var(--ui-dim); + margin-bottom: 8px; + text-transform: uppercase; + } + + .elements-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .element-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--ui-text); + cursor: pointer; + } + + .element-checkbox input[type="checkbox"] { + accent-color: var(--ui-accent); + } + + .element-checkbox .element-icon { + font-size: 14px; + } + + /* 사용자 템플늿 프늬뷰 */ + .user-template-preview { + display: none; + position: fixed; + width: 280px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 12px; + padding: 15px; + box-shadow: 0 10px 40px rgba(0,0,0,0.5); + z-index: 1000; + pointer-events: none; + } + + .user-template-preview::after { + content: ''; + position: absolute; + right: -8px; + top: 50%; + transform: translateY(-50%); + border: 8px solid transparent; + border-left-color: var(--ui-panel); + } + + .user-template-preview.show { + display: block; + } + + .preview-analyzed-features { + margin-top: 12px; + } + + .preview-analyzed-feature { + font-size: 11px; + color: var(--ui-accent); + padding: 3px 0; + display: flex; + align-items: center; + gap: 6px; + } + + .preview-analyzed-feature::before { + content: '✓'; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + body { + font-family: 'Noto Sans KR', sans-serif; + background: var(--ui-bg); + color: var(--ui-text); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + + /* ===== 상닚 툮바 ===== */ + .toolbar { + height: 50px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + display: flex; + align-items: center; + padding: 0 15px; + gap: 8px; + } + + .toolbar-logo { + display: flex; + align-items: center; + gap: 8px; + font-weight: 900; + color: var(--ui-accent); + font-size: 18px; + } + + .toolbar-spacer { flex: 1; } + + .toolbar-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 8px; + } + + .toolbar-btn { + padding: 7px 14px; + border-radius: 5px; + border: 1px solid var(--ui-border); + background: var(--ui-hover); + color: var(--ui-text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + } + + .toolbar-btn:hover { + background: var(--ui-panel); + border-color: var(--ui-dim); + } + + .toolbar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .toolbar-btn.active { + background: rgba(0, 200, 83, 0.2); + border-color: var(--ui-accent); + color: var(--ui-accent); + } + + .zoom-select { + padding: 6px 10px; + border-radius: 5px; + border: 1px solid var(--ui-border); + background: var(--ui-hover); + color: var(--ui-text); + font-size: 12px; + cursor: pointer; + } + + /* ===== 메읞 컚테읎너 ===== */ + .app { + display: flex; + flex: 1; + overflow: hidden; + } + + /* ===== 좌잡 사읎드바 ===== */ + .sidebar { + width: 280px; + background: var(--ui-nav); + border-right: 1px solid var(--ui-border); + display: flex; + flex-direction: column; + } + + .sidebar-header { + padding: 15px; + border-bottom: 1px solid var(--ui-border); + background: var(--ui-panel); + } + + .sidebar-title { + font-size: 11px; + font-weight: 700; + color: var(--ui-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; + } + + .sidebar-btn { + width: 100%; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-hover); + color: var(--ui-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + transition: all 0.15s; + text-align: left; + margin-bottom: 8px; + } + + .sidebar-btn:hover { + background: var(--ui-panel); + border-color: var(--ui-dim); + } + + .sidebar-btn .icon { font-size: 16px; } + + .sidebar-content { + flex: 1; + overflow-y: auto; + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; + } + + .section-title { + font-size: 11px; + font-weight: 700; + color: var(--ui-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + } + + /* ì°žê³  파음 확읞 */ + .file-check-box { + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 8px; + padding: 12px; + } + + .file-check-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + padding: 5px 0; + } + + .file-check-label { color: var(--ui-dim); } + + .file-check-value { font-weight: 600; } + .file-check-value.ok { color: var(--ui-accent); } + .file-check-value.warn { color: var(--ui-warning); cursor: pointer; } + .file-check-value.warn:hover { text-decoration: underline; } + + .file-path { + font-size: 11px; + color: var(--ui-info); + word-break: break-all; + padding: 6px 0; + border-bottom: 1px solid var(--ui-border); + margin-bottom: 5px; + } + + .file-path.empty { color: var(--ui-dim); font-style: italic; } + + /* 믞확읞 파음 펌칚 */ + .unknown-files { + background: var(--ui-bg); + border: 1px solid var(--ui-border); + border-radius: 6px; + margin-top: 8px; + padding: 10px; + display: none; + } + + .unknown-files.show { display: block; } + + .unknown-file-item { + font-size: 11px; + color: var(--ui-warning); + padding: 3px 0; + } + + .open-folder-btn { + margin-top: 8px; + padding: 6px 10px; + border-radius: 4px; + border: 1px solid var(--ui-border); + background: var(--ui-hover); + color: var(--ui-dim); + font-size: 11px; + cursor: pointer; + width: 100%; + } + + .open-folder-btn:hover { + border-color: var(--ui-accent); + color: var(--ui-accent); + } + + /* 작성 방식 선택 (가로 탭) */ + .write-mode-box { + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 8px; + padding: 10px; + } + + .write-mode-tabs { + display: flex; + gap: 4px; + } + + .write-mode-tab { + flex: 1; + padding: 10px 8px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + text-align: center; + } + + .write-mode-tab:hover { + background: var(--ui-hover); + border-color: var(--ui-dim); + } + + .write-mode-tab.selected { + background: rgba(0, 200, 83, 0.15); + border-color: var(--ui-accent); + } + + .write-mode-tab input[type="radio"] { + display: none; + } + + .write-mode-icon { + font-size: 16px; + margin-bottom: 4px; + } + + .write-mode-label { + font-size: 11px; + font-weight: 600; + color: var(--ui-text); + } + + .write-mode-tab.selected .write-mode-label { + color: var(--ui-accent); + } + + .write-mode-notice { + margin-top: 8px; + padding: 6px 8px; + background: rgba(255, 152, 0, 0.1); + border-radius: 4px; + font-size: 10px; + color: var(--ui-warning); + display: flex; + align-items: center; + gap: 5px; + } + + /* 진행 상태 */ + .step-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .step-item { + font-size: 11px; + color: var(--ui-dim); + padding: 6px 8px; + display: flex; + align-items: center; + gap: 8px; + border-radius: 4px; + transition: all 0.15s; + } + + .step-item .status { + width: 16px; + text-align: center; + font-size: 12px; + } + + .step-item.pending { color: #555; } + .step-item.running { + color: var(--ui-info); + background: rgba(88, 166, 255, 0.1); + } + .step-item.running .status { animation: pulse 1s infinite; } + .step-item.done { color: var(--ui-accent); } + .step-item.error { color: var(--ui-error); } + + .step-divider { + height: 1px; + background: var(--ui-border); + margin: 6px 0; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + /* 좌잡 생성 버튌 */ + .sidebar-generate-btn { + width: 100%; + padding: 14px; + border-radius: 8px; + border: none; + background: linear-gradient(135deg, var(--ui-accent), #00a844); + color: #003300; + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + } + + .sidebar-generate-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 15px rgba(0, 200, 83, 0.3); + } + + .sidebar-generate-btn:disabled { + background: var(--ui-border); + color: var(--ui-dim); + cursor: not-allowed; + transform: none; + box-shadow: none; + } + + /* ===== 가욎데 ë·°ì–Ž ===== */ + .main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; + } + + /* 서식 바 */ + .format-bar { + height: 44px; + background: #252a30; + border-bottom: 1px solid var(--ui-border); + display: none; + align-items: center; + padding: 0 12px; + gap: 4px; + } + .format-bar.active { display: flex; } + + .format-btn { + width: 32px; height: 32px; border-radius: 4px; border: none; + background: transparent; color: var(--ui-text); cursor: pointer; + font-size: 14px; display: flex; align-items: center; justify-content: center; + transition: all 0.15s; + } + .format-btn:hover { background: var(--ui-hover); } + .format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + + .format-select { + padding: 4px 8px; border-radius: 4px; border: 1px solid var(--ui-border); + background: var(--ui-hover); color: var(--ui-text); font-size: 12px; + cursor: pointer; min-width: 80px; + } + .format-divider { width: 1px; height: 24px; background: var(--ui-border); margin: 0 6px; } + + .viewer { + flex: 1; + background: #525659; + overflow: auto; + display: flex; + justify-content: center; + padding: 30px; + } + + .a4-wrapper { + transform-origin: top center; + } + + .a4-preview { + width: 210mm; + min-height: 297mm; + background: white; + box-shadow: 0 0 20px rgba(0,0,0,0.3); + color: #333; + position: relative; + } + + .preview-iframe { + width: 100%; + min-height: 297mm; + border: none; + background: white; + display: none; + } + + .preview-iframe.active { + display: block; + } + + .placeholder { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 297mm; + color: #888; + text-align: center; + padding: 40px; + } + + .placeholder .icon { + font-size: 72px; + margin-bottom: 25px; + opacity: 0.4; + } + + .placeholder .text { + font-size: 18px; + color: #666; + margin-bottom: 10px; + } + + .placeholder .sub-text { + font-size: 14px; + color: #999; + } + + /* 하당 플드백 바 */ + .feedback-bar { + background: var(--ui-panel); + border-top: 1px solid var(--ui-border); + padding: 12px 20px; + display: none; + align-items: center; + gap: 12px; + } + + /* 목찚 확읞 액션바 */ + .toc-action-bar { + background: var(--ui-panel); + border-top: 1px solid var(--ui-border); + padding: 12px 20px; + display: none; + align-items: center; + justify-content: flex-end; + gap: 12px; + } + .toc-action-bar.show { display: flex; } + + .toc-action-btn { + padding: 10px 24px; + border-radius: 6px; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .toc-action-btn.primary { + background: var(--ui-accent); + color: #003300; + } + .toc-action-btn.primary:hover { + background: #00e676; + } + .toc-action-btn.secondary { + background: var(--ui-hover); + color: var(--ui-text); + border: 1px solid var(--ui-border); + } + .toc-action-btn.secondary:hover { + border-color: var(--ui-accent); + color: var(--ui-accent); + } + + .feedback-bar.show { display: flex; } + + .feedback-input { + flex: 1; + padding: 10px 14px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-bg); + color: var(--ui-text); + font-size: 13px; + } + .feedback-input:focus { + outline: none; + border-color: var(--ui-accent); + } + + .feedback-btn { + padding: 10px 20px; + border-radius: 6px; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .feedback-btn.primary { + background: var(--ui-accent); + color: #003300; + } + .feedback-btn.primary:hover { + background: #00e676; + } + .feedback-btn.secondary { + background: var(--ui-hover); + color: var(--ui-text); + border: 1px solid var(--ui-border); + } + + /* ===== ìš°ìž¡ 팹널 ===== */ + .right-panel { + width: 280px; + background: var(--ui-nav); + border-left: 1px solid var(--ui-border); + display: flex; + flex-direction: column; + } + + .panel-header { + padding: 15px; + border-bottom: 1px solid var(--ui-border); + background: var(--ui-panel); + } + + .panel-title { + font-size: 11px; + font-weight: 700; + color: var(--ui-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .panel-body { + flex: 1; + overflow-y: auto; + padding: 15px; + } + + /* 묞서 유형 선택 - 심플 늬슀튞 */ + .doc-type-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + } + + .doc-type-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + position: relative; + } + + .doc-type-item:hover:not(.disabled) { + background: var(--ui-hover); + border-color: var(--ui-dim); + } + + .doc-type-item.selected { + border-color: var(--ui-accent); + background: rgba(0, 200, 83, 0.1); + } + + .doc-type-item.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .doc-type-item input[type="radio"] { + accent-color: var(--ui-accent); + } + + .doc-type-item .label { + flex: 1; + font-size: 13px; + font-weight: 500; + } + + .doc-type-item .badge { + font-size: 9px; + padding: 2px 6px; + border-radius: 4px; + background: var(--ui-warning); + color: #000; + font-weight: 600; + } + + .doc-type-item .delete-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--ui-dim); + font-size: 14px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.15s; + } + + .doc-type-item:hover .delete-btn { + opacity: 1; + } + + .doc-type-item .delete-btn:hover { + background: var(--ui-error); + color: white; + } + + /* 플로팅 프늬뷰 팝업 - fixed로 변겜 */ + .doc-type-preview { + display: none; + position: fixed; + width: 280px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 12px; + padding: 15px; + box-shadow: 0 10px 40px rgba(0,0,0,0.5); + z-index: 1000; + pointer-events: none; + } + + .doc-type-preview::after { + content: ''; + position: absolute; + right: -8px; + top: 50%; + transform: translateY(-50%); + border: 8px solid transparent; + border-left-color: var(--ui-panel); + } + + .doc-type-preview.show { + display: block; + } + + /* 프늬뷰 썞넀음 */ + .preview-thumbnail { + width: 100%; + height: 120px; + background: #ffffff; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-bottom: 12px; + border: 1px solid var(--ui-border); + overflow: hidden; + padding: 10px; + } + + /* Ʞ획서 프늬뷰 - 2페읎지 */ + .preview-thumbnail.briefing .page { + width: 45px; + height: 64px; + border: 1px solid #ccc; + border-radius: 2px; + padding: 3px; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + .preview-thumbnail.briefing .page-header { + height: 4px; + background: #1a365d; + margin-bottom: 3px; + border-radius: 1px; + } + .preview-thumbnail.briefing .page-title { + height: 5px; + background: #1a365d; + margin-bottom: 3px; + border-radius: 1px; + } + .preview-thumbnail.briefing .page-divider { + height: 2px; + background: linear-gradient(90deg, #1a365d, #2c5282); + margin-bottom: 3px; + } + .preview-thumbnail.briefing .page-lead { + height: 8px; + background: #f7fafc; + border-left: 2px solid #1a365d; + margin-bottom: 3px; + } + .preview-thumbnail.briefing .page-body { + height: 4px; + background: #e2e8f0; + margin-bottom: 2px; + border-radius: 1px; + } + .preview-thumbnail.briefing .page-bottom { + height: 6px; + background: linear-gradient(90deg, #1a365d 22%, #f0f0f0 22%); + margin-top: auto; + border-radius: 1px; + } + .preview-thumbnail.briefing .page-attach { + font-size: 4px; + color: #666; + text-align: center; + margin-bottom: 2px; + } + + /* 볎고서 프늬뷰 */ + .preview-thumbnail.report { + flex-direction: column; + justify-content: flex-start; + padding: 15px; + } + .preview-thumbnail.report .line { + width: 100%; + height: 3px; + background: #ddd; + margin-bottom: 4px; + border-radius: 1px; + } + .preview-thumbnail.report .line.h1 { + background: #333; + height: 5px; + width: 50%; + margin-bottom: 8px; + } + .preview-thumbnail.report .line.h2 { + background: #555; + height: 4px; + width: 40%; + margin-top: 6px; + margin-bottom: 6px; + } + .preview-thumbnail.report .line.body { + background: #ccc; + width: 95%; + } + + /* PPT 프늬뷰 */ + .preview-thumbnail.ppt { + background: #2d3748; + flex-direction: row; + gap: 6px; + padding: 15px; + } + .preview-thumbnail.ppt .slide { + width: 55px; + height: 40px; + background: #1a365d; + border-radius: 3px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 4px; + } + .preview-thumbnail.ppt .slide-title { + font-size: 5px; + color: white; + font-weight: bold; + margin-bottom: 3px; + } + .preview-thumbnail.ppt .slide-body { + width: 80%; + height: 2px; + background: rgba(255,255,255,0.3); + margin-bottom: 2px; + } + + /* custom 프늬뷰 */ + .preview-thumbnail.custom { + flex-direction: column; + justify-content: flex-start; + padding: 15px; + } + .preview-thumbnail.custom .line { + width: 100%; + height: 3px; + background: #ddd; + margin-bottom: 4px; + border-radius: 1px; + } + .preview-thumbnail.custom .line.h1 { + background: #1a365d; + height: 5px; + width: 60%; + margin-bottom: 8px; + } + .preview-thumbnail.custom .line.h2 { + background: #2c5282; + height: 4px; + width: 45%; + margin-top: 6px; + margin-bottom: 6px; + } + .preview-thumbnail.custom .line.body { + background: #cbd5e0; + width: 90%; + } + + /* 프늬뷰 정볎 */ + .preview-title { + font-size: 14px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 4px; + } + + .preview-desc { + font-size: 11px; + color: var(--ui-dim); + margin-bottom: 12px; + } + + .preview-features { + display: flex; + flex-direction: column; + gap: 5px; + } + + .preview-feature { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--ui-dim); + } + + .preview-feature .icon { + font-size: 12px; + } + + /* 템플늿 추가 버튌 */ + .add-template-btn { + width: 100%; + padding: 12px; + border-radius: 8px; + border: 1px dashed var(--ui-border); + background: transparent; + color: var(--ui-dim); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + margin-top: 10px; + } + + .add-template-btn:hover { + border-color: var(--ui-accent); + color: var(--ui-accent); + background: rgba(0, 200, 83, 0.05); + } + + /* 옵션 섹션 */ + .option-section { + margin-bottom: 20px; + } + + .option-title { + font-size: 11px; + font-weight: 700; + color: var(--ui-dim); + margin-bottom: 10px; + } + + .option-group { + display: flex; + flex-direction: column; + gap: 6px; + } + + .option-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + } + + .option-item:hover { + background: var(--ui-hover); + } + + .option-item.selected { + border-color: var(--ui-accent); + background: rgba(0, 200, 83, 0.1); + } + + .option-item input[type="radio"], + .option-item input[type="checkbox"] { + accent-color: var(--ui-accent); + } + + .option-item label { + font-size: 12px; + cursor: pointer; + flex: 1; + } + + /* 페읎지 수 입력 필드 */ + .page-input { + width: 45px; + padding: 4px 6px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; + text-align: center; + margin-left: 4px; + } + + .page-input:focus { + outline: none; + border-color: var(--ui-accent); + } + + .page-input:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .page-input-suffix { + font-size: 12px; + color: var(--ui-dim); + margin-left: 2px; + } + + /* 슀플너 숚ꞰꞰ (Chrome) */ + .page-input::-webkit-inner-spin-button, + .page-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* 요청사항 */ + .request-textarea { + width: 100%; + height: 100px; + padding: 12px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-panel); + color: var(--ui-text); + font-size: 12px; + resize: vertical; + font-family: inherit; + } + .request-textarea:focus { + outline: none; + border-color: var(--ui-accent); + } + .request-textarea::placeholder { + color: var(--ui-dim); + } + + /* 생성 버튌 */ + .generate-btn { + width: 100%; + padding: 14px; + border-radius: 8px; + border: none; + background: linear-gradient(135deg, var(--ui-accent), #00a844); + color: #003300; + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + margin-top: 20px; + } + + .generate-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 15px rgba(0, 200, 83, 0.3); + } + + .generate-btn:disabled { + background: var(--ui-border); + color: var(--ui-dim); + cursor: not-allowed; + transform: none; + box-shadow: none; + } + + /* ===== 상태바 ===== */ + .status-bar { + height: 28px; + background: var(--ui-nav); + border-top: 1px solid var(--ui-border); + padding: 0 15px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--ui-dim); + } + + .status-left { + display: flex; + align-items: center; + gap: 10px; + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + } + + .status-dot.connected { + background: var(--ui-accent); + box-shadow: 0 0 8px var(--ui-accent); + } + + /* ===== 몚달 ===== */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal-overlay.active { display: flex; } + + .modal { + background: var(--ui-panel); + border: 1px solid var(--ui-border); + border-radius: 12px; + padding: 20px; + min-width: 450px; + max-width: 550px; + box-shadow: 0 15px 50px rgba(0,0,0,0.5); + } + + .modal-header { + font-size: 16px; + font-weight: 700; + color: var(--ui-accent); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + } + + .modal-body { margin-bottom: 20px; } + + .modal-textarea { + width: 100%; + height: 220px; + padding: 12px 14px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; + font-family: 'Consolas', 'Monaco', monospace; + resize: vertical; + } + + .modal-textarea:focus { + outline: none; + border-color: var(--ui-accent); + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + } + + .modal-btn { + padding: 10px 20px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-hover); + color: var(--ui-text); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + } + + .modal-btn:hover { background: var(--ui-panel); } + + .modal-btn.primary { + background: var(--ui-accent); + border-color: var(--ui-accent); + color: #003300; + font-weight: 700; + } + + .modal-btn.primary:hover { background: #00e676; } + + /* 슀크례바 */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track { background: var(--ui-bg); } + ::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: #555; } + + /* 로딩 슀플너 */ + .loading-spinner { + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* AI 수정 플로팅 박슀 */ + .ai-edit-popup { + display: none; + position: fixed; + top: 80px; + right: 300px; + width: 320px; + background: var(--ui-panel); + border: 1px solid var(--ui-accent); + border-radius: 12px; + padding: 15px; + box-shadow: 0 10px 40px rgba(0, 200, 83, 0.2); + z-index: 500; + } + + .ai-edit-popup.show { display: block; } + + .ai-edit-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + color: var(--ui-accent); + margin-bottom: 12px; + } + + .ai-edit-selected { + font-size: 11px; + color: var(--ui-dim); + margin-bottom: 8px; + } + + .ai-edit-selected-text { + background: var(--ui-bg); + border: 1px solid var(--ui-border); + border-radius: 6px; + padding: 8px; + font-size: 12px; + color: var(--ui-text); + max-height: 80px; + overflow-y: auto; + margin-bottom: 12px; + } + + .ai-edit-input { + width: 100%; + padding: 10px; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; + margin-bottom: 12px; + resize: none; + } + + .ai-edit-btn { + width: 100%; + padding: 10px; + border-radius: 6px; + border: none; + background: var(--ui-accent); + color: #003300; + font-size: 13px; + font-weight: 700; + cursor: pointer; + } + + .ai-edit-close { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: var(--ui-dim); + cursor: pointer; + font-size: 16px; + } + + /* 묞서 유형별 옵션 컚테읎너 */ + #docTypeOptionsContainer { + margin-bottom: 20px; + } + + /* 사용자 묞서 유형 구분선 */ + .doc-type-divider { + height: 1px; + background: var(--ui-border); + margin: 10px 0; + position: relative; + } + + .doc-type-divider::after { + content: '사용자 추가'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--ui-nav); + padding: 0 8px; + font-size: 9px; + color: var(--ui-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* 로딩 상태 */ + .doc-type-list.loading { + min-height: 100px; + display: flex; + align-items: center; + justify-content: center; + } + + .doc-type-list.loading::after { + content: '로딩 쀑...'; + color: var(--ui-dim); + font-size: 12px; + } + + .analysis-progress { + padding: 20px 0; + } + + .analysis-step { + display: flex; + align-items: center; + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 6px; + background: var(--ui-bg); + } + + .analysis-step.running { + background: var(--accent-light); + } + + .analysis-step.done { + opacity: 0.7; + } + + .analysis-step .step-icon { + width: 24px; + text-align: center; + } + + .analysis-step .step-name { + flex: 1; + margin-left: 8px; + } + + .analysis-step .step-status { + font-size: 12px; + color: var(--ui-text-muted); + } + + .progress-bar-container { + height: 8px; + background: var(--ui-border); + border-radius: 4px; + margin-top: 16px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + background: var(--accent-primary); + transition: width 0.3s ease; + } + + /* 분석 결곌 UI */ + .analysis-result h4 { + margin-bottom: 16px; + } + + .result-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 20px; + } + + .summary-item { + padding: 12px; + background: var(--ui-bg); + border-radius: 6px; + text-align: center; + } + + .toc-list { + list-style: none; + padding: 0; + } + + .toc-list li { + padding: 6px 12px; + border-left: 2px solid var(--ui-border); + margin-bottom: 2px; + } + + .toc-level-2 { padding-left: 28px; } + .toc-level-3 { padding-left: 44px; } + .step-icon.spinning { + display: inline-block; + animation: spin 1s linear infinite; + } + + .analysis-step .step-status { + font-size: 10px; + color: var(--ui-dim); + margin-left: auto; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/ai_edit.js b/03. Code/geulbeot_10th/static/js/ai_edit.js new file mode 100644 index 0000000..4d3b683 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/ai_edit.js @@ -0,0 +1,143 @@ +// ===== AI 부분 수정 ===== + function setupIframeSelection() { + const frame = document.getElementById('previewFrame'); + if (!frame.contentDocument) return; + + // 랔록 선택 시 텍슀튞만 저장 (팝업 안 띄움) + frame.contentDocument.addEventListener('mouseup', function(e) { + const selection = frame.contentWindow.getSelection(); + const text = selection.toString().trim(); + + if (text.length > 0) { + selectedText = text; + selectedRange = selection.getRangeAt(0).cloneRange(); + // 툎바의 AI 버튌 활성화 표시 + const aiBtn = document.getElementById('aiEditToolbarBtn'); + if (aiBtn) { + aiBtn.classList.add('has-selection'); + aiBtn.title = `AI 수정 (${text.length}자 선택됚)`; + } + } else { + selectedText = ''; + selectedRange = null; + const aiBtn = document.getElementById('aiEditToolbarBtn'); + if (aiBtn) { + aiBtn.classList.remove('has-selection'); + aiBtn.title = 'AI 수정 (텍슀튞륌 뚌저 선택하섞요)'; + } + } + }); + } + + // 툮바 버튌 큎늭 시 AI 펞집 팝업 표시 + function triggerAiEdit() { + if (!selectedText || selectedText.length === 0) { + alert('뚌저 수정할 텍슀튞륌 드래귞하여 선택핎죌섞요.'); + return; + } + showAiEditPopup(selectedText); + } + + function showAiEditPopup(text) { + const popup = document.getElementById('aiEditPopup'); + const textDisplay = document.getElementById('aiEditSelectedText'); + + const displayText = text.length > 150 ? text.substring(0, 150) + '...' : text; + textDisplay.textContent = displayText; + + popup.classList.add('show'); + document.getElementById('aiEditInput').focus(); + } + + function closeAiEditPopup() { + document.getElementById('aiEditPopup').classList.remove('show'); + document.getElementById('aiEditInput').value = ''; + } + + async function submitAiEdit() { + const request = document.getElementById('aiEditInput').value.trim(); + if (!request) { + alert('수정 요청을 입력핎죌섞요.'); + return; + } + + if (!selectedText) { + alert('선택된 텍슀튞가 없습니닀.'); + return; + } + + const btn = document.querySelector('.ai-edit-btn'); + const originalText = btn.textContent; + btn.textContent = '⏳ 수정 쀑...'; + btn.disabled = true; + + setStatus('부분 수정 쀑...', true); + + try { + const response = await fetch('/refine-selection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + current_html: generatedHTML, + selected_text: selectedText, + request: request, + doc_type: currentDocType + }) + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + if (data.success && data.html) { + const frame = document.getElementById('previewFrame'); + const doc = frame.contentDocument; + + // 간닚한 텍슀튞 교첎 + const modifiedContent = data.html.replace(/```html\n?/g, '').replace(/```\n?/g, '').trim(); + + const searchStr = selectedText.substring(0, 30); + const allElements = doc.body.getElementsByTagName('*'); + + for (const el of allElements) { + if (el.textContent && el.textContent.includes(searchStr)) { + let hasChildWithText = false; + for (const child of el.children) { + if (child.textContent && child.textContent.includes(searchStr)) { + hasChildWithText = true; + break; + } + } + if (!hasChildWithText) { + el.innerHTML = modifiedContent; + break; + } + } + } + + generatedHTML = '' + doc.documentElement.outerHTML; + + // 선택 쎈Ʞ화 + selectedText = ''; + selectedRange = null; + const aiBtn = document.getElementById('aiEditToolbarBtn'); + if (aiBtn) { + aiBtn.classList.remove('has-selection'); + aiBtn.title = 'AI 수정 (텍슀튞륌 뚌저 선택하섞요)'; + } + + setTimeout(setupIframeSelection, 500); + closeAiEditPopup(); + setStatus('부분 수정 완료', true); + } + + } catch (error) { + alert('수정 였류: ' + error.message); + setStatus('였류 발생', false); + } finally { + btn.textContent = originalText; + btn.disabled = false; + } + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/demo_mode.js b/03. Code/geulbeot_10th/static/js/demo_mode.js new file mode 100644 index 0000000..2f66132 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/demo_mode.js @@ -0,0 +1,371 @@ +/** + * Ꞁ벗 Light v2.1 — 시연용 데몚 몚드 + * + * 배치: + * 1. output/result/ 폎더에 HTML 파음: + * - report.html, brief_1.html, brief_2.html, slide.html + * 2. index.html에 generator.js 뒀에 추가: + * + * 3. 시연 후 DEMO_MODE = false + */ + +const DEMO_MODE = true; + +// ===== 가짜 목찚 데읎터 (볎고서 6장) ===== +const DEMO_TOC_ITEMS = [ + { + num: '1장', + title: '한국 토목 엔지니얎링 소프튞웚얎 시장 현황', + guide: '국낎 토목 섀계 시장의 AutoCAD 독점 구조와 시장 점유윚 현황을 분석하고, 독점적 지위의 배겜곌 귞로 읞한 구조적 묞제점을 도출한닀.', + keywords: ['시장 점유윚', 'AutoCAD 독점', '띌읎선슀 비용', 'Ʞ술 종속'], + contents: [ + '📊 국낎 CAD 소프튞웚얎 시장 점유윚 비교표 (핵심역량강화방안.hwp p.8, 새로욎시대쀀비된우늬.hwp p.3)', + '📊 연간 띌읎선슀 비용 추읎표 (핵심역량강화방안.hwp p.12)' + ] + }, + { + num: '2장', + title: 'AutoCAD, 토목섀계에 정말 적합한가?', + guide: '걎축곌 토목의 귌볞적 찚읎륌 분석하고, AutoCAD의 토목 분알 적용 시 Ʞ능적 한계와 Ʞ술적 비횚윚을 검슝한닀.', + keywords: ['레고와 찰흙', '비정형 형상', '데읎터 닚절', 'BIM 부조화'], + contents: [ + '📊 걎축 vs 토목 섀계 특성 비교표 (핵심역량강화방안.hwp p.15)', + '🖌 비정형 지형 몚덞링 사례 읎믞지 (발표자료.pptx p.7)', + '📊 잡량→섀계→시공 데읎터 흐멄도 (새로욎시대쀀비된우늬.hwp p.11)' + ] + }, + { + num: '3장', + title: '시장의 족쇄: ꎀ행읞가, 필수읞가?', + guide: '익숙핚의 핚정곌 전환 비용 읞식, 띌읎선슀 비용 압박 구조륌 분석하여 독점 유지 메컀니슘의 싀첎륌 파악한닀.', + keywords: ['익숙핚의 핚정', '삌쀑 잠ꞈ', '비용 압박', 'Ʞ술적 우위 허상'], + contents: [ + '📊 삌쀑 잠ꞈ 횚곌 구조도 (핵심역량강화방안.hwp p.22)', + '📊 AutoCAD vs 대안 SW Ʞ능 비교표 (발표자료.pptx p.12, 핵심역량강화방안.hwp p.25)' + ] + }, + { + num: '4장', + title: '지식재산권: 묞제점곌 핎결 방안', + guide: '비공개 DWG 포맷윌로 읞한 성곌묌 소유권 왜곡, Ʞ술 종속, 데읎터 죌권 칚핎 묞제륌 분석하고 핎결 방안을 제시한닀.', + keywords: ['DWG 종속', '성곌묌 소유권', '데읎터 죌권', '개방형 포맷'], + contents: [ + '📊 DWG vs 개방형 포맷 비교표 (핵심역량강화방안.hwp p.30)', + '🖌 데읎터 종속 구조 닀읎얎귞랚 (발표자료.pptx p.15)', + '📊 공공조달 포맷 현황표 (새로욎시대쀀비된우늬.hwp p.18)' + ] + }, + { + num: '5장', + title: '새로욎 가능성: 대안을 찟아서', + guide: '토목 엔지니얎의 핵심 요구사항을 정늬하고, 시장의 대안 소프튞웚얎 옵션 및 국산 솔룚션 개발의 전략적 쀑요성을 분석한닀.', + keywords: ['핵심 요구사항', 'Civil 3D', 'OpenRoads', '국산 솔룚션'], + contents: [ + '📊 대안 소프튞웚얎 Ʞ능·비용 비교표 (핵심역량강화방안.hwp p.35, 발표자료.pptx p.18)', + '📊 닚계별 전환 로드맵 (새로욎시대쀀비된우늬.hwp p.22)', + '🖌 국산 솔룚션 아킀텍처 구성도 (발표자료.pptx p.20)' + ] + }, + { + num: '6장', + title: 'ê²°ë¡  및 시사점', + guide: '분석 결곌륌 종합하여 닚계적 전환 로드맵을 제시하고, 비용 절감·데읎터 죌권 확볎·Ʞ술 겜쟁력 강화의 Ʞ대횚곌륌 도출한닀.', + keywords: ['전환 로드맵', '비용 절감', '데읎터 죌권', 'Ʞ술 겜쟁력'], + contents: [ + '📊 Ʞ대횚곌 종합표 (핵심역량강화방안.hwp p.40, 새로욎시대쀀비된우늬.hwp p.25)', + '📊 Q1~Q4 싀행 음정표 (발표자료.pptx p.22)' + ] + } +]; + +// ===== 묞서 유형별 HTML 겜로 ===== +const DEMO_HTML_MAP = { + 'report': '/static/result/report.html', + 'briefing_1': '/static/result/brief_1.html', + 'briefing_2': '/static/result/brief_2.html', + 'slide': '/static/result/slide.html' +}; + + +// ===== fetch 읞터셉터 ===== +if (DEMO_MODE) { + const _originalFetch = window.fetch; + + window.fetch = async function(url, options) { + + // --- /api/doc-types → presentation 활성화 --- + if (typeof url === 'string' && url.includes('/api/doc-types') && !options) { + const resp = await _originalFetch(url, options); + const data = await resp.json(); + // presentation enabled로 변겜 + const pres = data.find(t => t.id === 'presentation'); + if (pres) { pres.enabled = true; pres.badge = ''; } + return { ok: true, json: async () => data }; + } + + // --- /api/generate-toc → 가짜 목찚 반환 --- + if (typeof url === 'string' && url.includes('/api/generate-toc')) { + console.log('[DEMO] 목찚 생성 읞터셉튞'); + await new Promise(r => setTimeout(r, 800)); + return { + ok: true, + json: async () => ({ + success: true, + toc_items: DEMO_TOC_ITEMS + }) + }; + } + + // --- /api/generate-report-from-toc → 데몚 HTML 반환 --- + if (typeof url === 'string' && url.includes('/api/generate-report-from-toc')) { + console.log('[DEMO] 볎고서 생성 읞터셉튞'); + + const docType = window.currentDocType || 'report'; + const htmlPath = _resolveHtmlPath(docType); + + try { + const resp = await _originalFetch(htmlPath); + const html = await resp.text(); + return { ok: true, json: async () => ({ success: true, html }) }; + } catch (e) { + console.error('[DEMO] HTML 로드 싀팚:', htmlPath, e); + return { ok: true, json: async () => ({ success: false, error: '데몚 HTML 로드 싀팚' }) }; + } + } + + + // --- ê·ž 왞: 원래 fetch --- + return _originalFetch(url, options); + }; + + console.log('[DEMO] ✅ 데몚 몚드 활성화'); +} + +// ===== HTML 겜로 결정 ===== +function _resolveHtmlPath(docType) { + if (docType === 'report' || (docType && docType.includes('볎고'))) { + return DEMO_HTML_MAP['report']; + } + if (docType === 'slide' || docType === 'ppt' || docType === 'presentation' || (docType && (docType.includes('발표') || docType.includes('slide')))) { + return DEMO_HTML_MAP['slide']; + } + if (docType === 'briefing' || (docType && docType.includes('Ʞ획'))) { + // briefing_1 vs briefing_2 판별: currentPageConfig 또는 Ʞ볞값 + const pageConfig = window.currentPageConfig || ''; + if (pageConfig === 'body-only') { + return DEMO_HTML_MAP['briefing_1']; + } + return DEMO_HTML_MAP['briefing_2']; + } + return DEMO_HTML_MAP['report']; +} + + +// ===== 묞서 유형 전환 팝업 ===== +async function demoConvertDocument(targetType) { + if (!DEMO_MODE) return false; + + const typeNames = { + 'report': '📄 볎고서', + 'briefing': '📋 Ʞ획서', + 'briefing_1': '📋 Ʞ획서 (1p)', + 'briefing_2': '📋 Ʞ획서 (2p)', + 'slide': '📊 발표자료', + 'ppt': '📊 발표자료' + }; + + const typeName = typeNames[targetType] || targetType; + + const overlay = document.createElement('div'); + overlay.id = 'demoConvertOverlay'; + overlay.innerHTML = ` +
        +
        + 🔄 + ${typeName} 변환 쀑 +
        +
        +
        ⏳원볞 윘텐잠 분석
        +
        ⏳묞서 구조 재섀계
        +
        ⏳${typeName} 형식 적용
        +
        ⏳레읎아웃 최적화
        +
        ⏳최종 퍌랔늬싱
        +
        +
        +
        +
        +
        쀀비 쀑...
        +
        + `; + document.body.appendChild(overlay); + + const steps = overlay.querySelectorAll('.demo-step'); + const progressBar = overlay.querySelector('#demoProgressBar'); + const statusEl = overlay.querySelector('#demoStatus'); + + const msgs = [ + '원볞 윘텐잠륌 분석하고 있습니닀...', + '묞서 구조륌 재섀계하고 있습니닀...', + `${typeName} 형식을 적용하고 있습니닀...`, + '레읎아웃을 최적화하고 있습니닀...', + '최종 퍌랔늬싱 쀑입니닀...' + ]; + + for (let i = 0; i < steps.length; i++) { + steps[i].querySelector('.demo-step-icon').textContent = '🔄'; + steps[i].classList.add('running'); + statusEl.textContent = msgs[i]; + progressBar.style.width = ((i + 1) / steps.length * 100) + '%'; + + await new Promise(r => setTimeout(r, 600 + Math.random() * 400)); + + steps[i].querySelector('.demo-step-icon').textContent = '✅'; + steps[i].classList.remove('running'); + steps[i].classList.add('done'); + } + + // 발표자료멎 16:9 비윚로 전환, 아니멎 A4 복원 + const a4Preview = document.getElementById('a4Preview'); + const a4Wrapper = document.getElementById('a4Wrapper'); + if (a4Preview && a4Wrapper) { + if (targetType === 'slide' || targetType === 'presentation') { + a4Preview.style.width = '1100px'; + a4Preview.style.height = '620px'; + a4Preview.style.aspectRatio = '16/9'; + a4Wrapper.style.width = '1100px'; + } else { + a4Preview.style.width = ''; + a4Preview.style.height = ''; + a4Preview.style.aspectRatio = ''; + a4Wrapper.style.width = ''; + } + } + + statusEl.textContent = '✅ 변환 완료!'; + await new Promise(r => setTimeout(r, 500)); + + // HTML 로드 & 프늬뷰 표시 + const htmlPath = _resolveHtmlPath(targetType); + try { + const resp = await fetch(htmlPath); + const html = await resp.text(); + + window.generatedHTML = html; + if (typeof generatedHTML !== 'undefined') generatedHTML = html; + + const placeholder = document.getElementById('placeholder'); + if (placeholder) placeholder.style.display = 'none'; + + const frame = document.getElementById('previewFrame'); + if (frame) { + frame.classList.add('active'); + frame.srcdoc = html; + setTimeout(setupIframeSelection, 500); + } + + const feedbackBar = document.getElementById('feedbackBar'); + if (feedbackBar) feedbackBar.classList.add('show'); + + if (typeof setStatus === 'function') setStatus('생성 완료', true); + } catch (e) { + console.error('[DEMO] 변환 HTML 로드 싀팚:', e); + } + + overlay.classList.add('fade-out'); + setTimeout(() => overlay.remove(), 300); + return true; +} + + +// ===== 팝업 CSS 죌입 (배지 없음) ===== +if (DEMO_MODE) { + const style = document.createElement('style'); + style.textContent = ` + #demoConvertOverlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + z-index: 99999; animation: demoFadeIn 0.2s ease; + } + #demoConvertOverlay.fade-out { animation: demoFadeOut 0.3s ease forwards; } + + .demo-convert-popup { + background: #1e1e2e; color: #e0e0e0; + border-radius: 16px; padding: 35px 40px; + width: 420px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); + border: 1px solid rgba(255,255,255,0.1); + } + .demo-convert-header { + font-size: 18pt; font-weight: 800; margin-bottom: 25px; + display: flex; align-items: center; gap: 12px; color: #fff; + } + .demo-convert-icon { font-size: 22pt; } + .demo-convert-steps { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; } + .demo-step { + display: flex; align-items: center; gap: 12px; + padding: 10px 14px; border-radius: 8px; + background: rgba(255,255,255,0.03); + font-size: 11pt; color: #d1d1d1; transition: all 0.3s ease; + } + .demo-step.running { background: rgba(59,130,246,0.15); color: #93c5fd; font-weight: 600; } + .demo-step.done { background: rgba(34,197,94,0.1); color: #86efac; } + .demo-step-icon { font-size: 14pt; min-width: 24px; text-align: center; } + .demo-convert-progress { + height: 4px; background: rgba(255,255,255,0.1); + border-radius: 2px; overflow: hidden; margin-bottom: 12px; + } + .demo-progress-bar { + height: 100%; width: 0%; + background: linear-gradient(90deg, #3b82f6, #22c55e); + border-radius: 2px; transition: width 0.5s ease; + } + .demo-convert-status { font-size: 10pt; color: #cfcfcf; text-align: center; } + + @keyframes demoFadeIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes demoFadeOut { from { opacity: 1; } to { opacity: 0; } } + `; + document.head.appendChild(style); +} + + +// ===== generate() 팚치 — 묞서 유형 전환 시 팝업 ===== +if (DEMO_MODE) { + let _lastDemoKey = null; + + + window.addEventListener('DOMContentLoaded', () => { + const _origGenerate = window.generate; + + if (typeof _origGenerate !== 'function') { + console.warn('[DEMO] generate() 핚수륌 찟을 수 없음 — 팚치 슀킵'); + return; + } + + window.generate = async function() { + const docType = window.currentDocType || 'report'; + const pageConfig = window.currentPageConfig || ''; + const demoKey = docType + '|' + pageConfig; + + // 읎믞 결곌 있윌멎 → 데몚 전환 + if (window.generatedHTML && window.generatedHTML.trim() !== '') { + // 완전히 같은 조합읎멎 원래 흐멄 + if (_lastDemoKey === demoKey) { + return _origGenerate(); + } + + let demoType = docType; + if (docType === 'presentation') demoType = 'slide'; + + await demoConvertDocument(demoType); + _lastDemoKey = demoKey; + return; + } + + // 첫 생성 → 원래 흐멄 (fetch 읞터셉터가 처늬) + _lastDemoKey = demoKey; + return _origGenerate(); + }; + + console.log('[DEMO] ✅ generate() 팚치 완료'); + }); +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/doc_type.js b/03. Code/geulbeot_10th/static/js/doc_type.js new file mode 100644 index 0000000..05566c6 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/doc_type.js @@ -0,0 +1,587 @@ + // ===== 묞서 유형 분석 ꎀ렚 ===== + const ANALYSIS_STEPS = [ + {id: 1, name: "묞서 파싱"}, + {id: 2, name: "레읎아웃 분석"}, + {id: 3, name: "맥띜 분석"}, + {id: 4, name: "구조 분석"}, + {id: 5, name: "템플늿 추출"}, + {id: 6, name: "최종 검슝"} + ]; + + + // ===== 묞서 유형 로드 ===== + async function loadDocTypes() { + const container = document.getElementById('docTypeList'); + container.classList.add('loading'); + + try { + const response = await fetch('/api/doc-types'); + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + docTypes = data; + renderDocTypeList(); + + // 첫 번짞 활성화된 유형 선택 + const firstEnabled = docTypes.find(t => t.enabled); + if (firstEnabled) { + selectDocType(firstEnabled.id); + } + + } catch (error) { + console.error('묞서 유형 로드 싀팚:', error); + container.innerHTML = '
        로드 싀팚
        '; + } finally { + container.classList.remove('loading'); + } + } + + // ===== 묞서 유형 늬슀튞 렌더링 ===== + function renderDocTypeList() { + const container = document.getElementById('docTypeList'); + + // Ʞ볞 유형곌 사용자 유형 분늬 + const defaultTypes = docTypes.filter(t => t.isDefault); + const userTypes = docTypes.filter(t => !t.isDefault); + + let html = defaultTypes.map(type => createDocTypeHTML(type)).join(''); + + if (userTypes.length > 0) { + html += '
        '; + html += userTypes.map(type => createDocTypeHTML(type)).join(''); + } + + container.innerHTML = html; + attachDocTypeEvents(); + } + + // ===== 개별 묞서 유형 HTML 생성 ===== + function createDocTypeHTML(type) { + const isSelected = currentDocType === type.id; + const isDisabled = !type.enabled; + + return ` +
        + + ${type.icon} ${type.name} + ${type.badge ? `${type.badge}` : ''} + ${!type.isDefault ? `` : ''} + +
        +
        + ${thumbnailTemplates[type.thumbnailType] || thumbnailTemplates.custom} +
        +
        ${type.name}
        +
        ${type.description || ''}
        +
        + ${(type.features || []).map(f => ` +
        + ${f.icon || '✓'} ${f.text || f} +
        + `).join('')} +
        +
        +
        + `; + } + + // ===== 묞서 유형 읎벀튞 연결 ===== + function attachDocTypeEvents() { + document.querySelectorAll('.doc-type-item').forEach(item => { + if (item.classList.contains('disabled')) return; + + // 큎늭 읎벀튞 + item.onclick = () => selectDocType(item.dataset.type); + + // 혞버 프늬뷰 읎벀튞 + const preview = item.querySelector('.doc-type-preview'); + if (preview) { + item.addEventListener('mouseenter', () => { + const rect = item.getBoundingClientRect(); + preview.style.top = (rect.top + rect.height / 2 - 150) + 'px'; + preview.style.left = (rect.left - 295) + 'px'; + preview.classList.add('show'); + }); + item.addEventListener('mouseleave', () => preview.classList.remove('show')); + } + }); + } + + // ===== 묞서 유형 선택 ===== + function selectDocType(typeId) { + const type = docTypes.find(t => t.id === typeId); + if (!type || !type.enabled) return; + + currentDocType = typeId; + + // 선택 상태 업데읎튞 + document.querySelectorAll('.doc-type-item').forEach(item => { + const isSelected = item.dataset.type === typeId; + item.classList.toggle('selected', isSelected); + const radio = item.querySelector('input[type="radio"]'); + if (radio) radio.checked = isSelected; + }); + + // 옵션 렌더링 + renderDocTypeOptions(type); + + // 버튌 텍슀튞 업데읎튞 (입력 상태도 반영) + const generateBtnText = document.getElementById('generateBtnText'); + const hasFolder = typeof folderPath !== 'undefined' && folderPath && folderPath.trim() !== ''; + const hasLinks = typeof referenceLinks !== 'undefined' && referenceLinks && referenceLinks.length > 0; + const hasHtml = typeof inputContent !== 'undefined' && inputContent && inputContent.trim() !== ''; + + if (type.generateFlow === 'draft-first' && (hasFolder || (hasLinks && hasHtml))) { + generateBtnText.textContent = '📋 목찚 확읞하Ʞ'; + } else { + generateBtnText.textContent = '🚀 생성하Ʞ'; + } + + console.log('묞서 유형 선택:', typeId); + } + + // ===== 묞서 유형별 옵션 렌더링 ===== + function renderDocTypeOptions(type) { + const container = document.getElementById('docTypeOptionsContainer'); + + if (!type.options) { + container.innerHTML = ''; + return; + } + + let html = ''; + + // 페읎지 구성 (Ʞ획서 - 새로욎 구조) + if (type.options.pageConfig) { + const config = type.options.pageConfig; + html += ` +
        +
        페읎지 구성
        +
        + ${config.choices.map((opt, idx) => ` +
        + + + ${opt.hasInput ? ` + + ${opt.inputSuffix || ''} + ` : ''} +
        + `).join('')} +
        +
        + `; + } + + // Ʞ졎 페읎지 옵션 (하위 혾환) + if (type.options.pageOptions) { + html += ` +
        +
        페읎지 구성
        +
        + ${type.options.pageOptions.map((opt, idx) => ` +
        + + +
        + `).join('')} +
        +
        + `; + } + + // 구성 요소 (볎고서) + if (type.options.components) { + html += ` +
        +
        볎고서 구성
        +
        + ${type.options.components.map(comp => ` +
        + + +
        + `).join('')} +
        +
        + `; + } + + // 슬띌읎드 수 (발표자료) + if (type.options.slideCount) { + html += ` +
        +
        슬띌읎드 수
        +
        + ${type.options.slideCount.map((opt, idx) => ` +
        + + +
        + `).join('')} +
        +
        + `; + } + + container.innerHTML = html; + } + + // ===== 페읎지 구성 선택 (새로욎 방식) ===== + var currentPageConfig = 'body-attach'; + let attachPageCount = 1; + + function selectPageConfig(value, idx) { + currentPageConfig = value; + + document.querySelectorAll('#docTypeOptionsContainer .option-item').forEach(item => { + const radio = item.querySelector('input[type="radio"][name="pageConfig"]'); + if (radio) { + const isSelected = radio.value === value; + item.classList.toggle('selected', isSelected); + radio.checked = isSelected; + } + }); + + // 첚부 페읎지 입력 활성화/비활성화 + const attachInput = document.getElementById('attachPages'); + if (attachInput) { + attachInput.disabled = (value === 'body-only'); + attachInput.style.opacity = (value === 'body-only') ? '0.5' : '1'; + } + } + + function updateAttachPages(value) { + attachPageCount = parseInt(value) || 1; + console.log('첚부 페읎지 수:', attachPageCount); + } + + // ===== 슬띌읎드 수 선택 ===== + function selectSlideCount(count) { + document.querySelectorAll('#docTypeOptionsContainer .option-item').forEach(item => { + const radio = item.querySelector('input[type="radio"][name="slideCount"]'); + if (radio) { + const isSelected = radio.value === count; + item.classList.toggle('selected', isSelected); + radio.checked = isSelected; + } + }); + } + + // 몚달 ì—Žêž° + function openDocTypeModal() { + resetDocTypeModal(); + document.getElementById('addDocTypeModal').classList.add('active'); + } + + // 몚달 ë‹«êž° + function closeAddDocTypeModal() { + document.getElementById('addDocTypeModal').classList.remove('active'); + resetDocTypeModal(); + } + + // 몚달 쎈Ʞ화 + function resetDocTypeModal() { + analysisResult = null; + + // Step 표시 쎈Ʞ화 + document.getElementById('docTypeStep1').style.display = 'block'; + document.getElementById('docTypeStep2').style.display = 'none'; + document.getElementById('docTypeStep3').style.display = 'none'; + + // 제목 쎈Ʞ화 + document.getElementById('addDocTypeModalTitle').textContent = '📄 묞서 유형 추가'; + + // 입력 쎈Ʞ화 + document.getElementById('newDocTypeName').value = ''; + document.getElementById('newDocTypeDesc').value = ''; + document.getElementById('newDocTypeFile').value = ''; + + // 버튌 쎈Ʞ화 + const footer = document.getElementById('docTypeModalFooter'); + footer.style.display = 'flex'; + + const actionBtn = document.getElementById('docTypeActionBtn'); + actionBtn.textContent = '분석 시작'; + actionBtn.onclick = startDocTypeAnalysis; + } + + // 분석 시작 + function startDocTypeAnalysis() { + const name = document.getElementById('newDocTypeName').value.trim(); + const file = document.getElementById('newDocTypeFile').files[0]; + + if (!name) { + alert('묞서 유형 읎늄을 입력핎죌섞요.'); + return; + } + if (!file) { + alert('샘플 묞서륌 선택핎죌섞요.'); + return; + } + + // Step 2로 전환 + document.getElementById('docTypeStep1').style.display = 'none'; + document.getElementById('docTypeStep2').style.display = 'block'; + document.getElementById('addDocTypeModalTitle').textContent = '🔄 묞서 분석 쀑...'; + + // 푾터 버튌 숚ꞰꞰ + document.getElementById('docTypeModalFooter').style.display = 'none'; + + // 진행 닚계 UI 생성 + renderAnalysisSteps(); + + // 분석 시작 + performAnalysis(name, file); + } + + // 진행 닚계 렌더링 + function renderAnalysisSteps() { + const container = document.getElementById('analysisSteps'); + container.innerHTML = ANALYSIS_STEPS.map(step => ` +
        + ⏳ + Step ${step.id}: ${step.name} + 대Ʞ +
        + `).join(''); + } + + // 닚계 상태 업데읎튞 (메시지 지원) + function updateAnalysisStep(stepId, status, message = '') { + const stepEl = document.getElementById(`analysisStep${stepId}`); + if (!stepEl) return; + + const iconEl = stepEl.querySelector('.step-icon'); + const statusEl = stepEl.querySelector('.step-status'); + + if (status === 'running') { + iconEl.textContent = '🔄'; + iconEl.classList.add('spinning'); + statusEl.textContent = message || '진행쀑...'; + stepEl.classList.add('running'); + } else if (status === 'done') { + iconEl.textContent = '✅'; + iconEl.classList.remove('spinning'); + statusEl.textContent = message || '완료'; + stepEl.classList.remove('running'); + stepEl.classList.add('done'); + } else if (status === 'error') { + iconEl.textContent = '❌'; + iconEl.classList.remove('spinning'); + statusEl.textContent = message || '싀팚'; + stepEl.classList.add('error'); + } + + // 진행률 업데읎튞 + const doneCount = document.querySelectorAll('.analysis-step.done').length; + const progress = Math.round((doneCount / ANALYSIS_STEPS.length) * 100); + document.getElementById('analysisProgressBar').style.width = progress + '%'; + document.getElementById('analysisProgressText').textContent = `${progress}% 완료`; + } + + // 분석 수행 (SSE 방식) + async function performAnalysis(name, file) { + console.log('🚀 performAnalysis 시작 (SSE 몚드)'); + + const formData = new FormData(); + formData.append('name', name); + formData.append('description', document.getElementById('newDocTypeDesc').value.trim()); + formData.append('file', file); + + // XMLHttpRequest로 SSE 연결 (FormData 전송) + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/doc-types/analyze-stream', true); + + let buffer = ''; + + xhr.onprogress = function() { + const newData = xhr.responseText.substring(buffer.length); + buffer = xhr.responseText; + + // SSE 메시지 파싱 + const lines = newData.split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.substring(6)); + handleSSEMessage(data); + } catch (e) { + console.warn('SSE 파싱 였류:', e); + } + } + } + }; + + xhr.onerror = function() { + console.error('❌ SSE 연결 였류'); + alert('서버 연결 였류'); + closeAddDocTypeModal(); + }; + + xhr.ontimeout = function() { + console.error('❌ SSE 타임아웃'); + alert('분석 시간 쎈곌'); + closeAddDocTypeModal(); + }; + + xhr.timeout = 120000; // 2분 + xhr.send(formData); + } + + // SSE 메시지 처늬 + function handleSSEMessage(data) { + console.log('📚 SSE:', data); + + switch(data.type) { + case 'progress': + updateAnalysisStep(data.step, data.status, data.message); + break; + + case 'result': + console.log('✅ 분석 완료!'); + analysisResult = data.data; + showAnalysisResult(data.data); + break; + + case 'error': + console.error('❌ 분석 였류:', data.error); + alert('분석 쀑 였류: ' + data.error.message); + closeAddDocTypeModal(); + break; + } + } + + // 분석 결곌 표시 (v2.0 맥띜 êž°ë°˜) + function showAnalysisResult(data) { + document.getElementById('docTypeStep2').style.display = 'none'; + document.getElementById('docTypeStep3').style.display = 'block'; + document.getElementById('addDocTypeModalTitle').textContent = '✅ 분석 완료'; + + // 푾터 버튌 복원 및 변겜 + const footer = document.getElementById('docTypeModalFooter'); + footer.style.display = 'flex'; + + const actionBtn = document.getElementById('docTypeActionBtn'); + actionBtn.textContent = '저장'; + actionBtn.onclick = saveAnalyzedDocType; + + // v2.0: 맥띜 정볎 + const context = data.context || {}; + const structure = data.structure || {}; + const config = data.config || {}; + + // 요앜 표시 + document.getElementById('analysisResultSummary').innerHTML = ` +
        유형: ${context.documentType || '?'}
        +
        페읎지: ${structure.pageEstimate || '?'}p
        +
        섹션: ${(structure.sectionGuides || structure.sections)?.length || '?'}개
        + `; + + // 상섞 결곌 표시 + const sections = structure.sectionGuides || structure.sections || []; + + document.getElementById('analysisResultToc').innerHTML = ` +
        +
        📋 묞서 맥띜
        +

        목적: ${context.purpose || '-'}

        +

        대상: ${context.audience || '-'}

        +

        톀: ${context.tone || '-'}

        +
        + +
        📐 묞서 구조
        +

        녌늬 흐멄: ${structure.logicFlow || '-'}

        + + ${sections.length > 0 ? ` +
          + ${sections.map(s => ` +
        • + ${s.name || s.title} + ${s.role || ''} +
        • + `).join('')} +
        + ` : ` +

        ⚠ 구조륌 파악하지 못했습니닀.

        + `} + `; + } + + // 분석 결곌 저장 + async function saveAnalyzedDocType() { + if (!analysisResult || !analysisResult.config) { + alert('저장할 분석 결곌가 없습니닀.'); + return; + } + + const savedName = analysisResult.config.name; // ← 믞늬 저장! + + const actionBtn = document.getElementById('docTypeActionBtn'); + actionBtn.disabled = true; + actionBtn.textContent = '저장 쀑...'; + + try { + const response = await fetch('/api/doc-types', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(analysisResult.config) + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + closeAddDocTypeModal(); + loadDocTypes(); + loadUserTemplates(); + setStatus(`묞서 유형 "${savedName}" 추가 완료`, true); // ← 저장한 값 사용! + + } catch (error) { + alert('저장 싀팚: ' + error.message); + } finally { + actionBtn.disabled = false; + actionBtn.textContent = '저장'; + } + } + + // ===== 묞서 유형 삭제 ===== + async function deleteDocType(typeId) { + if (!confirm('읎 묞서 유형을 삭제하시겠습니까?')) return; + + try { + const response = await fetch(`/api/doc-types/${typeId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('삭제 싀팚'); + } + + docTypes = docTypes.filter(t => t.id !== typeId); + renderDocTypeList(); + + // 삭제된 유형읎 선택되얎 있었윌멎 첫 번짞로 변겜 + if (currentDocType === typeId) { + const firstEnabled = docTypes.find(t => t.enabled); + if (firstEnabled) selectDocType(firstEnabled.id); + } + + setStatus('묞서 유형 삭제 완료', true); + + } catch (error) { + alert('삭제 였류: ' + error.message); + } + } diff --git a/03. Code/geulbeot_10th/static/js/domain_selector.js b/03. Code/geulbeot_10th/static/js/domain_selector.js new file mode 100644 index 0000000..4969328 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/domain_selector.js @@ -0,0 +1,288 @@ +/** + * domain_selector.js + * + * 도메읞 지식 선택 시슀템 + * - 폮더 섀정 직후 자동윌로 도메읞 선택 몚달 표시 + * - 첎크박슀로 도메읞 선택 → 선택된 .txt 합쳐서 서버 전달 + * - "섞부 분알별" 선택 시 하위 항목 펌칚 + */ + +// ===== 상태 ===== +let domainConfig = null; // 서버에서 로드한 config +let selectedDomains = []; // 선택된 도메읞 ID ë°°ì—Ž +let domainLoaded = false; + +// ===== 쎈Ʞ화 ===== +async function loadDomainConfig() { + try { + const resp = await fetch('/api/domain-config'); + if (!resp.ok) throw new Error('도메읞 섀정 로드 싀팚'); + domainConfig = await resp.json(); + domainLoaded = true; + console.log('[Domain] 섀정 로드 완료:', domainConfig.categories.length, '칎테고늬'); + } catch (e) { + console.error('[Domain] 섀정 로드 싀팚:', e); + // fallback: 빈 config + domainConfig = { categories: [] }; + } +} + +// 페읎지 로드 시 config 가젞였Ʞ +document.addEventListener('DOMContentLoaded', loadDomainConfig); + + +// ===== 몚달 ì—Žêž°/ë‹«êž° ===== + +function openDomainModal() { + if (!domainConfig || !domainConfig.categories) { + alert('도메읞 섀정을 불러였는 쀑입니닀. 잠시 후 닀시 시도핎죌섞요.'); + return; + } + renderDomainModal(); + document.getElementById('domainModal').style.display = 'flex'; +} + +function closeDomainModal() { + document.getElementById('domainModal').style.display = 'none'; +} + + +// ===== 몚달 렌더링 ===== + +function renderDomainModal() { + const container = document.getElementById('domainCategoryList'); + if (!container) return; + + container.innerHTML = ''; + + domainConfig.categories.forEach(cat => { + const catDiv = document.createElement('div'); + catDiv.className = 'domain-category'; + catDiv.dataset.id = cat.id; + + // 메읞 첎크박슀 행 + const isChecked = selectedDomains.includes(cat.id); + catDiv.innerHTML = ` + + `; + + // 하위 항목읎 있윌멎 서람 팹널 추가 + if (cat.children && cat.children.length > 0) { + const subPanel = document.createElement('div'); + subPanel.className = 'domain-sub-panel'; + subPanel.id = `sub_${cat.id}`; + subPanel.style.display = isChecked ? 'block' : 'none'; + + // 귞룹별로 묶Ʞ + const groups = {}; + cat.children.forEach(child => { + const g = child.group || 'Ʞ타'; + if (!groups[g]) groups[g] = []; + groups[g].push(child); + }); + + Object.entries(groups).forEach(([groupName, children]) => { + const groupDiv = document.createElement('div'); + groupDiv.className = 'domain-sub-group'; + groupDiv.innerHTML = `
        ${groupName}
        `; + + const chipsDiv = document.createElement('div'); + chipsDiv.className = 'domain-sub-chips'; + + children.forEach(child => { + const childChecked = selectedDomains.includes(child.id); + chipsDiv.innerHTML += ` + + `; + }); + + groupDiv.appendChild(chipsDiv); + subPanel.appendChild(groupDiv); + }); + + catDiv.appendChild(subPanel); + } + + container.appendChild(catDiv); + }); + + // 선택 요앜 업데읎튞 + updateDomainSummary(); +} + + +// ===== 선택 로직 ===== + +function toggleDomainCategory(catId, checked) { + const cat = domainConfig.categories.find(c => c.id === catId); + if (!cat) return; + + if (checked) { + // 칎테고늬 추가 + if (!selectedDomains.includes(catId)) { + selectedDomains.push(catId); + } + + // 하위 항목읎 있윌멎 서람 팹널 펌칚 + if (cat.children && cat.children.length > 0) { + const subPanel = document.getElementById(`sub_${catId}`); + if (subPanel) subPanel.style.display = 'block'; + const expand = document.getElementById(`expand_${catId}`); + if (expand) expand.classList.add('open'); + } + } else { + // 칎테고늬 제거 + selectedDomains = selectedDomains.filter(d => d !== catId); + + // 하위 항목도 전부 제거 + if (cat.children) { + cat.children.forEach(child => { + selectedDomains = selectedDomains.filter(d => d !== child.id); + }); + const subPanel = document.getElementById(`sub_${catId}`); + if (subPanel) subPanel.style.display = 'none'; + const expand = document.getElementById(`expand_${catId}`); + if (expand) expand.classList.remove('open'); + } + } + + renderDomainModal(); +} + +function toggleDomainChild(parentId, childId, checked) { + if (checked) { + if (!selectedDomains.includes(childId)) { + selectedDomains.push(childId); + } + } else { + selectedDomains = selectedDomains.filter(d => d !== childId); + } + + // 칩 UI 업데읎튞 + renderDomainModal(); +} + + +// ===== 선택 요앜 ===== + +function updateDomainSummary() { + const summaryEl = document.getElementById('domainSummaryText'); + if (!summaryEl) return; + + if (selectedDomains.length === 0) { + summaryEl.textContent = '선택된 도메읞읎 없습니닀. AI가 자동윌로 분알륌 판닚합니닀.'; + summaryEl.className = 'domain-summary-text empty'; + return; + } + + // 선택된 항목 읎늄 수집 + const names = []; + domainConfig.categories.forEach(cat => { + if (selectedDomains.includes(cat.id)) { + names.push(cat.label); + } + if (cat.children) { + cat.children.forEach(child => { + if (selectedDomains.includes(child.id)) { + names.push(child.label); + } + }); + } + }); + + summaryEl.textContent = names.join(', '); + summaryEl.className = 'domain-summary-text'; +} + + +// ===== 확읞 (서버에 전달) ===== + +async function submitDomainSelection() { + if (selectedDomains.length === 0) { + // 선택 없윌멎 step3 자동 분석 몚드 + closeDomainModal(); + updateDomainDisplay('자동 분석 (AI 판당)'); + return; + } + + try { + const resp = await fetch('/api/domain-combine', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selected: selectedDomains }) + }); + + const data = await resp.json(); + + if (data.success) { + closeDomainModal(); + + // 사읎드바에 선택 결곌 표시 + const names = data.selected_names || selectedDomains; + updateDomainDisplay(names.join(', ')); + + console.log('[Domain] 도메읞 지식 결합 완료:', data.combined_length, '자'); + } else { + alert('도메읞 지식 결합 싀팚: ' + (data.error || '알 수 없는 였류')); + } + } catch (e) { + console.error('[Domain] 서버 전달 싀팚:', e); + alert('서버 통신 였류가 발생했습니닀.'); + } +} + + +// ===== 사읎드바 표시 업데읎튞 ===== + +function updateDomainDisplay(text) { + const el = document.getElementById('domainDisplayText'); + if (el) { + el.textContent = text; + el.className = 'domain-display-text' + (text.includes('자동') ? ' auto' : ' selected'); + } +} + + +// ===== 전첎 선택/핎제 ===== + +function selectAllDomains() { + selectedDomains = []; + domainConfig.categories.forEach(cat => { + selectedDomains.push(cat.id); + if (cat.children) { + cat.children.forEach(child => selectedDomains.push(child.id)); + } + }); + renderDomainModal(); +} + +function clearAllDomains() { + selectedDomains = []; + renderDomainModal(); +} + + +// ===== 폮더 섀정 후 자동 혞출 ===== +// Ʞ졎 submitFolder() 핚수에서 성공 후 읎 핚수 혞출 +function onFolderSetComplete() { + // 앜간의 딜레읎 후 도메읞 선택 몚달 표시 + setTimeout(() => { + openDomainModal(); + }, 500); +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/editor.js b/03. Code/geulbeot_10th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
        + + + +
        + + + + +
        + + +
        +
        + A + +
        +
        + A + +
        + + +
        + + + +
        + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
        +
        +
        ▩ 표 삜입
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
        헀더낎용
        '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
        + +
        귞늌 섀명
        +
        `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
        '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
        + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
        + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_10th/static/js/export.js b/03. Code/geulbeot_10th/static/js/export.js new file mode 100644 index 0000000..1806d76 --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/export.js @@ -0,0 +1,72 @@ + // ===== 저장/출력 ===== + function saveHtml() { + if (!generatedHTML) { + alert('뚌저 묞서륌 생성핎죌섞요.'); + return; + } + + const frame = document.getElementById('previewFrame'); + const html = frame.contentDocument ? + '' + frame.contentDocument.documentElement.outerHTML : + generatedHTML; + + const blob = new Blob([html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `report_${new Date().toISOString().slice(0,10)}.html`; + a.click(); + URL.revokeObjectURL(url); + } + + async function exportHwp() { + if (!generatedHTML) { + alert('뚌저 묞서륌 생성핎죌섞요.'); + return; + } + + const frame = document.getElementById('previewFrame'); + const html = frame.contentDocument ? + '' + frame.contentDocument.documentElement.outerHTML : + generatedHTML; + + setStatus('HWP 변환 쀑...', true); + + try { + const response = await fetch('/export-hwp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + html: html, + doc_type: currentDocType, + style_grouping: true + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'HWP 변환 싀팚'); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `report_${new Date().toISOString().slice(0,10)}.hwp`; + a.click(); + URL.revokeObjectURL(url); + + setStatus('HWP 변환 완료', true); + + } catch (error) { + alert('HWP 변환 였류: ' + error.message); + setStatus('였류 발생', false); + } + } + + function printDoc() { + const frame = document.getElementById('previewFrame'); + if (frame.contentWindow) { + frame.contentWindow.print(); + } + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/generator.js b/03. Code/geulbeot_10th/static/js/generator.js new file mode 100644 index 0000000..c856faa --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/generator.js @@ -0,0 +1,484 @@ + function updateGenerateBtnText() { + const btnText = document.getElementById('generateBtnText'); + if (!btnText) return; + const hasFolder = folderPath && folderPath.trim() !== ''; + const hasLinks = referenceLinks.length > 0; + const hasHtml = inputContent && inputContent.trim() !== ''; + + if (hasFolder || (hasLinks && hasHtml)) { + btnText.textContent = '📋 목찚 확읞하Ʞ'; + } else { + btnText.textContent = '🚀 생성하Ʞ'; + } + } + + async function generate() { + const type = docTypes.find(t => t.id === currentDocType); + if (!type) return; + + // ★ 입력 타입 자동 판별 + const hasFolder = folderPath && folderPath.trim() !== ''; + const hasLinks = referenceLinks.length > 0; + const hasHtml = inputContent && inputContent.trim() !== ''; + + if (hasFolder || (hasLinks && hasHtml)) { + // 입력 1,2,3: 폮더/링크 → 파읎프띌읞 → 목찚 → 생성 + await generateDraft(); + } else if (hasHtml) { + // 입력 4: HTML → 형식 변환 + await generateBriefing(); + } else { + alert('뚌저 폮더 위치, ì°žê³  링크, 또는 HTML을 입력핎죌섞요.'); + } + } + + async function generateBriefing() { + if (!inputContent && !folderPath && referenceLinks.length === 0) { + alert('뚌저 폮더 위치, ì°žê³  링크, 또는 HTML을 입력핎죌섞요.'); + return; + } + + const btn = document.getElementById('generateBtn'); + const btnText = document.getElementById('generateBtnText'); + const spinner = document.getElementById('generateSpinner'); + + btn.disabled = true; + btnText.textContent = '생성 쀑...'; + spinner.style.display = 'block'; + resetSteps(); + updateStep(0, 'done'); + setStatus('생성 쀑...', true); + + try { + for (let i = 1; i <= 7; i++) { + updateStep(i, 'running'); + await new Promise(r => setTimeout(r, 200)); + updateStep(i, 'done'); + } + + updateStep(8, 'running'); + + // 페읎지 구성 값 가젞였Ʞ + let pageOption = '2'; // Ʞ볞값 + if (currentPageConfig === 'body-only') { + pageOption = '1'; + } else if (currentPageConfig === 'body-attach') { + pageOption = String(1 + attachPageCount); // 볞묞 1p + 첚부 np + } + + const formData = new FormData(); + formData.append('content', inputContent); + formData.append('doc_type', currentDocType); + formData.append('page_option', pageOption); + formData.append('attach_pages', attachPageCount); + formData.append('instruction', document.getElementById('globalInstructionInput').value); + formData.append('write_mode', currentWriteMode); + formData.append('folder_path', folderPath || ''); + formData.append('links', JSON.stringify(referenceLinks || [])); + + const response = await fetch('/generate', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + updateStep(8, 'done'); + updateStep(9, 'running'); + await new Promise(r => setTimeout(r, 300)); + updateStep(9, 'done'); + + if (data.success && data.html) { + generatedHTML = data.html; + document.getElementById('placeholder').style.display = 'none'; + const frame = document.getElementById('previewFrame'); + frame.classList.add('active'); + frame.srcdoc = generatedHTML; + setTimeout(setupIframeSelection, 500); + document.getElementById('feedbackBar').classList.add('show'); + setStatus('생성 완료', true); + } + + } catch (error) { + alert('생성 였류: ' + error.message); + setStatus('였류 발생', false); + for (let i = 0; i <= 9; i++) { + const item = document.querySelector(`.step-item[data-step="${i}"]`); + if (item && item.classList.contains('running')) { + updateStep(i, 'error'); + } + } + } finally { + btn.disabled = false; + btnText.textContent = '🚀 생성하Ʞ'; + spinner.style.display = 'none'; + } + } + + async function generateDraft() { + if (!folderPath && !inputContent && referenceLinks.length === 0) { + alert('뚌저 폮더 위치, ì°žê³  링크, 또는 HTML을 입력핎죌섞요.'); + return; + } + + const btn = document.getElementById('generateBtn'); + const btnText = document.getElementById('generateBtnText'); + const spinner = document.getElementById('generateSpinner'); + + btn.disabled = true; + btnText.textContent = '분석 쀑...'; + spinner.style.display = 'block'; + resetSteps(); + updateStep(0, 'done'); + setStatus('목찚 생성 쀑...', true); + + try { + const hasFolder = folderPath && folderPath.trim() !== ''; + const hasLinks = referenceLinks && referenceLinks.length > 0; + const hasHtml = inputContent && inputContent.trim() !== ''; + const needsPipeline = hasFolder || (hasLinks && hasHtml); + + if (needsPipeline) { + // 파읎프띌읞 몚드: 서버에서 step3~7 싀행 + updateStep(1, 'running'); + const tocResp = await fetch('/api/generate-toc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder_path: folderPath, + links: referenceLinks, + domain_selected: selectedDomains && selectedDomains.length > 0, + write_mode: currentWriteMode + }) + }); + const tocData = await tocResp.json(); + + if (!tocData.success) { + throw new Error(tocData.error || '목찚 생성 싀팚'); + } + + // step 1~7 완료 표시 + for (let i = 1; i <= 7; i++) updateStep(i, 'done'); + + // 목찚 애니메읎션 표시 + if (tocData.toc_items && typeof displayTocWithAnimation === 'function') { + displayTocWithAnimation(tocData.toc_items); + } + } else { + // HTML 입력 몚드: 가짜 진행 (Ʞ졎) + for (let i = 1; i <= 7; i++) { + updateStep(i, 'running'); + await new Promise(r => setTimeout(r, 500)); + updateStep(i, 'done'); + } + } + + document.getElementById('feedbackBar').classList.remove('show'); + + setStatus('목찚 생성 완료 - 확읞 후 승읞핎죌섞요', true); + + } catch (error) { + alert('목찚 생성 였류: ' + error.message); + setStatus('였류 발생', false); + } finally { + btn.disabled = false; + btnText.textContent = '📋 목찚 확읞하Ʞ'; + spinner.style.display = 'none'; + } + } + + // ===== 목찚 애니메읎션 표시 ===== + function displayTocWithAnimation(tocItems) { + const tocDisplay = document.getElementById('tocDisplayArea'); + if (!tocDisplay) return; + + tocDisplay.style.display = 'block'; + tocDisplay.innerHTML = ''; + + tocItems.forEach((item, index) => { + const tocEl = document.createElement('div'); + tocEl.className = 'toc-anim-item'; + tocEl.style.opacity = '0'; + tocEl.style.transform = 'translateY(12px)'; + + // 킀워드 HTML 생성 + const keywordsHtml = (item.keywords || []) + .map(k => `${k}`) + .join(''); + + const contentsHtml = (item.contents || []) + .map(c => `
        ${c}
        `) + .join(''); + + tocEl.innerHTML = ` +
        ${item.num || (index + 1) + '장'}
        +
        ${item.title}
        + ${item.guide ? `
        ${item.guide}
        ` : ''} + ${contentsHtml ? `
        ${contentsHtml}
        ` : ''} + ${keywordsHtml ? `
        ${keywordsHtml}
        ` : ''} + `; + + tocDisplay.appendChild(tocEl); + + // 순찚적윌로 나타낹 + setTimeout(() => { + tocEl.style.transition = 'all 0.4s ease'; + tocEl.style.opacity = '1'; + tocEl.style.transform = 'translateY(0)'; + }, 300 + (index * 600)); + }); + + // 몚든 항목 표시 후 액션바 표시 + const totalDelay = 300 + (tocItems.length * 600) + 400; + setTimeout(() => { + document.getElementById('tocActionBar').classList.add('show'); + document.getElementById('feedbackBar').classList.remove('show'); + setStatus('목찚 생성 완료 - 확읞 후 승읞핎죌섞요', true); + }, totalDelay); + } + + // ===== 목찚 표시 쎈Ʞ화 ===== + function hideTocDisplay() { + const tocDisplay = document.getElementById('tocDisplayArea'); + if (tocDisplay) { + tocDisplay.style.display = 'none'; + tocDisplay.innerHTML = ''; + } + } + + function editToc() { + const tocContainer = document.getElementById('tocContainer'); + if (tocContainer) { + tocContainer.contentEditable = true; + tocContainer.style.outline = '2px solid var(--ui-accent)'; + } + + // 애니메읎션 목찚도 펞집 가능하게 + const tocDisplay = document.getElementById('tocDisplayArea'); + if (tocDisplay && tocDisplay.style.display !== 'none') { + tocDisplay.contentEditable = true; + tocDisplay.style.outline = '2px solid var(--ui-accent)'; + } + + setStatus('목찚 펞집 몚드 - 직접 수정 가능합니닀', true); + } + + async function approveToc() { + const btn = document.getElementById('approveBtn'); + btn.disabled = true; + btn.textContent = '⏳ 생성 쀑...'; + + document.getElementById('tocActionBar').classList.remove('show'); + hideTocDisplay(); + setStatus('최종 묞서 생성 쀑...', true); + + // 진행률 표시 + const progressArea = document.getElementById('genProgressArea'); + if (progressArea) { + progressArea.style.display = 'block'; + } + + try { + // Step 8: 윘텐잠 생성 + updateStep(8, 'running'); + updateGenProgress(10, '📚 RAG 검색 쀑...'); + + await generateReport(); + + updateGenProgress(100, '✅ 생성 완료'); + updateStep(8, 'done'); + updateStep(9, 'running'); + await new Promise(r => setTimeout(r, 300)); + updateStep(9, 'done'); + + // 진행률 숚ꞰꞰ + setTimeout(() => { + if (progressArea) progressArea.style.display = 'none'; + }, 1000); + + } catch (error) { + alert('묞서 생성 였류: ' + error.message); + setStatus('였류 발생', false); + if (progressArea) progressArea.style.display = 'none'; + } finally { + btn.disabled = false; + btn.textContent = '✅ 승읞 & 생성하Ʞ'; + } + } + + // ===== 생성 진행률 업데읎튞 ===== + function updateGenProgress(percent, message) { + const bar = document.getElementById('genProgressBar'); + const text = document.getElementById('genProgressText'); + if (bar) bar.style.width = percent + '%'; + if (text) text.textContent = message; + } + + // === generateReport() 핚수 전첎 교첎 === + async function generateReport() { + const coverCheck = document.getElementById('cover'); + const tocCheck = document.getElementById('toc'); + const dividerCheck = document.getElementById('divider'); + + updateGenProgress(20, '📋 목찚 êž°ë°˜ 구조화 쀑...'); + + // ★ 입력1,2,3음 때 (폮더/링크 있윌멎) + const hasFolder = folderPath && folderPath.trim() !== ''; + const hasLinks = referenceLinks && referenceLinks.length > 0; + const hasHtml = inputContent && inputContent.trim() !== ''; + if (hasFolder || (hasLinks && hasHtml)) { + // 펞집된 목찚 수집 + const editedToc = collectEditedToc(); + + const response = await fetch('/api/generate-report-from-toc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + toc_items: editedToc, + write_mode: currentWriteMode, + instruction: document.getElementById('globalInstructionInput').value, + cover: coverCheck ? coverCheck.checked : true, + toc: tocCheck ? tocCheck.checked : true, + }) + }); + + updateGenProgress(60, '📝 볞묞 작성 쀑...'); + const data = await response.json(); + + if (data.error) throw new Error(data.error); + + updateGenProgress(85, '🎚 레읎아웃 조늜 쀑...'); + await new Promise(r => setTimeout(r, 500)); + + if (data.success && data.html) { + generatedHTML = data.html; + showGeneratedHtml(generatedHTML); + } + + } else { + const response = await fetch('/generate-report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: inputContent, + folder_path: folderPath, + cover: coverCheck ? coverCheck.checked : true, + toc: tocCheck ? tocCheck.checked : true, + divider: dividerCheck ? dividerCheck.checked : false, + instruction: document.getElementById('globalInstructionInput').value + }) + }); + + updateGenProgress(60, '📝 볞묞 작성 쀑...'); + const data = await response.json(); + if (data.error) throw new Error(data.error); + + updateGenProgress(85, '🎚 레읎아웃 조늜 쀑...'); + await new Promise(r => setTimeout(r, 500)); + + if (data.success && data.html) { + generatedHTML = data.html; + showGeneratedHtml(generatedHTML); + } + } + } + + // ★ 공통 HTML 표시 핚수 추가 + function showGeneratedHtml(html) { + document.getElementById('placeholder').style.display = 'none'; + const frame = document.getElementById('previewFrame'); + frame.classList.add('active'); + frame.srcdoc = html; + setTimeout(setupIframeSelection, 500); + document.getElementById('feedbackBar').classList.add('show'); + updateGenProgress(100, '✅ 생성 완료'); + setStatus('생성 완료', true); + } + + // ★ 펞집된 목찚 수집 핚수 추가 + function collectEditedToc() { + const tocDisplay = document.getElementById('tocDisplayArea'); + if (!tocDisplay) return []; + + const items = []; + tocDisplay.querySelectorAll('.toc-anim-item').forEach(el => { + items.push({ + num: el.querySelector('.toc-anim-num')?.textContent || '', + title: el.querySelector('.toc-anim-title')?.textContent || '', + guide: el.querySelector('.toc-anim-guide')?.textContent || '', + keywords: Array.from(el.querySelectorAll('.toc-anim-keyword')) + .map(k => k.textContent) + }); + }); + return items; + } + + + // ===== 플드백 ===== + async function submitFeedback() { + const feedback = document.getElementById('feedbackInput').value.trim(); + if (!feedback) { + alert('수정 낎용을 입력핎죌섞요.'); + return; + } + + if (!generatedHTML) { + alert('뚌저 묞서륌 생성핎죌섞요.'); + return; + } + + const btn = document.getElementById('feedbackBtn'); + const btnText = document.getElementById('feedbackBtnText'); + const spinner = document.getElementById('feedbackSpinner'); + + btn.disabled = true; + btnText.textContent = '⏳ 수정 쀑...'; + spinner.style.display = 'inline-block'; + + setStatus('수정 쀑...', true); + + try { + const response = await fetch('/refine', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: feedback, + current_html: generatedHTML + }) + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + if (data.success && data.html) { + generatedHTML = data.html; + document.getElementById('previewFrame').srcdoc = generatedHTML; + document.getElementById('feedbackInput').value = ''; + + setTimeout(setupIframeSelection, 500); + setStatus('수정 완료', true); + } + + } catch (error) { + alert('수정 였류: ' + error.message); + setStatus('였류 발생', false); + } finally { + btn.disabled = false; + btnText.textContent = '🔄 수정 반영'; + spinner.style.display = 'none'; + } + } + + function regenerate() { + if (confirm('현재 결곌륌 버늬고 닀시 생성하시겠습니까?')) { + hideTocDisplay(); + generate(); + } + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/modals.js b/03. Code/geulbeot_10th/static/js/modals.js new file mode 100644 index 0000000..89b454d --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/modals.js @@ -0,0 +1,135 @@ + // ===== modals.js ë§š 첫쀄에 추가 ===== + function updateDomainSectionVisibility() { + const section = document.getElementById('domainSection'); + if (!section) return; + const hasFolder = typeof folderPath !== 'undefined' && folderPath && folderPath.trim() !== ''; + const hasLinks = typeof referenceLinks !== 'undefined' && referenceLinks && referenceLinks.length > 0; + section.style.display = (hasFolder || hasLinks) ? 'block' : 'none'; + } + // ===== 폮더 몚달 ===== + function openFolderModal() { + document.getElementById('folderModal').classList.add('active'); + document.getElementById('folderPath').focus(); + } + + function closeFolderModal() { + document.getElementById('folderModal').classList.remove('active'); + } + + function submitFolder() { + const path = document.getElementById('folderPath').value.trim(); + if (!path) { + alert('폮더 겜로륌 입력핎죌섞요.'); + return; + } + + folderPath = document.getElementById('folderPath').value; + closeFolderModal(); + updateInputStatus(); + setStatus('폮더 겜로 섀정됚', true); + + // 서버에 폮더 검토 요청 + fetch('/api/check-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_path: folderPath }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + document.getElementById('totalCount').textContent = data.total + '개'; + document.getElementById('okCount').textContent = data.ok + '개 ✓'; + document.getElementById('unknownCount').textContent = data.unknown + '개'; + + // 믞확읞 파음 목록 + const listEl = document.getElementById('unknownFilesList'); + if (listEl && data.unknown_list) { + listEl.innerHTML = data.unknown_list + .map(name => `
        ${name}
        `) + .join(''); + } + } else { + document.getElementById('totalCount').textContent = '였류'; + alert('폮더 검토 싀팚: ' + (data.error || '')); + } + }) + .catch(err => { + document.getElementById('totalCount').textContent = '였류'; + console.error('[Folder]', err); + }); + updateDomainSectionVisibility(); + if (typeof onFolderSetComplete === 'function') { + onFolderSetComplete(); + } + } + + function toggleUnknownFiles() { + document.getElementById('unknownFilesBox').classList.toggle('show'); + } + + function openFolder() { + alert('폮더 엎Ʞ는 Engine읎 싀행 쀑음 때만 가능합니닀.'); + } + + // ===== 링크 몚달 ===== + function openLinkModal() { + document.getElementById('linkModal').classList.add('active'); + } + + function closeLinkModal() { + document.getElementById('linkModal').classList.remove('active'); + } + + function addLinkInput() { + const container = document.getElementById('linkInputList'); + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'link-input'; + input.placeholder = 'https://...'; + input.style = 'width:100%; padding:10px; border-radius:6px; border:1px solid var(--ui-border); background:var(--ui-bg); color:var(--ui-text); font-size:12px; margin-bottom:8px;'; + container.appendChild(input); + } + + function submitLinks() { + const inputs = document.querySelectorAll('#linkInputList .link-input'); + referenceLinks = []; + inputs.forEach(input => { + const val = input.value.trim(); + if (val) referenceLinks.push(val); + }); + + closeLinkModal(); + updateInputStatus(); + + if (referenceLinks.length > 0) { + setStatus(`ì°žê³  링크 ${referenceLinks.length}개 섀정됚`, true); + } + updateDomainSectionVisibility(); + if (typeof onFolderSetComplete === 'function') { + onFolderSetComplete(); + } + } + + // ===== HTML 몚달 ===== + function openHtmlModal() { + document.getElementById('htmlModal').classList.add('active'); + document.getElementById('htmlContent').focus(); + } + + function closeHtmlModal() { + document.getElementById('htmlModal').classList.remove('active'); + } + + function submitHtml() { + const html = document.getElementById('htmlContent').value.trim(); + if (!html) { + alert('HTML을 입력핎죌섞요.'); + return; + } + + inputContent = html; + closeHtmlModal(); + updateInputStatus(); + setStatus('HTML 입력 완료', true); + updateDomainSectionVisibility(); + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/template.js b/03. Code/geulbeot_10th/static/js/template.js new file mode 100644 index 0000000..41dae8c --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/template.js @@ -0,0 +1,189 @@ + // ===== 템플늿 몚달 ===== + function openTemplateModal() { + document.getElementById('templateModal').classList.add('active'); + document.getElementById('templateNameInput').value = ''; + removeTemplateFile(); + } + + function closeTemplateModal() { + document.getElementById('templateModal').classList.remove('active'); + } + + function handleTemplateFile(input) { + if (input.files.length > 0) { + const file = input.files[0]; + const validExtensions = ['.hwpx', '.hwp', '.pdf']; + const ext = '.' + file.name.split('.').pop().toLowerCase(); + + if (!validExtensions.includes(ext)) { + alert('지원하지 않는 파음 형식입니닀.\n(HWPX, HWP, PDF만 지원)'); + return; + } + + selectedTemplateFile = file; + document.getElementById('templateFileName').textContent = file.name; + document.getElementById('templateFileInfo').classList.add('show'); + document.getElementById('templateDropzone').style.display = 'none'; + + const nameInput = document.getElementById('templateNameInput'); + if (!nameInput.value.trim()) { + nameInput.value = file.name.replace(/\.[^/.]+$/, ''); + } + + updateTemplateSubmitBtn(); + } + } + + function removeTemplateFile() { + selectedTemplateFile = null; + document.getElementById('templateFileInput').value = ''; + document.getElementById('templateFileInfo').classList.remove('show'); + document.getElementById('templateDropzone').style.display = 'block'; + updateTemplateSubmitBtn(); + } + + function updateTemplateSubmitBtn() { + const nameInput = document.getElementById('templateNameInput'); + const btn = document.getElementById('templateSubmitBtn'); + btn.disabled = !selectedTemplateFile || !nameInput.value.trim(); + } + + async function submitTemplate() { + const name = document.getElementById('templateNameInput').value.trim(); + if (!name || !selectedTemplateFile) return; + + const btn = document.getElementById('templateSubmitBtn'); + const spinner = document.getElementById('templateSpinner'); + const text = document.getElementById('templateSubmitText'); + + btn.disabled = true; + spinner.style.display = 'inline-block'; + text.textContent = '분석 쀑...'; + + try { + const formData = new FormData(); + formData.append('name', name); + formData.append('file', selectedTemplateFile); + + const response = await fetch('/analyze-template', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + userTemplates.push(data.meta); + renderUserTemplates(); + closeTemplateModal(); + + setStatus(`템플늿 "${name}" 추가 완료`, true); + + } catch (error) { + alert('템플늿 분석 였류: ' + error.message); + } finally { + btn.disabled = false; + spinner.style.display = 'none'; + text.textContent = '✹ 분석 및 추가'; + } + } + + function selectTemplate(templateId) { + currentTemplate = templateId; + + document.querySelectorAll('.template-item').forEach(item => { + item.classList.remove('selected'); + const radio = item.querySelector('input[type="radio"]'); + if (radio) radio.checked = false; + }); + + const selectedItem = document.querySelector(`.template-item[data-template="${templateId}"]`); + if (selectedItem) { + selectedItem.classList.add('selected'); + const radio = selectedItem.querySelector('input[type="radio"]'); + if (radio) radio.checked = true; + } + + const elementsPanel = document.getElementById('templateElementOptions'); + if (templateId === 'default') { + elementsPanel.style.display = 'none'; + } else { + showTemplateElements(templateId); + elementsPanel.style.display = 'block'; + } + } + + function showTemplateElements(templateId) { + const template = userTemplates.find(t => t.id === templateId); + if (!template || !template.elements) return; + + const container = document.querySelector('#templateElementOptions .elements-list'); + container.innerHTML = template.elements.map(el => ` + + `).join(''); + } + + function toggleTemplateElement(templateId, elementType, checked) { + if (!templateElements[templateId]) { + templateElements[templateId] = {}; + } + templateElements[templateId][elementType] = checked; + } + + function renderUserTemplates() { + const container = document.getElementById('userTemplatesListNew'); + + if (userTemplates.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = userTemplates.map(tpl => ` +
        + + 📑 ${tpl.name} + +
        + `).join(''); + } + + async function loadUserTemplates() { + try { + const response = await fetch('/api/templates'); + const data = await response.json(); + if (Array.isArray(data)) { + userTemplates = data; + renderUserTemplates(); + } + } catch (error) { + console.error('템플늿 목록 로드 싀팚:', error); + } + } + + async function deleteTemplate(templateId) { + if (!confirm('읎 템플늿을 삭제하시겠습니까?')) return; + + try { + await fetch(`/api/templates/${templateId}`, { method: 'DELETE' }); + userTemplates = userTemplates.filter(t => t.id !== templateId); + renderUserTemplates(); + + if (currentTemplate === templateId) { + selectTemplate('default'); + } + + setStatus('템플늿 삭제 완료', true); + } catch (error) { + alert('삭제 였류: ' + error.message); + } + } \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/js/ui.js b/03. Code/geulbeot_10th/static/js/ui.js new file mode 100644 index 0000000..89d64ee --- /dev/null +++ b/03. Code/geulbeot_10th/static/js/ui.js @@ -0,0 +1,91 @@ + // ===== 상태 표시 ===== + function setStatus(msg, connected = false) { + document.getElementById('statusMessage').textContent = msg; + document.getElementById('statusDot').classList.toggle('connected', connected); + } + + // ===== 입력 상태 업데읎튞 ===== + function updateInputStatus() { + const hasFolder = folderPath.length > 0; + const hasLinks = referenceLinks.length > 0; + const hasHtml = inputContent.length > 0; + + const pathEl = document.getElementById('folderPathDisplay'); + if (hasFolder) { + pathEl.textContent = folderPath; + pathEl.classList.remove('empty'); + } else { + pathEl.textContent = '폮더 겜로가 섀정되지 않음'; + pathEl.classList.add('empty'); + } + + document.getElementById('linkCount').textContent = referenceLinks.length + '개'; + + const htmlStatus = document.getElementById('htmlInputStatus'); + if (hasHtml) { + htmlStatus.textContent = '✓ 입력됚'; + htmlStatus.classList.add('ok'); + } else { + htmlStatus.textContent = '없음'; + htmlStatus.classList.remove('ok'); + } + + const canGenerate = hasHtml || hasFolder || hasLinks; + document.getElementById('generateBtn').disabled = !canGenerate; + // 버튌 텍슀튞: 폮더/링크 있윌멎 목찚 확읞, 아니멎 생성하Ʞ + const btnText = document.getElementById('generateBtnText'); + if (btnText) { + if (hasFolder || (hasLinks && hasHtml)) { + btnText.textContent = '📋 목찚 확읞하Ʞ'; + } else { + btnText.textContent = '🚀 생성하Ʞ'; + } + } + + if (canGenerate) { + updateStep(0, 'done'); + } else { + updateStep(0, 'pending'); + } + } + + // ===== 진행 상태 ===== + function updateStep(num, status) { + const item = document.querySelector(`.step-item[data-step="${num}"]`); + if (!item) return; + item.classList.remove('pending', 'running', 'done', 'error'); + item.classList.add(status); + item.querySelector('.status').textContent = + status === 'pending' ? '○' : + status === 'running' ? '◐' : + status === 'done' ? '●' : '✕'; + } + + function resetSteps() { + for (let i = 0; i <= 9; i++) { + updateStep(i, 'pending'); + } + } + + // ===== 쀌 ===== + function setZoom(value) { + currentZoom = parseInt(value); + document.getElementById('a4Wrapper').style.transform = `scale(${currentZoom / 100})`; + } + + // ===== 작성 방식 선택 ===== + function selectWriteMode(mode) { + currentWriteMode = mode; + + document.querySelectorAll('.write-mode-tab').forEach(tab => { + tab.classList.remove('selected'); + tab.querySelector('input[type="radio"]').checked = false; + }); + + const selectedTab = document.querySelector(`.write-mode-tab input[value="${mode}"]`); + if (selectedTab) { + selectedTab.checked = true; + selectedTab.closest('.write-mode-tab').classList.add('selected'); + } + } + diff --git a/03. Code/geulbeot_10th/static/result/brief_1.html b/03. Code/geulbeot_10th/static/result/brief_1.html new file mode 100644 index 0000000..3520538 --- /dev/null +++ b/03. Code/geulbeot_10th/static/result/brief_1.html @@ -0,0 +1,315 @@ + + + + + 한국 토목 엔지니얎링의 딜레마 - Ʞ획서 + + + +
        + + +
        +

        한국 토목 엔지니얎링의 딜레마

        +
        +
        + +
        +
        +
        "AutoCAD 독점 구조의 Ʞ술적·겜제적 늬슀크륌 분석하고,
        3D/BIM 시대에 대응하는 닚계적 전환 로드맵을 제시"
        +
        + +
        +
        추진 배겜 및 전략적 목적
        +
          +
        • 독점 시장 구조 타파: 국낎 토목 섀계 시장의 AutoCAD 점유윚 85% 읎상, 연간 띌읎선슀 비용(읞당 280만원) 지속 읞상윌로 겜제적 부닎 심화.
        • +
        • Ʞ술 종속 핎소: 비공개 독점 포맷(.dwg) êž°ë°˜ 성곌묌 ꎀ늬로 데읎터 죌권 및 지식재산권 제앜 발생, 장Ʞ적 Ʞ술 자늜 저핎.
        • +
        • 토목 특성 대응: AutoCAD는 걎축 직교 첎계에 최적화되얎 토목 분알의 비정형 형상(지형·비탈멎) 섀계에 구조적 한계 졎재.
        • +
        • 3D/BIM 전환 대비: 잡량→섀계→시공 간 데읎터 닚절 묞제 핎결 및 개방형 포맷(IFC/LandXML) êž°ë°˜ 워크플로우 구축 필요.
        • +
        +
        + +
        +
        핵심 요구사항 및 찚별화 전략
        +
          +
        • 비정형 지형 몚덞링: 토공량 자동 산출 및 3찚원 지형 분석 Ʞ능을 통핎 토목 고유 업묎의 디지턞 전환 가속.
        • +
        • 원슀톱 데읎터 연계: 잡량·섀계·시공 전죌Ʞ 데읎터 흐늄을 닚절 없읎 연결하여 수작업 변환 였류 제거.
        • +
        • 개방형 포맷 전환: IFC/LandXML 국제 표쀀 포맷 채택윌로 특정 소프튞웚얎 종속성 탈플 및 데읎터 죌권 확볎.
        • +
        • 국산 솔룚션 육성: 국낎 싀정에 맞는 토목 전용 솔룚션 개발을 통한 Ʞ술 자늜 및 장Ʞ적 비용 절감 싀현.
        • +
        +
        + +
        +
        닚계별 전환 로드맵
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        닚계핵심 수행 전략적용 Ʞ술 / êž°ê°„
        현황 진닚부서별 CAD 사용 현황 및 비용 구조 분석, 늬슀크 도출Q1 '26 / 낎부 싀태 조사
        대안 PoCCivil 3D, OpenRoads, 국산 솔룚션 대상 Ʞ능 비교, 시범 적용Q2 '26 / 벀치마크
        파음럿 적용선정 솔룚션 ì‹€ 프로젝튞 투입, Ʞ졎 워크플로우 혞환성 검슝Q3 '26 / 시범 프로젝튞
        전사 확산교육 첎계 수늜, 띌읎선슀 전환, 전 부서 닚계적 례아웃Q4 '26 / 전사 배포
        +
        + +
        +
        Ʞ대 횚곌 및
        향후 계획
        +
        + • 띌읎선슀 비용 연간 30% 읎상 절감 및 특정 벀더 종속 늬슀크 핎소
        + • 개방형 포맷 전환을 통한 데읎터 죌권 확볎 및 장Ʞ적 Ʞ술 자늜 êž°ë°˜ 마렚
        + • 3D/BIM êž°ë°˜ 원슀톱 워크플로우 구축윌로 섀계 생산성 향상 및 였류 최소화 +
        +
        +
        + +
        + + + +
        +
        + + \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/result/brief_2.html b/03. Code/geulbeot_10th/static/result/brief_2.html new file mode 100644 index 0000000..68d40b8 --- /dev/null +++ b/03. Code/geulbeot_10th/static/result/brief_2.html @@ -0,0 +1,427 @@ + + + + + 한국 토목 엔지니얎링의 딜레마 - Ʞ획서 + + + + + +
        + + +
        +

        한국 토목 엔지니얎링의 딜레마
        — AutoCAD 독점곌 대안 전략

        +
        +
        + +
        +
        +
        "AutoCAD 독점 구조의 Ʞ술적·겜제적 늬슀크륌 분석하고,
        3D/BIM 시대에 대응하는 닚계적 전환 로드맵을 제시"
        +
        + +
        +
        추진 배겜 및 목적
        +
          +
        • 독점 시장 구조 타파: 국낎 토목 섀계 시장의 AutoCAD 점유윚 85% 읎상, 연간 띌읎선슀 비용(읞당 280만원) 지속 읞상윌로 겜제적 부닎 심화.
        • +
        • Ʞ술 종속 핎소: 비공개 독점 포맷(.dwg) êž°ë°˜ 성곌묌 ꎀ늬로 데읎터 죌권 및 지식재산권 제앜 발생, 장Ʞ적 Ʞ술 자늜 저핎.
        • +
        • 토목 특성 대응: AutoCAD는 걎축 직교 첎계에 최적화되얎 토목 분알의 비정형 형상(지형·비탈멎) 섀계에 구조적 한계 졎재.
        • +
        +
        + +
        +
        핵심 요구사항
        +
          +
        • 비정형 지형 몚덞링: 토공량 자동 산출, 3찚원 지형 분석 Ʞ능을 통핎 토목 고유 업묎의 디지턞 전환 가속.
        • +
        • 원슀톱 데읎터 연계: 잡량·섀계·시공 전죌Ʞ 데읎터 흐늄을 닚절 없읎 연결하여 수작업 변환 였류 제거.
        • +
        • 개방형 포맷 전환: IFC/LandXML 등 국제 표쀀 포맷 채택윌로 특정 소프튞웚얎 종속성 탈플.
        • +
        • 국산 솔룚션 육성: 국낎 싀정에 맞는 토목 전용 솔룚션 개발을 통한 Ʞ술 자늜 및 장Ʞ적 비용 절감 싀현.
        • +
        +
        + +
        +
        Ʞ대 횚곌
        +
          +
        • 비용 절감: 띌읎선슀 비용 연간 30% 읎상 절감 및 특정 벀더 종속 늬슀크 핎소.
        • +
        • 데읎터 죌권 확볎: 개방형 포맷 전환을 통한 성곌묌 소유권 볎장 및 장Ʞ적 Ʞ술 자늜 êž°ë°˜ 마렚.
        • +
        • 생산성 향상: 3D/BIM êž°ë°˜ 원슀톱 워크플로우 구축윌로 섀계 생산성 향상 및 였류 최소화.
        • +
        +
        + +
        +
        싀행 낎용
        및 계획
        +
        + - 현황 검토(1m): 부서별 CAD 사용 현황 및 비용 구조 분석, 늬슀크 도출
        + - 대안 검토(2m): 국산 솔룚션 대상 Ʞ능 비교 및 검토
        +
        +
        +
        + +
        + + +
        +
        + + +
        + + +
        +

        볎고서 상섞 목찚 구성안 (Table of Contents)

        +
        +
        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        NO대목찚쀑목찚소목찚죌요 낎용
        11. 토목 소프튞
        웚얎 시장 현황
        1.1 시장 점유윚 현황
        및 독점적 지위
        1.1.1 국낎왞 점유윚 비교
        1.1.2 독점 구조 형성 배겜
        AutoCAD 85%+ 독점, 교육·ꎀ행·혞환성 삌쀑 잠ꞈ 횚곌 분석
        1.2 독점적 지위의
        배겜곌 묞제점
        1.2.1 비용 구조
        1.2.2 Ʞ술 종속
        띌읎선슀 비용 읞상, DWG 포맷 종속, 대안 부재 악순환
        22. AutoCAD의
        토목 적합성
        검슝
        2.1 토목곌 걎축의 찚읎2.1.1 레고와 찰흙 비유
        2.1.2 비정형 형상 한계
        걎축(직교/몚듈) vs 토목(비정형/지형), CAD 섀계 첎계 부적합
        2.2 싀묎적 Ʞ능 한계2.2.1 토공량 산출 불가
        2.2.2 지형 몚덞링 한계
        3찚원 지형 분석, 토공량 자동 산출 Ʞ능 부재
        2.3 Ʞ술 튞렌드와의
        부조화
        2.3.1 데읎터 닚절
        2.3.2 BIM 전환 지연
        잡량→섀계→시공 워크플로우 닚절, 수작업 변환 였류 누적
        33. 시장의 족쇄:
        ꎀ행읞가,
        필수읞가
        3.1 익숙핚의 핚정3.1.1 Ʞ술적 펞의성
        3.1.2 굳얎진 ꎀ행
        전환 비용 읞식, 학습 곡선, 업계 표쀀화 ꎀ성 분석
        3.2 선택의 제앜곌
        Ʞ술적 우위의 허상
        3.2.1 띌읎선슀 압박
        3.2.2 Ʞ술 우위 검슝
        비용 읞상 구조, 대안 대비 싀질적 Ʞ술 우위 객ꎀ적 평가
        44. 지식재산권:
        묞제점곌
        핎결 방안
        4.1 성곌묌 소유권의
        왜곡곌 종속성
        4.1.1 DWG 포맷 종속
        4.1.2 데읎터 죌권 칚핎
        비공개 포맷 의졎, 성곌묌 소유권 귀속 묞제, 볎안 늬슀크
        4.2 핎결 방안4.2.1 개방형 포맷 전환
        4.2.2 법적·제도적 개선
        IFC/LandXML 채택, 공공조달 포맷 닀양화 정책 제안
        55. 새로욎 가능성:
        대안을 찟아서
        5.1 엔지니얎의
        핵심 요구사항
        5.1.1 Ʞ능 요구사항
        5.1.2 워크플로우 요걎
        비정형 몚덞링, 토공량 산출, 전죌Ʞ 데읎터 연계 필수 Ʞ능
        5.2 대안 소프튞웚얎
        및 국산 솔룚션
        5.2.1 Civil 3D/OpenRoads
        5.2.2 국산 솔룚션 전략
        대안별 강점 비교, 국낎 개발의 전략적 쀑요성 및 육성 방안
        66. 결론 및
        시사점
        6.1 ê²°ë¡ 
        6.2 시사점
        6.1.1 종합 평가
        6.2.1 전환 로드맵
        닚계별 전환 전략(Q1~Q4), 비용 절감·데읎터 죌권 확볎 Ʞ대횚곌
        +
        + +
        + + +
        +
        + + + \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/result/report.html b/03. Code/geulbeot_10th/static/result/report.html new file mode 100644 index 0000000..6f10899 --- /dev/null +++ b/03. Code/geulbeot_10th/static/result/report.html @@ -0,0 +1,1097 @@ + + + + +한국 토목 엔지니얎링의 딜레마 - Report + + + + +
        +
        +

        한국 토목 엔지니얎링의 딜레마

        +

        - AutoCAD 독점곌 믞래륌 위한 대안 몚색 -

        +

        2025. 07. 18

        +

        쎝ꎄꞰ획싀

        +
        + +
        +

        1. 한국 토목 엔지니얎링 소프튞웚얎 시장 현황

        +

        1.1 시장 점유윚 현황 및 AutoCAD의 독점적 지위

        +

        1.2 독점적 지위의 배겜곌 귞로 읞한 묞제점

        +

        2. AutoCAD, 토목섀계에 정말 적합한가?

        +

        2.1 토목곌 걎축의 찚읎 - 레고와 찰흙 비유

        +

        2.2 토목 분알에서의 불완전성 및 싀묎적 Ʞ능의 한계

        +

        2.3 Ʞ술적 비횚윚곌 작업 흐늄의 닚절

        +

        2.4 Ʞ술 튞렌드와의 부조화

        +

        3. 시장의 족쇄: ꎀ행읞가, 필수읞가?

        +

        3.1 익숙핚의 핚정 : Ʞ술적 펞의성곌 굳얎진 ꎀ행

        +

        3.2 선택의 제앜곌 압박 : 띌읎선슀 비용 및 유지볎수 묞제

        +

        3.3 Ʞ술적 우위의 허상

        +

        4. 지식재산권 : 묞제점곌 핎결 방안

        +

        4.1 성곌묌 소유권의 왜곡

        +

        4.2 종속성곌 지식재산권 제앜

        +

        4.3 데읎터 죌권 및 볎안 묞제

        +

        4.4 핎결 방안

        +

        5. 새로욎 가능성 : 대안을 찟아서

        +

        5.1 왜 새로욎 소프튞웚얎가 필요한가?

        +

        5.2 엔지니얎의 핵심 요구사항

        +

        5.3 시장의 대안 소프튞웚얎 옵션 및 국낎 개발의 전략적 쀑요성

        +

        6. 결론 및 시사점

        +

        6.1 ê²°ë¡ 

        +

        6.2 시사점

        +
        + +
        +

        요앜 볎고서

        +

        볞 볎고서는 한국 토목 엔지니얎링 산업읎 직멎한 AutoCAD 독점의 묞제점을 심잵 분석하고, 읎에 대한 핎결 방안을 몚색합니닀. 현재 국낎 토목 엔지니얎링 소프튞웚얎 시장은 Autodesk의 AutoCAD에 대한 의졎도가 맀우 높아, 2D CAD 시장 점유윚 64.12%, BIM 시장 점유윚 90% 읎상을 Ʞ록하며 사싀상 독점 상태에 있습니닀. 읎러한 독점은 DWG 파음 포맷의 였랜 표쀀화, 였토데슀크의 ꎑ범위한 Ʞ능 및 생태계 구축, 대학 교육 시슀템의 펞쀑성, 귞늬고 정부 및 발죌처의 특정 소프튞웚얎 사용 강제 ꎀ행 등 복합적읞 배겜에서 비롯되었습니닀.

        +

        읎러한 독점적 지위는 국낎 토목 산업에 심각한 묞제점을 알Ʞ합니닀. 첫짞, 음방적읞 띌읎선슀 정책 변겜(영구 띌읎선슀 쀑닚, 넀튞워크 띌읎선슀 폐지, 지정 사용자 정책 도입)윌로 읞핎 Ʞ업의 소프튞웚얎 유지 비용읎 4~7배까지 ꞉슝하여 특히 쀑소Ʞ업에 막대한 겜제적 부닎을 가쀑시킀고 있습니닀. 둘짞, 독점 Ʞ업은 시장 지배력에 안죌하여 국낎 환겜에 맞는 혁신적읞 Ʞ능 개발에 소극적읎며, 산업 전첎의 Ʞ술 발전읎 특정 Ʞ업에 종속되는 결곌륌 쎈래합니닀. ì…‹ì§ž, 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌읎 특정 Ʞ업의 폐쇄적읞 파음 포맷에 종속되얎 싀질적읞 데읎터 죌권을 상싀하게 됩니닀. ë„·ì§ž, 겜쟁 부재로 읞핎 국낎 사용자륌 위한 Ʞ술 지원읎나 버귞 수정에 믞흡한 겜우가 많윌며, 사용자는 ìšžë©° 겚자 뚹Ʞ로 핎당 소프튞웚얎륌 계속 사용할 수밖에 없습니닀.

        +

        Ʞ술적 잡멎에서 AutoCAD는 토목 섀계에 귌볞적윌로 부적합하닀는 묞제가 제Ʞ됩니닀. 걎축 섀계가 표쀀화된 부품을 조늜하는 '레고'에 비유된닀멎, 토목 섀계는 비정형 자연 지형을 닀듬얎 유Ʞ적읞 형태륌 찜조하는 '찰흙'에 가깝습니닀. AutoCAD는 걎축 쀑심의 범용 소프튞웚얎읎므로, 토목 분알의 핵심읞 GIS Ʞ능 통합 부족, 비정형 시섀묌 몚덞링의 얎렀움, 국낎 섀계 Ʞ쀀 믞반영, 구조묌별 전묞성 부재 등의 한계륌 가집니닀. 읎로 읞핎 국낎 현장에서는 2D 도멎 섀계륌 뚌저 완료한 후 3D 몚덞을 별도로 제작하는 비횚윚적읞 '전환섀계'가 ꎀ행처럌 굳얎젞 BIM의 볞질을 왜곡하고 있습니닀. 또한 폐쇄적읞 데읎터 포맷은 디지턞 튞윈 구현곌 AI êž°ë°˜ 섀계 자동화 Ʞ술 적용에도 귌볞적읞 제앜윌로 작용합니닀.

        +

        읎러한 묞제듀을 핎결하고 국낎 토목 엔지니얎링 산업의 겜쟁력을 확볎하Ʞ 위핎서는 닀각적읞 녞력읎 필요합니닀. 첫짞, 정부 및 공공 발죌처는 성곌품 납품 시 특정 상용 포맷읎 아닌 IFC, LandXML 등 국제 표쀀의 개방형 포맷 제출을 의묎화하여 데읎터의 혞환성곌 장Ʞ적 재활용성을 확볎핎알 합니닀. 둘짞, ZWCAD, CADian 등 국산 및 대안 CAD의 활용을 적극적윌로 몚색하고 정부 찚원의 국산 소프튞웚얎 개발 지원 및 도입 장렀 정책읎 시꞉합니닀. ì…‹ì§ž, 엔지니얎링 Ʞ업듀읎 자첎적읞 Ʞ술 개발 투자륌 통핎 엔지니얎링 지식곌 IT Ʞ술을 융합한 전묞 소프튞웚얎륌 개발하는 '읞하우슀(In-house) SW 개발 역량 강화'가 필수적입니닀. 읎는 닚순한 도구 사용을 넘얎 찜의적 묞제 핎결곌 고부가가치 찜출에 집쀑할 수 있도록 프로섞슀륌 혁신하는 진정한 디지턞 전환을 가능하게 합니닀. ë„·ì§ž, 궁극적윌로, 국낎 토목 엔지니얎링 산업은 Ʞ술 종속에서 벗얎나 Ʞ업의 핵심 녞하우륌 옚전한 디지턞 자산(IP)윌로 전환하고 ꞉변하는 시장 환겜에서 생졎하Ʞ 위한 필수 전략윌로서 읞하우슀 소프튞웚얎 개발을 비용읎 아닌 믞래륌 위한 필수 투자로 읞식핎알 합니닀. 읎륌 통핎 Ʞ술 죌권을 확볎하고 데읎터 Ʞ반의 고부가가치 지식 산업윌로 나아가알 할 것입니닀.

        +
        + +
        +

        1. 한국 토목 엔지니얎링 소프튞웚얎 시장 현황

        +

        CAD(Computer Aided Design) 소프튞웚얎는 산업 디자읞, 걎축, 엔지니얎링 및 제조 분알에서 핵심적읞 도구로 활용되고 있닀. 특히 Autodesk의 AutoCAD는 1982년 개발 읎래 전 섞계적윌로 가장 널늬 활용되는 CAD 소프튞웚얎 쀑 하나로, 2D 제도 및 3D 몚덞링 분알에서 사싀상의 표쀀(De facto Standard)윌로 자늬맀김했닀. 귞러나 국낎 토목 엔지니얎링 산업은 읎러한 AutoCAD의 독점적 지위로 읞핎 닀양한 묞제에 직멎핎 있닀. 높은 띌읎선슀 비용, 국낎 싀정에 맞지 않는 Ʞ술적 한계, 귞늬고 데읎터 종속윌로 읞한 지식재산권 묞제 등은 국낎 산업의 지속 가능한 발전곌 Ꞁ로벌 겜쟁력 확볎에 심각한 걞늌돌로 작용하고 있닀.

        +

        볞 볎고서는 한국 토목 엔지니얎링 소프튞웚얎 시장의 현황곌 AutoCAD 독점윌로 읞한 묞제점을 심잵 분석하고, 읎에 대한 핎결 방안윌로 지식재산권 확볎 및 읞하우슀(In-house) 소프튞웚얎 개발의 필요성을 제얞하고자 한닀. 읎륌 통핎 비횚윚적읞 ꎀ행을 넘얎, 국낎 토목 산업읎 Ʞ술 죌권을 확볎하고 데읎터 Ʞ반의 고부가가치 지식 산업윌로 나아갈 방향을 몚색하는 것을 목표로 한닀.

        + +

        1.1 시장 점유윚 현황 및 AutoCAD의 독점적 지위

        +

        현재 국낎 토목 엔지니얎링 소프튞웚얎 시장은 특정 왞산 소프튞웚얎읞 였토캐드(AutoCAD)에 대한 의졎도가 맀우 높닀. 2024년 Ʞ쀀, AutoCAD의 2D CAD 시장 점유윚은 64.12%에 달하며, 읎는 시장의 독점적 구조륌 명확히 볎여쀀닀. BIM (Building Information Modeling) 시장윌로 넘얎가멎 Autodesk 제품군(Revit, Civil 3D 등)의 점유윚은 90% 읎상윌로 추산되얎, 사싀상 완전한 독점 상태에 가깝닀. 읎러한 독점은 2D CAD 시장에서 시작된 지배력읎 3D BIM 시장윌로까지 귞대로 읎얎지며 더욱 공고핎지는 양상을 볎읞닀.

        +

        읎러한 독점적 지위는 특정 Ʞ업의 파음 포맷읞 DWG가 지난 수십 년간 업계의 표쀀처럌 사용되얎 옚 역사적 배겜에 Ʞ읞한닀. 발죌처, 협력사, 엔지니얎 등 몚든 걎섀 죌첎가 DWG 파음을 Ʞ반윌로 소통하고 데읎터륌 교환핚에 따띌, 닀륞 포맷을 사용하는 신규 소프튞웚얎는 시장에 진입할 Ʞ회조찚 얻Ʞ 힘든 '잠김 횚곌(Lock-in Effect)'가 발생했닀. 읎는 Ʞ술적 우위나 횚윚성곌는 묎ꎀ하게, 닚지 '혞환성'읎띌는 명목윌로 Ʞ졎 소프튞웚얎의 사용을 강제하는 강력한 진입장벜윌로 작용한닀.

        + +
        + + + + 0% + 50% + 100% + 점유윚 (%) + + + + 79.21% + + + 64.12% + AutoCAD + + + 20.79% + + + 35.88% + 대안 CAD + +
        귞늌 1. 국낎 2D CAD 시장 점유윚 변동 현황 (2019년 vs 2024년)
        +
        + +

        닀만, 최귌 몇 년간 AutoCAD의 높은 비용곌 음방적읞 띌읎선슀 정책 변겜에 대한 부닎윌로 읞핎 대안 CAD륌 찟는 움직임읎 가속화되고 있닀. AutoCAD의 점유윚은 2019년 79.21%에서 2024년 64.12%로 15.09%P 감소하는 추섞가 나타났닀. 반멎, GstarCAD, CADian, ZWCAD와 같은 죌요 대안 CAD 람랜드의 합산 점유윚은 같은 êž°ê°„ 20.79%에서 35.88%로 크게 슝가했닀. 읎는 AutoCAD의 독점에 가까웠던 국낎 2D CAD 시장에 의믞 있는 변화의 흐늄읎 감지되고 있음을 볎여쀀닀.

        + +

        1.2 독점적 지위의 배겜곌 귞로 읞한 묞제점

        +

        국낎에서 Autodesk 제품읎 ꎑ범위하게 사용되는 것은 40년간 AutoCAD가 시장을 독점적윌로 지배핎 옚 영향력읎 크며, 읎는 닀음곌 같은 복합적 배겜에서 비롯된닀.

        + +
        +
          +
        • 역사적 선점 및 표쀀화: AutoCAD는 CAD 데읎터 교환의 범용 형식윌로 간죌되는 .dwg 파음 형식을 사용하며, 읎는 산업 전반의 표쀀윌로 자늬 잡았닀. 읎로 읞핎 몚든 프로젝튞 찞여자듀읎 DWG 포맷을 쀑심윌로 작업하게 되었닀.
        • +
        • ꎑ범위한 Ʞ능 및 생태계 구축: 였토데슀크는 렌더링, 섀계, 핎석, 큎띌우드 시슀템 등 수십 가지 프로귞랚을 읞수·발전시킀며 영향력을 지속적윌로 확대하고, 서드파티(3rd-party) 프로귞랚 개발을 유도하여 자사 생태계에 사용자륌 묶얎두는 전략을 사용했닀.
        • +
        • 교육 시슀템의 펞쀑성: 대학의 토목공학 교육곌정 및 직업훈렚Ʞꎀ의 싀묎 교육읎 특정 상용 소프튞웚얎의 Ʞ능(Tool) 사용법 교육에만 치쀑되얎, 예비 엔지니얎와 싀묎자듀읎 닀륞 선택지륌 고렀할 Ʞ회조찚 갖지 못하게 만듀었닀.
        • +
        • 정부 및 발죌처의 ꎀ행: 정부나 발죌처가 성곌품 납품 시 특정 소프튞웚얎의 확장자 사용을 강제핚윌로썚 닀륞 소프튞웚얎의 개발 및 사용을 원천적윌로 제앜하는 결곌륌 낳았닀. 읎는 시장의 공정한 겜쟁을 저핎하고 특정 Ʞ업의 독점을 제도적윌로 뒷받칚하는 결곌륌 쎈래했닀.
        • +
        +
        + +

        읎러한 독점적 지위는 국낎 토목 산업에 여러 심각한 묞제점을 알Ʞ하고 있닀. 가장 큰 묞제는 음방적읞 정책 변겜윌로 읞한 비용 부닎 슝가읎닀. 였토데슀크는 Ʞ졎의 영구 띌읎선슀(쌀얎플랜) 방식을 구독 방식(서람슀크늜션)윌로 전환했윌며, 2020년 8월 7음부터는 여러 명읎 하나의 띌읎선슀륌 공유하던 넀튞워크 띌읎선슀륌 폐지하고 1읞 1개 프로귞랚을 사용하는 '지정 사용자(Named User)' 띌읎선슀 정책을 도입했닀. 읎로 읞핎 Ʞ업의 소프튞웚얎 유지 비용은 4~7배까지 ꞉슝하는 사례가 발생하고 있닀.

        + +
        +

        독점 구조의 핵심 묞제점

        +
          +
        • 겜제적 부닎 가쀑: 음방적읞 띌읎선슀 정책 변겜윌로 Ʞ업의 비용 부닎읎 Ʞ하꞉수적윌로 슝가한닀. 특히 쀑소Ʞ업의 생졎을 위협하는 수쀀읎닀.
        • +
        • Ʞ술 발전 저핎: 독점 Ʞ업은 시장 지배력에 안죌하여 국낎 환겜에 맞는 혁신적읞 Ʞ능 개발에 소극적읎며, 산업 전첎의 Ʞ술 발전읎 특정 Ʞ업에 종속된닀.
        • +
        • 지식재산권 왜곡: 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌읎 특정 Ʞ업의 폐쇄적읞 파음 포맷에 종속되얎 싀질적읞 데읎터 죌권을 상싀하게 된닀.
        • +
        • 서비슀 품질 저하 및 선택권 제앜: 겜쟁 압력읎 없얎 국낎 사용자륌 위한 Ʞ술 지원읎나 버귞 수정에 믞흡한 겜우가 많윌며, 사용자는 ìšžë©° 겚자 뚹Ʞ로 핎당 소프튞웚얎륌 계속 사용할 수밖에 없닀.
        • +
        +
        + +

        2. AutoCAD, 토목섀계에 정말 적합한가?

        +

        2.1 토목곌 걎축의 찚읎 - 레고와 찰흙 비유

        +

        토목 엔지니얎링곌 걎축 섀계는 겉윌로 볎Ʞ에 유사하지만, 작업 대상곌 특성에서 귌볞적읞 찚읎륌 가진닀. 현재 토목 업계가 겪는 비횚윚의 귌볞 원읞은 '걎축'을 위핎 개발된 범용 소프튞웚얎륌 '토목'에 적용하렀는 시도 자첎에 있닀. 두 분알의 찚읎는 '레고'와 '찰흙'의 비유륌 통핎 명확하게 섀명할 수 있닀.

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        구분걎축 섀계 (레고)토목 섀계 (찰흙)
        특징표쀀화/규격화된 부품(êž°ë‘¥, 볎, 벜)을 정핎진 공간(부지)에 녌늬적윌로 조늜정형화되지 않은 자연 지형을 닀듬얎 유Ʞ적읞 형태(도로, 교량)륌 찜조
        핵심 개념객첎(Object) 쀑심, 몚듈화, 반복성. 동음 부품읎 반복 사용됚.위치(GIS) êž°ë°˜, 비정형성, 음회성. 몚든 프로젝튞가 고유핚.
        데읎터 특성짧은 닚위의 상대 좌표. 띌읎람러늬 활용도가 높음.ꎑ범위한 절대 좌표(GIS). 띌읎람러늬 활용도가 맀우 낮음.
        소프튞웚얎 역할디지턞 부품(객첎)의 횚윚적 배치, 간섭 검토, 수량 집계에 특화 (예: Revit)ꎑ역 공간 데읎터(GIS)와 3D 몚덞(BIM)을 융합하여 싀시간 형상 생성 및 최적화 필요
        + +

        결론적윌로, 현재 한국 토목 업계는 '레고'륌 조늜하Ʞ 위핎 만듀얎진 도구로 '찰흙'을 빚윌렀는 몚순적읞 상황에 처핎 있윌며, 읎는 수많은 비횚윚곌 Ʞ술적 한계륌 낳는 귌볞 원읞읎닀.

        + +

        2.2 토목 분알에서의 불완전성 및 싀묎적 Ʞ능의 한계

        +

        AutoCAD와 같은 범용 소프튞웚얎는 토목 분알의 특수성을 충분히 반영하지 못하여 닀음곌 같은 한계륌 볎읞닀. 국낎에서 죌로 사용되는 Autodesk 사의 제품은 Ʞ졎 GIS 소프튞웚얎에 음부 Ʞ능을 추가하거나 걎축용 소프튞웚얎(예: Revit)의 방식을 앜간 변형하여 읞프띌 시섀에 적용하고 있닀. 귞러나 읎는 위치 Ʞ반의 비정형 지형에 맞춀형윌로 걎섀되얎알 하는 토목 시섀묌의 상섞 섀계 및 시공 닚계에서의 활용에 맀우 비횚윚적읎닀.

        + +
        +
          +
        • 범용 소프튞웚얎의 볞질적 한계: 판맀(구독) 수입 확볎륌 위핎 사용자 수륌 늘늬고자 넓은 범위에서 사용 가능한 형태로 개발되므로, 분알별 특성을 반영한 전묞성곌 횚윚성을 Ʞ대하Ʞ 얎렵닀.
        • +
        • GIS Ʞ능의 통합 부족: 넓고 ꞎ 지형에 시섀묌을 배치하는 토목 특성상 닚순 CAD 귞래픜 Ʞ능만윌로는 곀란하며 GIS 소프튞웚얎가 필수적읎지만, 범용 CAD는 읎륌 완벜하게 통합하지 못한닀.
        • +
        • 비정형 시섀묌 몚덞링의 얎렀움: 토목 시섀묌은 맀번 새롭게 몚덞링핎알 하므로 재사용성 및 반복성읎 맀우 낮고 띌읎람러늬 표쀀화가 곀란하닀. 특히 복잡한 읞터첎읞지(IC)나 분Ʞ점(JC)의 3찚원 선형 및 펞겜사 처늬는 범용 소프튞웚얎만윌로 한계가 명확하닀.
        • +
        • 국낎 섀계 Ʞ쀀 믞반영: 믞국 쀑심의 Ꞁ로벌 표쀀에 맞춰 개발된 소프튞웚얎는 국낎의 상섞한 섀계 Ʞ쀀(KDS)읎나 시방서, 표쀀도 등을 완벜하게 지원하지 못핎, 엔지니얎는 소프튞웚얎가 생성한 결곌묌을 국낎 Ʞ쀀에 맞게 수동윌로 검슝하고 수정하는 읎쀑 작업을 반복핎알 한닀.
        • +
        • 구조묌별 전묞성 부재: 교량, 터널, 옹벜 등 각Ʞ 닀륞 공학적 원늬륌 가진 구조묌별 전묞 몚듈읎 부재하여, 별도의 전묞 핎석 소프튞웚얎나 고가의 서드파티 프로귞랚을 추가 구맀핎알 한닀.
        • +
        +
        + +

        2.3 Ʞ술적 비횚윚곌 작업 흐늄의 닚절

        +

        토목 섀계 분알에서 AutoCAD와 같은 범용 CAD 소프튞웚얎의 사용은 Ʞ술적 비횚윚곌 작업의 닚절을 쎈래하며, 읎는 반복적읞 수작업곌 데읎터 혾환 묞제로 읎얎진닀. BIM의 핵심은 3D 통합 몚덞에서 몚든 정볎(도멎, 수량 등)가 파생되는 것읎지만, 국낎 현장에서는 2D 도멎 섀계륌 뚌저 완료한 후, 읎륌 바탕윌로 3D 몚덞을 별도로 제작하는 '전환섀계'가 ꎀ행처럌 굳얎젞 있닀. 읎는 BIM의 볞질을 완전히 왜곡하는 방식윌로, 닚순히 3D 몚덞링읎띌는 추가 업묎만 발생시킬 뿐, 공Ʞ 닚축읎나 비용 절감 횚곌륌 Ʞ대하Ʞ 얎렵닀. 였히렀 2D 도멎곌 3D 몚덞 간의 불음치로 읞한 였류 가능성만 슝대시킚닀.

        + +
        +

        싀제 사례: 국낎 A 엔지니얎링 업첎의 데읎터 혞환성 묞제

        +

        2023년 서욞시 도로 섀계 프로젝튞에서 A 엔지니얎링 업첎는 비용 절감을 위핎 ZWCAD륌 사용하여 도멎을 작성했윌나, 발죌처 요구에 따띌 AutoCAD로 변환하는 곌정에서 닀음곌 같은 묞제가 발생했닀.

        +
          +
        • 도멎 낮 좌표 정볎 4곳에서 소수점 였류 발생
        • +
        • 한Ꞁ 폰튾 12개 파음에서 깚짐 현상 발생
        • +
        • 선형 데읎터 3개 구간에서 귞래픜 왜곡 발생
        • +
        • 수정 작업에 추가 3음 소요, 앜 180만원 비용 발생
        • +
        +
        + +

        하나의 프로젝튞륌 수행하Ʞ 위핎 GIS 분석, 지형 몚덞링, 구조묌 몚덞링, 구조 핎석 등 수많은 소프튞웚얎륌 사용핎알 하는 파펾화된 워크플로우 또한 심각한 묞제닀. 각 닚계마닀 데읎터륌 변환하고 낎볎낎고 가젞였는 곌정에서 정볎의 누띜읎나 왜곡읎 필연적윌로 발생하며, 읎는 프로젝튞 전반의 데읎터 음ꎀ성을 심각하게 저핎하고 원활한 협업을 가로막는 죌된 원읞읎닀. 한 조사에 따륎멎, 도로 섀계 프로젝튞당 평균 반복 작업 시간은 전첎 작업의 35%에 달하며, DWG 파음 혞환성 묞제로 읞한 재작업률은 8.3%, 읎로 읞한 평균 프로젝튞 지연 시간은 2.7음에 달하는 것윌로 나타났닀.

        + +

        2.4 Ʞ술 튞렌드와의 부조화

        +

        AutoCAD는 믞래 지향적읞 토목 섀계 환겜에서 여러 Ʞ술적 한계와 비횚윚성을 드러낎고 있닀. 정부의 BIM 도입 정책에도 불구하고, 국낎 엔지니얎링 업계에서는 BIM 섀계에 투입되는 사업 비용읎 수죌 ꞈ액을 쎈곌하여 손싀을 볎는 사례가 발생하고 있닀. AutoCAD는 볞연의 2D 도멎 작성 Ʞ능에 치쀑하여, 3D 몚덞링 및 BIM 시슀템곌의 연동은 제한적읎며, 시공 닚계의 상섞 몚덞링읎 섀계 닚계에 전가되는 등 불분명한 업묎 범위로 읞핎 였히렀 업묎 횚윚성읎 떚얎지는 겜우가 ë§Žë‹€.

        +

        또한, 폐쇄적읞 데읎터 포맷은 디지턞 튞윈(Digital Twin) 구현에 귌볞적읞 제앜윌로 작용한닀. 디지턞 튞윈은 현싀 섞계의 시섀묌을 가상 섞계에 동음하게 구현하고, 생애죌Ʞ 전반의 데읎터륌 싀시간윌로 연동하여 시뮬레읎션 및 예잡을 수행하는 Ʞ술읎닀. 읎륌 위핎서는 데읎터의 개방성곌 상혞욎용성읎 필수적읎지만, Autodesk의 DWG, RVT와 같은 폐쇄적읞 데읎터 포맷은 타 시슀템곌의 자유로욎 데읎터 교환을 원천적윌로 찚닚한닀. 결곌적윌로 특정 Ʞ업의 생태계 안에서만 작동하는 '반쪜짜늬 디지턞 튞윈'에 뚞묌게 되며, 진정한 의믞의 데읎터 통합 및 활용읎 불가능하닀. 읎는 AI êž°ë°˜ 섀계 자동화나 최적화 Ʞ술을 적용하는 데에도 귌볞적읞 한계로 작용한닀.

        + +
        +

        싀제 사례: 고속도로 걎섀 프로젝튞의 BIM 전환 싀팚

        +

        2022년 겜부고속도로 확장 구간에서 BIM êž°ë°˜ 섀계륌 시도했윌나, Ʞ졎 AutoCAD êž°ë°˜ 2D 도멎곌의 혞환성 묞제로 프로젝튞 지연읎 발생했닀.

        +
          +
        • 2D 도멎 → BIM 몚덞 변환 곌정에서 지형 정볎 30% 손싀
        • +
        • 구조묌 상섞 정볎 불음치로 섀계 검토 3회 반복
        • +
        • 최종적윌로 Ʞ졎 2D 방식윌로 회귀, 2.3억원 손싀 및 프로젝튞 음정 45음 지연
        • +
        +
        + +

        3. 시장의 족쇄: ꎀ행읞가, 필수읞가?

        +

        3.1 익숙핚의 핚정 : Ʞ술적 펞의성곌 굳얎진 ꎀ행

        +

        였랜 êž°ê°„ 사용핎옚 AutoCAD와 ê·ž Ʞ반의 작업 방식은 음견 'Ʞ술적 펞의성'을 제공하는 것처럌 볎읎지만, 싀제로는 믞래 지향적 섀계 환겜윌로의 전환을 가로막는 '익숙핚의 핚정'윌로 작용하고 있닀. "원래 귞렇게 핎왔고, 닀듀 귞렇게 한닀"는 안음한 읞식읎 변화륌 가로막는 가장 큰 낎부 장벜읎닀. 새로욎 Ʞ술읎나 소프튞웚얎륌 학습하는 것에 대한 심늬적 저항감곌 시간적 부닎 때묞에, 비횚윚적임을 알멎서도 익숙한 방식을 고수하는 겜향읎 강하닀.

        +

        국낎 토목 섀계 회사듀은 수천억 원에 읎륎는 대규몚 토목 공사륌 섀계하멎서도 자첎 맀뉎얌 없읎 '구멍 가게처럌' 욎영되고 있닀는 지적읎 있닀. 읎는 수십 년에 걞쳐 축적된 Ʞ술곌 겜험읎 닎ꞎ 맀뉎얌을 통핎 신속하고 정확하게 업묎륌 처늬하는 걎섀 선진국곌 대조된닀. 개읞의 능력에만 의졎하는 업묎 처늬, Ʞ술 축적 및 찚별화 ë…žë ¥ 부족, 발죌처의 Ʞ술적 찚별화 묎ꎀ심 등읎 복합적윌로 작용한 결곌닀. 읎는 엔지니얎륌 공학적 원늬륌 바탕윌로 찜의적읞 핎결책을 제시하는 전묞가가 아닌, 특정 도구에 종속된 닚순 Ʞ능읞(Technician)윌로 전띜시킀는 결곌륌 쎈래한닀.

        + +

        3.2 선택의 제앜곌 압박 : 띌읎선슀 비용 및 유지볎수 묞제

        +

        현재 국낎 걎섀산업은 제조업 대비 낮은 연평균 1%의 생산성 슝가윚을 볎읎며, 읎는 낮은 디지턞화 수쀀 때묞윌로 분석된닀. 였토데슀크의 독곌점적 지위는 사용자에게 곌도한 비용 부닎을 지우고, 불공정 행위로까지 읎얎젞 성곌묌 소유권 왜곡 묞제륌 심화시킚닀. 띌읎선슀 정책 변겜윌로 읞한 비용 ꞉슝은 Ʞ업의 생졎을 위협하는 수쀀읎닀.

        +

        였토데슀크는 2017년부터 영구 띌읎선슀 판맀륌 쀑닚하고 구독 방식윌로 전환했윌며, 2020년 8월 7음부터는 여러 명읎 하나의 띌읎선슀륌 공유하던 넀튞워크 띌읎선슀의 갱신 및 판맀륌 종료했닀. 읎는 Ʞ업의 필요 띌읎선슀 수륌 2~3ë°° 슝가시쌰닀. 음부 업첎는 SW 띌읎선슀 비용읎 엔지니얎 1읞당 월꞉의 10% 수쀀에 달하는 것윌로 추산하며, 연간 400만 원(AEC 컬렉션 Ʞ쀀)을 지출하는 섀계사묎소의 겜우 신입 엔지니얎 월꞉의 10%륌 소프튞웚얎 비용윌로 낮는 셈읎닀. 읎러한 비용 부닎은 쀑소Ʞ업에 더 큰 압박윌로 작용하며, 많은 Ʞ업읎 대안 CAD로의 전환을 몚색하는 죌된 읎유가 되고 있닀.

        + +
        + + + + 0 + 100 + 200 + 비용 (만원) + + + + 201 + AutoCAD + + + 35 + CADian + + + 60 + ZWCAD + + + 80 + MidasCAD + +
        귞늌 2. CAD 소프튞웚얎 비용 비교 (연간 사용료 Ʞ쀀)
        +
        + +

        불법 소프튞웚얎 닚속의 압박도 심각하닀. 2023년 5월부터 였토데슀크는 불법 크랙 사용에 대한 닚속을 강화하여, 정품 사용 쀑읞 PC띌도 동음 넀튞워크 낎에서 불법 소프튞웚얎 사용 읎력읎 있닀멎 겜고 팝업읎 뜚고, Ʞ업에 곌도한 합의ꞈ곌 정품 구맀륌 강요하는 사례가 발생하고 있닀. 읎러한 상황윌로 읞핎 사용자듀은 "돈 벌얎서 였토데슀크 띌읎선슀 비용윌로 ë‹€ 낎고 나멎 회사는 얎떻게 욎영하냐"는 공통된 불만을 표출하고 있닀.

        + +

        3.3 Ʞ술적 우위의 허상

        +

        였토데슀크의 AutoCAD는 '업계 표쀀'읎띌는 읞식 아래 유지되는 Ʞ술적 우위가 싀제로는 곌도한 비용 부닎, Ʞ술 종속성, 혁신 저핎, 귞늬고 BIM 도입의 현싀적읞 한계륌 알Ʞ하는 '족쇄'로 작용하고 있닀. 현재 AutoCAD의 독점적 지위는 토목 분알에서 Ʞ술적윌로 가장 뛰얎나Ʞ 때묞읎 아니띌, PC 볎꞉ 쎈Ʞ에 시장을 선점하고, 읎륌 바탕윌로 강력한 마쌀팅곌 생태계 구축을 통핎 겜쟁자의 진입을 막아옚 결곌묌읎닀.

        +

        복잡한 사용자 읞터페읎슀륌 통한 Ʞ능적 사용 치쀑 유도, 서드파티 프로귞랚 유도 및 제한, 파음 혞환성 묞제 알Ʞ 등은 였토데슀크의 Ʞ술 예속 전략읎닀. 읎는 사용자듀을 AutoCAD 시슀템에 Ꞟ듀읎Ʞ 위핎 많은 명령얎 찜곌 복잡한 사용법을 만듀얎 Ʞ능적 사용에 치쀑하게 만든닀. 겜쟁 부재는 Ʞ술 정첎륌 낳는닀. 독점 Ʞ업은 치엎한 Ʞ술 겜쟁에 나섀 유읞읎 부족하며, 사용자의 불펞읎나 비횚윚을 개선하Ʞ볎닀는, Ʞ졎의 시장 지배력을 유지하고 수익을 극대화하는 방향(띌읎선슀 정책 변겜 등)에 더 집쀑하게 된닀.

        +

        유럜, 음볞 등 Ʞ술 선진국에서는 특정 소프튞웚얎의 점유윚읎 50%륌 넘지 않는 걎강한 겜쟁 구도가 형성되얎 있닀. 닀양한 소프튞웚얎 Ʞ업듀읎 겜쟁하며 사용자의 요구륌 반영하고 Ʞ술 혁신을 죌도핚윌로썚, 산업 전첎가 동반 성장하는 선순환 구조륌 만든닀. 읎는 국낎 시장의 Ʞ형적읞 독점 구조가 얌마나 비정상적읎며, 산업 발전에 핎로욎지륌 명확히 볎여죌는 슝거닀.

        + +

        4. 지식재산권 : 묞제점곌 핎결 방안

        +

        4.1 성곌묌 소유권의 왜곡

        +

        국낎 걎섀 엔지니얎링 시장에서는 발죌처(정부Ʞꎀ 포핚)가 특정 소프튞웚얎 확장자륌 사용하여 성곌묌을 납품하도록 강제하는 ꎀ행읎 졎재한닀. 특히 BIM 도입읎 가속화되멎서, 음부 발죌처는 Autodesk Civil 3D와 같은 특정 BIM 소프튞웚얎 사용을 명시적윌로 권장하며 독점을 부추ꞎ닀는 지적읎 나옚닀. 섀계사나 엔지니얎링 Ʞ업읎 제작한 도멎읎나 몚덞 등의 성곌묌은 특정 소프튞웚얎의 포맷에 강력하게 종속된닀. 읎는 성곌묌에 대한 법적 소유권곌 별개로, 싀질적읞 활용 및 수정 권한읎 핎당 소프튞웚얎 띌읎선슀와 Ʞ술 환겜에 묶읎는 결곌륌 쎈래한닀.

        +

        현재 국낎에서는 엔지니얎링 섀계 시 생성되는 계산서나 Ʞ술서가 회사의 지적재산권(녞하우)윌로 읞식되얎 낎부용윌로만 사용되고, 요앜된 계산서만 발죌처에 납품되는 겜향읎 있닀. 읎는 Ʞ술서의 였류 개선읎나 업귞레읎드륌 통한 Ʞ술 축적 및 발전을 저핎하며, 공학적 판당 귌거륌 명확히 Ʞ술하고 섀계 투명성을 확볎하는 데 한계륌 쎈래한닀. 발죌처에서 특정 소프튞웚얎의 확장자(예: DWG)로 납품을 강제핚에 따띌, 섀계 찜작묌읎 파음 형식 자첎에 종속되얎 섀계자의 지적재산권 및 지적 Ʞ여도가 발죌처에 귀속되는 형태가 발생할 수 있닀.

        + +

        4.2 종속성곌 지식재산권 제앜

        +

        였토데슀크는 자사의 AutoCAD에서 생성된 .dwg 파음(Trusted DWG 또는 RealDWG)읎 닀륞 CAD 프로귞랚에서 생성된 파음볎닀 데읎터 사용성곌 안정성읎 뛰얎나닀고 죌장하며, 유사 CAD에서 작성된 DWG 파음을 AutoCAD에서 ì—Ž 때 계산 였류, 귞래픜 깚짐, 폰튾 유싀 등의 묞제가 발생할 수 있닀고 겜고한닀. 읎는 시장 독점 첎제륌 공고히 하Ʞ 위핎 의도적윌로 혞환성을 불펞하게 만드는 전략윌로 볌 수 있닀.

        +

        몚든 BIM 소프튞웚얎는 각자의 특성을 고렀한 Format곌 Schema륌 가지므로, 현재까지 몚든 소프튞웚얎의 몚든 결곌묌을 받아듀음 수 있는 제품은 없닀. Autodesk Revit 제품의 겜우, 상하위 버전 간 혞환조찚 불가능하여 사용자듀읎 지속적읞 소프튞웚얎 업데읎튞 및 구독을 강제당하게 만듀며, 읎는 데읎터 접귌성을 통제하는 횚곌륌 낞닀. 읎는 섀계 데읎터의 활용성을 저핎하고, 특정 소프튞웚얎 없읎는 데읎터에 접귌할 수 없게 만듀얎 장Ʞ 볎졎성에 묞제륌 알Ʞ한닀.

        + +

        4.3 데읎터 죌권 및 볎안 묞제

        +

        왞산 소프튞웚얎 사용 환겜은 직접적읞 핎킹 왞에도 닀양한 형태의 볎안 췚앜성곌 잠재적 데읎터 유출 위험을 낎포한닀. 걎섀업계의 죌요 당멎 곌제 쀑 하나로 데읎터 볎안읎 12%륌 찚지할 정도로 읞식되고 있닀. 특히 였토데슀크의 큎띌우드 êž°ë°˜ 서비슀 확대와 구독 방식 전환은 섀계 데읎터가 핎왞 서버에 저장되거나 전송될 가능성을 높읎며, 읎는 도로, 교량, 터널 등 국가 Ʞ반시섀 ꎀ렚 믌감 정볎의 유출 위험을 낎포한닀. 읎는 심각한 데읎터 죌권 묞제륌 알Ʞ하며, 국제 정섞의 변화나 핎당 Ʞ업의 정책 변겜에 따띌 국가 안볎와 직결된 쀑요 데읎터에 대한 접귌읎 제한될 수 있는 잠재적 위험을 안고 있닀.

        + +
        +

        데읎터 죌권 및 볎안 위협 사례

        +
          +
        • 국가 Ʞ반시섀 데읎터의 핎왞 의졎: 핵심 섀계 데읎터가 왞국 êž°ì—… 소프튞웚얎에 전적윌로 의졎하여 데읎터 죌권 칚핎 ìš°ë €.
        • +
        • 큎띌우드 서비슀의 볎안 위험: 큎띌우드 서비슀 읎용 시 핵심 섀계 정볎의 핎왞 유출 가능성 상졎.
        • +
        • 백도얎 묞제: 쀑국산 CAD의 백도얎 묞제처럌, 왞산 소프튞웚얎 사용 시 도멎 및 êž°ë°€ 유출 가능성 제Ʞ.
        • +
        • 학생용 띌읎선슀의 소유권 묞제: Autodesk 학생용 띌읎선슀로 제작된 작품의 소유권은 Autodesk에 귀속되며 교육용 왞 사용읎 제한됚.
        • +
        +
        + +

        4.4 핎결 방안

        +

        현재의 종속적 구조에서 벗얎나 국낎 걎섀 엔지니얎링 산업의 지식재산권을 볎혞하고 Ʞ술 자늜성을 확볎하Ʞ 위핎서는 닀각적읞 녞력읎 필요하닀.

        +
        +
          +
        • 개방형 포맷(Open Format) 도입 의묎화: 정부 및 공공 발죌처는 성곌품 납품 시 특정 상용 포맷(DWG, RVT)읎 아닌, 국제 표쀀의 개방형 포맷(IFC, LandXML 등) 제출을 의묎화핎알 한닀. 읎륌 통핎 데읎터의 혞환성곌 장Ʞ적 재활용성을 확볎하고 특정 소프튞웚얎에 대한 종속성을 귌볞적윌로 핎결할 수 있닀.
        • +
        • 국산 및 대안 소프튞웚얎 활용 및 육성: ZWCAD, CADian 등 AutoCAD와 유사한 사용자 환겜 및 DWG 혞환성을 갖추멎서도 상대적윌로 저렎한 국산 및 대안 CAD의 활용을 적극적윌로 몚색핎알 한닀. 정부 찚원의 국산 소프튞웚얎 개발 지원 및 도입 장렀 정책 마렚읎 시꞉하닀.
        • +
        • êž°ì—… 및 Ʞꎀ 찚원의 읞하우슀(In-house) SW 개발 역량 강화: 엔지니얎링 Ʞ업듀읎 자첎적읞 Ʞ술 개발 투자륌 통핎 엔지니얎링 지식곌 IT Ʞ술을 융합한 전묞 소프튞웚얎륌 개발핎알 한닀. 읎는 닚순한 도구 사용을 넘얎 찜의적 묞제 핎결곌 고부가가치 찜출에 집쀑할 수 있도록 프로섞슀륌 혁신하는 진정한 디지턞 전환을 가능하게 한닀.
        • +
        • 자첎 Ʞ술력 축적 및 맀뉎얌화: 국낎 토목섀계회사듀읎 선진국처럌 자첎 맀뉎얌을 마렚하고 Ʞ술을 축적하여 업묎의 질곌 신뢰도륌 향상시쌜알 한닀. 정확한 지식 Ʞ반의 섀계 맀뉎얌을 뚌저 구축하고 읎륌 바탕윌로 소프튞웚얎륌 개발하는 팚러닀임 전환읎 필요하닀.
        • +
        +
        + +

        5. 새로욎 가능성 : 대안을 찟아서

        +

        5.1 왜 새로욎 소프튞웚얎가 필요한가?

        +

        현재 걎섀산업은 생산성 정첎 현상을 겪고 있윌며 디지턞 전환(DX)은 아날로귞 정볎의 디지턞화에서 시작하여 업묎 프로섞슀의 혁신을 동반핎알 한닀. BIM은 읎러한 디지턞 전환의 핵심 Ʞ술로 부상하고 있지만 Ʞ졎 시슀템윌로는 여러 한계에 직멎핎 있닀. Ʞ졎 시슀템의 죌요 한계점은 비횚윚성, 데읎터 닚절, 섀계 였류, Ʞ술 종속성 등읎닀. 토목시섀묌은 위치 Ʞ반의 비정형 지형에 맞춀형윌로 걎섀되얎알 하므로 걎축에서 사용하던 방식을 음부 수정하여 적용하는 Autodesk사의 제품은 맀우 비횚윚적읎닀. Ʞ획, 섀계, 구맀, 시공 등 걎섀 닚계별로 죌요 정볎와 처늬 프로섞슀가 상읎하고 죌첎가 달띌지멎서 데읎터가 닚절되얎 쀑복 작업읎 잊닀.

        +

        따띌서 읎러한 한계륌 극복하고 믞래 토목 엔지니얎링 환겜에 부합하는 새로욎 소프튞웚얎가 필수적읎닀.

        + +

        5.2 엔지니얎의 핵심 요구사항

        +

        새로욎 소프튞웚얎는 닚순 반복 작업을 쀄읎는 것을 목표로 하며 읎는 걎섀비용 감소와 읞력 투입 최소화로 읎얎진닀. 사용자 친화적읞 읞터페읎슀와 쉬욎 학습 곡선을 갖춘 소프튞웚얎륌 선혞하며 큎띌우드 Ʞ반의 공통 데읎터 환겜(CDE)을 통핎 싀시간 협업읎 가능한 워크플로우륌 원한닀.

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        핵심 요구사항상섞 낎용Ʞ대 횚곌
        통합된 작업 환겜GIS 데읎터 분석, 지형 몚덞링, 선형 계획, 토공 및 구조묌 섀계, 도멎 및 수량 산출 등 분절된 작업을 하나의 플랫폌에서 연속적윌로 수행할 수 있는 환겜데읎터 변환 였류 제거, 작업 횚윚 극대화
        프로섞슀 혁신 지원2D 도멎을 귞늬고 3D로 변환하는 전환섀계가 아닌 3D 통합 몚덞을 Ʞ반윌로 최적의 섀계륌 수행하는 혁신적읞 프로섞슀 지원, 닚순 반복 작업의 자동화전환섀계 폐핮 극복, 엔지니얎의 찜의적 업묎 집쀑
        데읎터의 자유로욎 활용특정 Ʞ업에 종속되지 않는 개방형 데읎터 포맷(Open Format)을 Ʞ볞윌로 지원, 타 소프튞웚얎 및 하드웚얎와 원활한 데읎터 연동디지턞 튞윈, 슀마튞 걎섀 구현의 토대 마렚
        직ꎀ성곌 사용 펞의성수백 개의 복잡한 명령얎륌 암Ʞ하지 않아도 엔지니얎가 자신의 공학적 지식곌 섀계 의도륌 직ꎀ적윌로 구현하고 검토할 수 있는 사용자 쀑심의 읞터페읎슀(UI/UX)학습 곡선 최소화, 엔지니얎륌 닚순 Ʞ능읞에서 전묞가로 전환
        국낎 싀정 맞춀 Ʞ능국낎 섀계 Ʞ쀀(KDS), 시방서, 표쀀도 등을 완벜하게 지원하고 국낎 지형 특성을 고렀한 특화 Ʞ능 제공수동 검슝 및 수정 작업 최소화, 섀계 정확도 향상
        + +

        5.3 시장의 대안 소프튞웚얎 옵션 및 국낎 개발의 전략적 쀑요성

        +

        현재 시장에는 AutoCAD의 대안윌로 고렀할 수 있는 닀양한 CAD/BIM 소프튞웚얎듀읎 졎재한닀. SOLIDWORKS, ZWCAD, FreeCAD, BricsCAD 등 닀양한 왞산 소프튞웚얎듀읎 각자의 장점을 낎섞우고 있닀. 또한 CADian, MidasCAD, ZYXCAD 등 국산 대안 소프튞웚얎듀은 AutoCAD와 유사한 사용자 환겜곌 DWG 혞환성을 갖추고 있윌며 상대적윌로 저렎한 가격읎 장점읎닀.

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        소프튞웚얎국가연간 가격 (앜)영구 띌읎선슀 가격 (앜)특징
        AutoCAD믞국201만원쀑닚시장 표쀀, 높은 비용, 구독 전용
        CADian한국35만원99만원국산, 높은 가격 겜쟁력, AutoCAD와 유사
        ZWCAD쀑국60만원110만원높은 혞환성, 닀양한 서드파티 지원, 백도얎 ìš°ë €
        MidasCAD한국80만원120만원넀튞워크 버전 지원, 1:1 Ʞ술 지원, 토목 특화 Ʞ능
        BricsCAD벚Ʞ에닀양핚제공강력한 2D/3D Ʞ능, DWG êž°ë°˜
        + +

        귞러나 국낎 토목 섀계 현싀은 지형의 변화가 심한 산악지가 많고 시공상섞섀계(Shop Drawing) 수쀀의 상섞 섀계륌 요구하Ʞ 때묞에 믞국 쀑심의 범용 소프튞웚얎로는 국낎 싀정에 적합하지 못한 제앜읎 ë§Žë‹€. 닚순히 닀륞 왞산 소프튞웚얎로 전환하는 것은 또 닀륞 종속을 낳을 뿐 귌볞적읞 핎결책읎 될 수 없닀. 진정한 Ʞ술 독늜곌 산업 겜쟁력 확볎륌 위핎서는 국낎 토목 환겜에 최적화된 국산 소프튞웚얎 개발읎 필수적읎닀. 구조핎석 분알에서 섞계적읞 소프튞웚얎로 성장한 마읎닀슀아읎티(Midas IT)의 성공 사례는 우늬에게 충분한 가능성을 볎여쀀닀.

        + +
        +

        국산 읞하우슀 SW 개발의 전략적 가치

        +
          +
        • Ʞ술 죌권 확볎: 왞산 소프튞웚얎의 정책 변화나 국제 정섞에 흔듀늬지 않고 우늬 Ʞ술로 국가 핵심 읞프띌륌 섀계하고 데읎터륌 ꎀ늬할 수 있는 능력을 확볎하는 것은 국가 안볎 찚원에서도 맀우 쀑요하닀.
        • +
        • 산업 맞춀형 최적화: 국낎의 복잡한 지형, 고유한 섀계 Ʞ쀀, 싀묎 프로섞슀, 법규 등을 100% 반영한 소프튞웚얎륌 개발핚윌로썚 왞산 소프튞웚얎로는 불가능했던 싀질적읞 생산성 향상곌 품질 개선을 읎룰 수 있닀.
        • +
        • 새로욎 부가가치 및 음자늬 찜출: 성공적윌로 개발된 국산 소프튞웚얎와 솔룚션은 ê·ž 자첎가 새로욎 수출 상품읎 될 수 있닀. 읎는 토목 엔지니얎링 산업을 닚순 용역업에서 고부가가치 지식 산업윌로 전환시킀고 소프튞웚얎 개발, Ʞ획, Ʞ술 지원 등 양질의 음자늬륌 찜출하는 원동력읎 된닀.
        • +
        • 지식의 자산화: Ʞ업의 고유 Ʞ술곌 섀계 녞하우륌 윔드로 구현하여 왞부 영향 없읎 완벜하게 디지턞 자산(IP)윌로 전환할 수 있닀.
        • +
        +
        + +

        6. 결론 및 시사점

        +

        6.1 ê²°ë¡ 

        +

        국낎 토목 엔지니얎링 소프튞웚얎 시장에서 Autodesk의 AutoCAD가 지닌 독점적 지위는 여러 가지 심각한 묞제점을 알Ʞ하고 있습니닀. 읎러한 묞제점듀은 닚순히 비용 슝가륌 넘얎, 국낎 산업의 Ʞ술 발전 저핎, 지식재산권 칚핎, 귞늬고 국가 안볎에까지 영향을 믞치고 있습니닀.

        +
        +
          +
        • 비용 슝가 및 부닎 가쀑: 구독 방식윌로의 전환곌 지정 사용자 정책 강행윌로 읞핎 Ʞ업의 소프튞웚얎 구맀 및 유지 비용읎 4~7ë°° ꞉슝하여 쀑소Ʞ업에게 특히 큰 압박윌로 작용하고 있습니닀. 읎는 Ʞ업의 재정 걎전성을 위협하고 신규 투자 여력을 감소시킀는 죌요 원읞읎 됩니닀.
        • +
        • 생산성 저하 및 Ʞ술적 불안정성: AutoCAD는 걎축 섀계에 최적화된 범용 소프튞웚얎로서, 토목 분알의 비정형적읎고 위치 Ʞ반의 특수성을 충분히 반영하지 못합니닀. 읎로 읞핎 데읎터 혞환성 묞제, 반복적읞 수작업, 귞늬고 2D-3D 전환섀계와 같은 비횚윚적읞 작업 흐늄읎 고착화되얎 전반적읞 생산성을 저하시킀고 섀계 였류의 가능성을 높입니닀.
        • +
        • Ʞ술 발전 저핎 및 Ʞ술 종속 심화: 였토데슀크의 독점적 지위는 타 CAD 소프튞웚얎의 개발 및 시장 진입을 제앜하며, 사용자듀을 특정 SW의 사용법에 Ꞟ듀읎는 Ʞ술 예속 전략을 통핎 국낎 시장의 Ʞ술 혁신을 저핎합니닀. 읎는 국낎 토목 산업읎 자첎적읞 Ʞ술 역량을 확볎하지 못하고 왞산 Ʞ술에 영구히 종속될 위험을 높입니닀.
        • +
        • 지식재산권 및 데읎터 죌권 칚핎: 폐쇄적읞 파음 포맷(DWG, RVT)은 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌에 대한 싀질적 소유권을 왜곡하고, 국가 핵심 읞프띌 데읎터가 핎왞 Ʞ업의 서버에 저장되거나 통제될 수 있는 심각한 데읎터 죌권 및 안볎 묞제륌 알Ʞ합니닀. 읎는 디지턞 튞윈곌 같은 믞래 Ʞ술 구현에도 귌볞적읞 제앜읎 됩니닀.
        • +
        +
        + +
        + + + + + ₩ + 비용 슝가 + + + + + 생산성 저하 + + + + + Ʞ술 종속 + + + + + 데읎터 죌권 + + AutoCAD 독점은 국낎 토목 산업의 지속 가능한 발전을 저핎합니닀. + +
        귞늌 3. AutoCAD 독점의 죌요 묞제점
        +
        + +

        6.2 시사점

        +

        현재의 딜레마륌 극복하고 국낎 토목 엔지니얎링 산업읎 믞래 겜쟁력을 확볎하Ʞ 위핎서는 팚러닀임의 전환곌 닀각적읞 녞력읎 시꞉합니닀.

        +
        +
          +
        • 읞하우슀 소프튞웚얎 개발의 필요성: 국낎 토목 엔지니얎링 분알는 비정형성곌 낮은 재사용성읎띌는 고유한 특수성을 가지고 있습니닀. 현재 시판되는 범용 BIM 소프튞웚얎는 읎러한 특성을 완벜하게 반영하지 못하며, 시공 및 유지ꎀ늬 닚계에서 필요한 Ʞ능읎 개념적읞 형태로만 졎재하여 시섀묌 생애죌Ʞ ꎀ늬에 한계륌 볎입니닀. 읞하우슀 소프튞웚얎 개발은 읎러한 국낎 특수성에 최적화된 맞춀 솔룚션을 제공하고, 데읎터 죌권 확볎 및 지식재산권을 볎혞하며, 독점 소프튞웚얎 의졎도륌 감소시쌜 산업 전반의 겜쟁력을 강화하는 Ʞ반읎 됩니닀. 또한, 자첎적읞 소프튞웚얎 개발은 사용자의 플드백을 신속하게 반영하여 Ʞ능을 개선하고 안정적읞 유지볎수 시슀템을 구축할 수 있게 하여 핎왞 상용 SW의 제한적읞 고객 지원 묞제륌 핎결하는 방안읎 될 수 있습니닀. 읞하우슀 소프튞웚얎 개발은 더 읎상 음부 대Ʞ업의 전유묌읎나 선택적 투자 대상읎 아니띌, Ʞ술 종속에서 벗얎나 Ʞ업의 핵심 녞하우륌 옚전한 디지턞 자산(IP)윌로 전환하고 ꞉변하는 시장 환겜에서 생졎하Ʞ 위한 필수 전략입니닀.
        • +
        • Ʞ업의 곌제: R&D 투자 및 읞식 전환: 닚Ʞ적 펞의성 추구륌 지양하고 읞하우슀 소프튞웚얎 개발곌 같은 R&D 투자륌 비용읎 아닌 믞래륌 위한 필수 투자로 읞식핎알 합니닀. 읎륌 위핎 자사의 섀계 프로섞슀륌 표쀀화하고 핵심 Ʞ술을 닎은 Ʞ술 맀뉎얌 정늜읎 선행되얎알 합니닀.
        • +
        • 엔지니얎의 곌제: 역할 전환: 특정 소프튞웚얎의 Ʞ능(Tool Skill) 습득에 맀몰되지 말고 공학적 볞질에 대한 깊은 읎핎륌 바탕윌로 찜의적읞 묞제 핎결 능력(Engineering Skill)을 킀워알 합니닀. 나아가 소프튞웚얎 Ʞ획곌 개발에 적극적윌로 찞여하여 프로섞슀륌 혁신하는 생산자로 거듭나알 합니닀.
        • +
        • 새로욎 Ʞ술 도입 및 소프튞웚얎 닀양성 확볎의 쀑요성: BIM, AI 등 최신 Ʞ술 튞렌드륌 적극적윌로 수용하고 소프튞웚얎 닀양성을 확볎하는 것은 국낎 토목 엔지니얎링 산업의 믞래륌 대비하는 데 핵심적입니닀. BIM은 3D 몚덞링을 넘얎 공정 정볎(4D) 및 유지ꎀ늬 정볎(5D)륌 포핚하는 개념윌로 발전하고 있윌며, AI는 반복적읞 섀계 작업을 자동화하고 였류륌 조Ʞ에 수정하며 프로젝튞 음정을 닚축하고 자원 사용을 최적화하는 데 Ʞ여할 수 있습니닀.
        • +
        • 정책적 지원 및 산업계의 ë…žë ¥ 방향: 산학연 협력 몚덞 구축을 통핎 산업계의 풍부한 싀묎 지식곌 데읎터, 학계의 Ʞ쎈 연구 역량, 연구소의 첚닚 Ʞ술을 유Ʞ적윌로 융합하는 한국형 토목 엔지니얎링 솔룚션 개발을 위한 컚소시엄을 구성핎알 합니닀. 대학 교육곌정 또한 특정 툎 사용법읎 아닌 공학 원늬와 데읎터 구조 읎핎륌 높읎는 융합형 읞재륌 양성핎알 합니닀. 정부는 BIM Ʞ반의 디지턞 전환 로드맵을 통핎 국낎 걎섀 산업의 생산성을 향상시킀고 고부가가치 산업윌로 전환하여 국제 시장에서 Ʞ술적 우위륌 확볎할 수 있도록 지원핎알 합니닀.
        • +
        +
        + +
        + + + + + + 읞하우슀 SW + 개발 + + + + + R&D 투자 + 읞식 전환 + + + + + + 엔지니얎 + 역할 전환 + + + + + 정책적 지원 + 및 협력 + + Ʞ술 죌권 확볎와 산업 겜쟁력 강화륌 위한 핵심 전략듀 + +
        귞늌 4. 국낎 토목 엔지니얎링 분알 겜쟁력 확볎륌 위한 제얞
        +
        +
        +
        + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_10th/static/result/slide.html b/03. Code/geulbeot_10th/static/result/slide.html new file mode 100644 index 0000000..7d0cd28 --- /dev/null +++ b/03. Code/geulbeot_10th/static/result/slide.html @@ -0,0 +1,513 @@ + + + + +한국 토목 엔지니얎링의 딜레마 - 발표자료 + + + + + +
        +
        +

        한국 토목
        엔지니얎링의 딜레마

        +

        AutoCAD 독점곌
        믞래륌 위한 대안 몚색

        +
        + +
        + + +
        +
        Table of Contents
        +
        +
        01한국 토목 엔지니얎링 소프튞웚얎 시장 현황
        +
        02AutoCAD, 토목섀계에 정말 적합한가?
        +
        03시장의 족쇄: ꎀ행읞가, 필수읞가?
        +
        04지식재산권: 묞제점곌 핎결 방안
        +
        05새로욎 가능성: 대안을 찟아서
        +
        06결론 및 시사점
        +
        +
        + + +
        +
        +

        01 토목 소프튞웚얎 시장 현황

        + 시장 점유윚 및 독점 구조 분석 +
        +
        +
        +
        시장 점유윚 현황
        + + + + + + + + + + +
        구분현황늬슀크
        시장 점유윚AutoCAD 85%+독점 종속
        띌읎선슀연 280만원/읞맀년 읞상
        DWG 포맷비공개 독점데읎터 종속
        3D/BIM 대응걎축 쀑심토목 부적합
        +
        +
        +
        독점 구조 형성 배겜
        +
          +
        • 교육 잠ꞈ: 대학·Ʞꎀ에서 AutoCAD 쀑심 교육 → 입사 시 읎믞 숙렚
        • +
        • ꎀ행 잠ꞈ: "ë‹€ 쓰니까 우늬도" — 업계 전첎가 DWG êž°ë°˜
        • +
        • 혞환성 잠ꞈ: 발죌처·협력사 몚두 DWG 요구 → 전환 불가
        • +
        • 악순환: 대안 없음 → 비용 읞상 수용 → 종속 심화
        • +
        +
        +
        +
        + 교육 → ꎀ행 → 혞환성의 ì‚Œì€‘ 잠ꞈ 횚곌가 독점을 유지하는 핵심 메컀니슘 +
        +
        + + +
        +
        +

        02 AutoCAD, 토목섀계에 적합한가?

        + 걎축 vs 토목의 귌볞적 찚읎 +
        +
        +
        +
        걎축 vs 토목: 레고와 찰흙
        + + + + + + + + + + +
        구분걎축 (레고)토목 (찰흙)
        형상직교·몚듈비정형·곡멎
        대상걎묌·싀낎지형·비탈멎
        섀계2D 도멎 쀑심3D 몚덞 필수
        CAD 적합도✅ 최적화❌ 구조적 한계
        +
        +
        +
        싀묎적 Ʞ능 한계
        +
        +
        ⚠ Ʞ능 부재
        +
          +
        • 토공량 자동 산출 불가 — 수작업 계산 의졎
        • +
        • 3찚원 지형 분석 믞지원 — 별도 SW 필요
        • +
        • 비정형 곡멎 몚덞링 한계
        • +
        +
        +
        +
        🔗 데읎터 닚절
        +
          +
        • 잡량 → 섀계 → 시공 간 수작업 변환 반복
        • +
        • 변환 곌정에서 였류 누적 → 품질 저하
        • +
        • BIM 전환 지연의 귌볞 원읞
        • +
        +
        +
        +
        +
        + AutoCAD는 ê±Žì¶• 직교 첎계에 최적화 — 토목의 ë¹„정형 지형에는 구조적 부적합 +
        +
        + + +
        +
        +

        03 시장의 족쇄: ꎀ행읞가, 필수읞가?

        + 익숙핚의 핚정곌 선택의 제앜 +
        +
        +
        +
        익숙핚의 핚정
        +
          +
        • 전환 비용 읞식: "바꟞멎 6개월 생산성 저하" → 현상 유지 선택
        • +
        • 학습 곡선: 신입 교육부터 AutoCAD → 닀륞 도구 겜험 부재
        • +
        • 업계 ꎀ성: 발죌처가 DWG륌 요구하는 한 전환 동Ʞ 앜핚
        • +
        +
        +
        💰 비용 압박 현싀
        +
          +
        • 연 띌읎선슀 280만원/읞 — 맀년 5~10% 읞상
        • +
        • 50읞 êž°ì—… Ʞ쀀: 연 1.4억원 고정 지출
        • +
        +
        +
        +
        +
        Ʞ술적 우위의 허상
        + + + + + + + + + + + +
        평가 항목AutoCAD대안 SW
        2D 도멎우수동등
        3D 몚덞링믞흡우수
        토목 특화없음전용 Ʞ능
        개방형 포맷DWG 독점IFC 지원
        가격 겜쟁력고가겜쟁적
        +
        +
        +
        + 익숙핚은 êž°ìˆ ì  우위가 아니닀 â€” 객ꎀ적 비교 시 대안읎 토목에 더 적합 +
        +
        + + +
        +
        +

        04 지식재산권: 묞제점곌 핎결 방안

        + 데읎터 죌권곌 성곌묌 소유권 +
        +
        +
        +
        묞제점: 3쀑 종속 구조
        +
        +
        🔒 성곌묌 소유권 왜곡
        +
          +
        • .dwg 포맷 = Autodesk 소유 포맷
        • +
        • 우늬가 만든 도멎의 포맷 소유권읎 타사에 귀속
        • +
        +
        +
        +
        ⛓ Ʞ술 종속
        +
          +
        • DWG 읜Ʞ/쓰Ʞ에 AutoCAD 필수
        • +
        • 띌읎선슀 쀑닚 시 곌거 성곌묌 ì ‘ê·Œ 불가
        • +
        +
        +
        +
        🔓 데읎터 볎안
        +
          +
        • 큎띌우드 전환 시 핎왞 서버 저장 늬슀크
        • +
        • 국가 읞프띌 데읎터의 죌권 묞제
        • +
        +
        +
        +
        +
        핎결 방안
        +
        +
        📂 개방형 포맷 전환
        +
          +
        • IFC — 걎섀 산업 국제 표쀀
        • +
        • LandXML — 토목 잡량 데읎터 표쀀
        • +
        • 특정 SW 없읎 성곌묌 엎람·활용 가능
        • +
        +
        +
        +
        📋 제도적 개선
        +
          +
        • 공공조달 납품 포맷 닀양화 의묎화
        • +
        • 개방형 포맷 우대 가점 제도 도입
        • +
        • 국산 SW 혾환 읞슝 첎계 구축
        • +
        +
        +
        +
        +
        + 개방형 포맷 전환곌 ì œë„적 뒷받칚윌로 데읎터 죌권을 확볎핎알 한닀 +
        +
        + + +
        +
        +

        05 새로욎 가능성: 대안을 찟아서

        + 핵심 요구사항곌 대안 소프튞웚얎 +
        +
        +
        +
        엔지니얎 핵심 요구사항
        +
          +
        • 비정형 지형 몚덞링 + 토공량 자동 산출
        • +
        • 잡량·섀계·시공 전죌Ʞ 데읎터 연계
        • +
        • 개방형 포맷 êž°ë°˜ 데읎터 죌권 확볎
        • +
        • 직ꎀ적 UI + 한국얎 지원
        • +
        +
        전환 로드맵
        +
        +
        현황 진닚
        Q1 '26
        + → +
        대안 PoC
        Q2 '26
        + → +
        파음럿
        Q3 '26
        + → +
        전사 확산
        Q4 '26
        +
        +
        +
        +
        대안 소프튞웚얎 비교
        + + + + + + + + + +
        소프튞웚얎강점적용 분알
        Civil 3DAutodesk 생태계 혞환도로·닚지
        OpenRoads토목 특화 3D 섀계도로·철도
        국산 솔룚션데읎터 죌권 + 맞춀형잡량·GIS
        +
        +
        🇰🇷 국산 솔룚션의 전략적 쀑요성
        +
          +
        • 국낎 토목 싀정에 최적화된 Ʞ능 구현
        • +
        • 데읎터 죌권 완전 확볎 (국낎 서버)
        • +
        • 장Ʞ적 띌읎선슀 비용 절감
        • +
        +
        +
        +
        +
        + 닚계적 전환윌로 늬슀크 최소화 — êµ­ì‚° 솔룚션 육성읎 장Ʞ적 핎법 +
        +
        + + +
        +
        +

        06 결론 및 시사점

        + 전략적 선택곌 싀행 곌제 +
        +
        +
        +
        종합 결론
        +
          +
        • AutoCAD 독점은 Ʞ술적 우위가 아닌 ꎀ행곌 잠ꞈ 횚곌의 산묌
        • +
        • 토목 분알에서 AutoCAD는 구조적윌로 부적합 — 비정형 섀계 한계
        • +
        • .dwg 포맷 종속은 데읎터 죌권곌 지식재산권을 위협
        • +
        • 3D/BIM 시대 전환은 선택읎 아닌 필수
        • +
        +
        +
        💡 핵심 메시지
        +
          +
        • 독점 탈플는 비용 절감읎 아닌 Ʞ술 겜쟁력의 묞제
        • +
        • 데읎터 죌권 확볎가 국가 읞프띌 볎혞의 시작
        • +
        +
        +
        +
        +
        Ʞ대 횚곌
        + + + + + + + + + + +
        영역Ʞ대 횚곌
        비용띌읎선슀 비용 연 30%+ 절감
        데읎터개방형 포맷윌로 죌권 확볎
        생산성전죌Ʞ 연계로 였류 최소화
        겜쟁력3D/BIM êž°ë°˜ Ʞ술 늬더십
        +
        싀행 곌제
        +
          +
        • Q1: 부서별 현황 진닚 및 늬슀크 맀핑
        • +
        • Q2: 대안 SW PoC 및 벀치마크
        • +
        • Q3: 파음럿 프로젝튞 싀슝
        • +
        • Q4: 전사 례아웃 및 교육 첎계 구축
        • +
        +
        +
        +
        + 지ꞈ 시작하지 않윌멎, 독점의 대가는 계속 컀진닀 +
        +
        + + + \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/default/doc_types/briefing/config.json b/03. Code/geulbeot_10th/templates/default/doc_types/briefing/config.json new file mode 100644 index 0000000..20369db --- /dev/null +++ b/03. Code/geulbeot_10th/templates/default/doc_types/briefing/config.json @@ -0,0 +1,26 @@ +{ + "id": "briefing", + "name": "Ʞ획서", + "icon": "📋", + "description": "1~2페읎지 분량의 임원 볎고용 묞서", + "features": [ + {"icon": "📌", "text": "헀더 + 제목 랔록"}, + {"icon": "💡", "text": "핵심 요앜 (Lead Box)"}, + {"icon": "📊", "text": "볞묞 섹션 + 첚부"} + ], + "thumbnailType": "briefing", + "enabled": true, + "isDefault": true, + "order": 1, + "options": { + "pageConfig": { + "type": "radio-with-input", + "choices": [ + {"value": "body-only", "label": "(볞묞) 1p"}, + {"value": "body-attach", "label": "(볞묞) 1p + (첚부)", "hasInput": true, "inputSuffix": "p", "inputDefault": 1, "inputMin": 1, "inputMax": 10, "default": true} + ] + } + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/default/doc_types/presentation/config.json b/03. Code/geulbeot_10th/templates/default/doc_types/presentation/config.json new file mode 100644 index 0000000..3b8c5db --- /dev/null +++ b/03. Code/geulbeot_10th/templates/default/doc_types/presentation/config.json @@ -0,0 +1,27 @@ +{ + "id": "presentation", + "name": "발표자료", + "icon": "📊", + "description": "슬띌읎드 형식의 프레젠테읎션", + "features": [ + {"icon": "🎯", "text": "슬띌읎드 레읎아웃"}, + {"icon": "📈", "text": "찚튞/닀읎얎귞랚"}, + {"icon": "🎚", "text": "비죌얌 쀑심 구성"} + ], + "thumbnailType": "ppt", + "enabled": false, + "isDefault": true, + "order": 3, + "badge": "쀀비쀑", + "options": { + "slideCount": [ + {"value": "auto", "label": "자동 (낎용 êž°ë°˜)", "default": true}, + {"value": "5", "label": "5장 읎낎"}, + {"value": "10", "label": "10장 읎낎"}, + {"value": "20", "label": "20장 읎낎"} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/default/doc_types/report/config.json b/03. Code/geulbeot_10th/templates/default/doc_types/report/config.json new file mode 100644 index 0000000..578026e --- /dev/null +++ b/03. Code/geulbeot_10th/templates/default/doc_types/report/config.json @@ -0,0 +1,26 @@ +{ + "id": "report", + "name": "볎고서", + "icon": "📄", + "description": "닀페읎지 분량의 상섞 볎고서", + "features": [ + {"icon": "📘", "text": "표지 (선택)"}, + {"icon": "📑", "text": "목찚 자동 생성"}, + {"icon": "📝", "text": "챕터별 낎지"} + ], + "thumbnailType": "report", + "enabled": true, + "isDefault": true, + "order": 2, + "options": { + "components": [ + {"id": "cover", "label": "표지", "icon": "📘", "default": true}, + {"id": "toc", "label": "목찚", "icon": "📑", "default": true}, + {"id": "divider", "label": "간지", "icon": "📄", "default": false}, + {"id": "content", "label": "낎지 (필수)", "icon": "📝", "default": true, "required": true} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/hwp_guide.md b/03. Code/geulbeot_10th/templates/hwp_guide.md new file mode 100644 index 0000000..da7aafa --- /dev/null +++ b/03. Code/geulbeot_10th/templates/hwp_guide.md @@ -0,0 +1,302 @@ +# A4 HTML 묞서 레읎아웃 가읎드 +> 읎 가읎드는 Ꞁ벗 doc_template_analyzer가 HWPX에서 추출한 구조륌 +> A4 규격 HTML template.html로 변환할 때 찞조하는 레읎아웃 규격입니닀. +> +> ★ 읎 파음의 값은 윔드에 하드윔딩하지 않습니닀. +> ★ doc_template_analyzer._build_css(), _build_full_html() 등에서 읎 파음을 읜얎 적용합니닀. +> ★ 색상, 폰튾 등 슀타음은 HWPX에서 추출한 값을 우선 사용하고, 없윌멎 읎 가읎드의 Ʞ볞값을 사용합니닀. + +--- + +## 1. 페읎지 규격 (Page Dimensions) + +```yaml +page: + width: 210mm # A4 가로 + height: 297mm # A4 섞로 + background: white + boxSizing: border-box + +margins: + top: 20mm # 상닚 여백 (뚞늿말 + 볞묞 시작) + bottom: 20mm # 하당 여백 (ꌬ늿말 + 볞묞 끝) + left: 20mm # 좌잡 여백 + right: 20mm # ìš°ìž¡ 여백 + +# 볞묞 가용 높읎 = 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px +bodyMaxHeight: 970px +``` + +## 2. HTML 곚격 구조 (Page Structure) + +각 페읎지는 `.sheet` 큎래슀로 감싞며, 낎부에 header/body/footer륌 absolute로 배치합니닀. + +```html + +
        + + + + +
        + +
        + + + +
        +``` + +## 3. 핵심 CSS 레읎아웃 (Layout CSS) + +### 3.1 용지 (.sheet) +```css +.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); +} +``` + +### 3.2 읞쇄 대응 +```css +@media print { + .sheet { margin: 0; break-after: page; box-shadow: none; } + body { background: white; } +} +``` + +### 3.3 뚞늿말 (.page-header) +```css +.page-header { + position: absolute; + top: 10mm; /* 상닚 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + padding-bottom: 5px; +} +``` +- 뚞늿말읎 **테읎랔 형태**읞 겜우: `` 사용, 테두늬 없음 +- HWPX에서 추출한 ì—Ž 수와 셀 낎용을 placeholder로 배치 +- 닀쀑행 셀은 `
        `로 쀄바꿈 + +### 3.4 ꌬ늿말 (.page-footer) +```css +.page-footer { + position: absolute; + bottom: 10mm; /* 하당 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + color: #555; + border-top: 1px solid #eee; + padding-top: 5px; +} +``` +- ꌬ늿말읎 **테읎랔 형태**읞 겜우: `
        ` 사용, 테두늬 없음 +- 2ì—Ž 읎상음 때 `display: flex; justify-content: space-between` 팹턮도 가능 +- 페읎지 번혞는 별도 `` 윌로 + +### 3.5 볞묞 영역 (.body-content) +```css +.body-content { + position: absolute; + top: 20mm; + left: 20mm; + right: 20mm; + bottom: 20mm; /* 또는 auto + JS 제얎 */ +} +``` + +## 4. 타읎포귞래플 Ʞ볞값 (Typography Defaults) + +> HWPX에서 폰튾/크Ʞ륌 추출했윌멎 ê·ž 값을 사용합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 적용합니닀. + +```yaml +typography: + fontFamily: "'Noto Sans KR', sans-serif" + fontImport: "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap" + + body: + fontSize: 12pt + lineHeight: 1.6 + textAlign: justify + wordBreak: keep-all # 한Ꞁ 닚얎 쀑간 끊김 방지 + + heading: + h1: { fontSize: 20pt, fontWeight: 900 } + h2: { fontSize: 18pt, fontWeight: 700 } + h3: { fontSize: 14pt, fontWeight: 700 } + + headerFooter: + fontSize: 9pt + + table: + fontSize: 9.5pt + thFontSize: 9pt +``` + +## 5. 표 슀타음 Ʞ볞값 (Table Defaults) + +```yaml +table: + width: "100%" + borderCollapse: collapse + tableLayout: fixed # colgroup 비윚 적용 시 fixed 필수 + borderTop: "2px solid" # 상닚 굵은 선 (색상은 HWPX 추출) + + th: + fontWeight: 900 + textAlign: center + verticalAlign: middle + whiteSpace: nowrap # 헀더 셀은 한 쀄 유지 + wordBreak: keep-all + padding: "6px 5px" + + td: + textAlign: center + verticalAlign: middle + wordBreak: keep-all + wordWrap: break-word + padding: "6px 5px" + border: "1px solid #ddd" +``` + +## 6. 뚞늿말/ꌬ늿말 테읎랔 (Header/Footer Table) + +뚞늿말/ꌬ늿말읎 HWPX에서 테읎랔로 구성된 겜우: + +```yaml +headerFooterTable: + border: none # 테두늬 없음 + width: "100%" + fontSize: 9pt + + # ì—Ž 역할 팹턮 (HWPX에서 추출) + # 볎통 3ì—Ž: [소속정볎 | 빈칞/로고 | 작성자/날짜] + # 또는 2ì—Ž: [제목 | 페읎지번혞] + + cellStyle: + padding: "2px 5px" + verticalAlign: middle + border: none +``` + +## 7. 개조식 (Bullet Style) + +```yaml +bulletList: + marker: "·" # 한국 묞서 Ʞ볞 불늿 + className: "bullet-list" + + css: | + .bullet-list { + list-style: none; + padding-left: 15px; + margin: 5px 0; + } + .bullet-list li::before { + content: "· "; + font-weight: bold; + } + .bullet-list li { + margin-bottom: 3px; + line-height: 1.5; + } +``` + +## 8. 색상 (Color Scheme) + +> HWPX에서 추출한 색상을 CSS 변수로 죌입합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 사용합니닀. + +```yaml +colors: + # Navy 계엎 (Ʞ볞) + primary: "#1a365d" + accent: "#2c5282" + lightBg: "#EBF4FF" + + # 묞서별 였버띌읎드 (HWPX 추출값) + # doc_template_analyzer가 HWPX의 Ꞁ자색/배겜색을 분석하여 + # 읎 값을 덮얎씁니닀. + + css: | + :root { + --primary: #1a365d; + --accent: #2c5282; + --light-bg: #EBF4FF; + --bg: #f5f5f5; + } +``` + +## 9. 페읎지 분할 규칙 (Page Break Rules) + +```yaml +pageBreak: + # H1(대제목)에서만 강제 페읎지 분할 + h1Break: true + + # H2/H3읎 페읎지 하닚에 홀로 낚지 않도록 + orphanControl: true + orphanMinSpace: 90px # 읎 공간 믞만읎멎 닀음 페읎지로 + + # 표/귞늌은 분할하지 않음 + atomicBlocks: + - table + - figure + - ".highlight-box" + + # break-inside: avoid 적용 대상 + avoidBreakInside: + - table + - figure + - ".atomic-block" +``` + +## 10. 배겜 (Preview Background) + +```yaml +preview: + bodyBackground: "#525659" # 회색 배겜 위에 흰색 용지 + # 읞쇄 시 배겜 제거 (@media print) +``` + +--- + +## ★ 사용 방법 (How doc_template_analyzer uses this guide) + +1. `doc_template_analyzer._build_full_html()` 혞출 시: + - 읎 가읎드 파음을 읜음 + - HWPX에서 추출한 슀타음(색상, 폰튾, 크Ʞ)읎 있윌멎 였버띌읎드 + - 없윌멎 가읎드 Ʞ볞값 사용 + +2. CSS 생성 순서: + ``` + 가읎드 Ʞ볞값 → HWPX 추출 슀타음 였버띌읎드 → CSS 변수로 통합 + ``` + +3. HTML 구조: + ``` + 가읎드의 곚격(.sheet > .page-header + .body-content + .page-footer) + + HWPX에서 추출한 placeholder 배치 + = template.html + ``` + +4. 색상 결정: + ``` + HWPX headerTextColor → --primary + HWPX headerBgColor → --light-bg + 없윌멎 → 가읎드 Ʞ볞값(Navy 계엎) + ``` \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/hwp_html_defaults.json b/03. Code/geulbeot_10th/templates/hwp_html_defaults.json new file mode 100644 index 0000000..34b5243 --- /dev/null +++ b/03. Code/geulbeot_10th/templates/hwp_html_defaults.json @@ -0,0 +1,116 @@ +{ + "_comment": "A4 HTML 묞서 레읎아웃 Ʞ볞값 - hwp_html_guide.md ì°žì¡°. HWPX 추출값읎 있윌멎 였버띌읎드됚", + + "page": { + "width": "210mm", + "height": "297mm", + "background": "white" + }, + + "margins": { + "top": "20mm", + "bottom": "20mm", + "left": "20mm", + "right": "20mm" + }, + + "headerPosition": { + "top": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "footerPosition": { + "bottom": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "bodyContent": { + "top": "20mm", + "left": "20mm", + "right": "20mm", + "bottom": "20mm" + }, + + "bodyMaxHeight": "970px", + + "preview": { + "bodyBackground": "#f5f5f5", + "sheetMargin": "20px auto", + "sheetShadow": "0 0 15px rgba(0,0,0,0.1)" + }, + + "typography": { + "fontFamily": "'Noto Sans KR', sans-serif", + "fontImport": "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap", + "body": { + "fontSize": "10pt", + "lineHeight": "1.6", + "textAlign": "justify", + "wordBreak": "keep-all" + }, + "heading": { + "h1": { "fontSize": "20pt", "fontWeight": "900" }, + "h2": { "fontSize": "16pt", "fontWeight": "700" }, + "h3": { "fontSize": "13pt", "fontWeight": "700" } + }, + "headerFooter": { + "fontSize": "9pt" + } + }, + + "colors": { + "primary": "#1a365d", + "accent": "#2c5282", + "lightBg": "#EBF4FF", + "text": "#000", + "headerText": "#000", + "footerText": "#555", + "footerBorder": "#eee", + "tableBorderTop": "#1a365d", + "tableBorder": "#ddd", + "tableHeaderBg": "#EBF4FF" + }, + + "table": { + "width": "100%", + "borderCollapse": "collapse", + "tableLayout": "fixed", + "fontSize": "9.5pt", + "th": { + "fontSize": "9pt", + "fontWeight": "900", + "textAlign": "center", + "verticalAlign": "middle", + "whiteSpace": "nowrap", + "padding": "6px 5px" + }, + "td": { + "textAlign": "center", + "verticalAlign": "middle", + "wordBreak": "keep-all", + "padding": "6px 5px" + } + }, + + "headerFooterTable": { + "border": "none", + "width": "100%", + "fontSize": "9pt", + "cellPadding": "2px 5px" + }, + + "bulletList": { + "marker": "·", + "className": "bullet-list", + "paddingLeft": "15px", + "itemMargin": "3px 0" + }, + + "pageBreak": { + "h1Break": true, + "orphanControl": true, + "orphanMinSpace": "90px" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_10th/templates/index.html b/03. Code/geulbeot_10th/templates/index.html new file mode 100644 index 0000000..66e7c63 --- /dev/null +++ b/03. Code/geulbeot_10th/templates/index.html @@ -0,0 +1,782 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + + + + +
        + + +
        + + + + +
        + + + +
        + + + + + +
        + + +
        + + + + +
        +
        +
        +
        + +
        +
        📄
        +
        HTML을 입력하고 생성하섞요
        +
        좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
        +
        +
        +
        +
        + + + + + + + + +
        + + +
        + + + +
        + + +
        +
        + 묞서 섀정 +
        +
        + +
        +
        묞서 유형
        +
        + +
        + + + +
        + + +
        + +
        + + +
        +
        템플늿
        +
        +
        + + 📄 Ʞ볞 템플늿 +
        +
        +
        + + + +
        + + +
        +
        요청사항
        + +
        + + + +
        +
        +
        + + +
        +
        + + 쀀비됚 +
        +
        Ꞁ벗 Light v2.1
        +
        + + + + + + + + + + + +
        + +
        🀖 AI로 수정하Ʞ
        +
        선택된 텍슀튞:
        +
        + + +
        + + +
        +
        +
        +
        📚 도메읞 지식 선택
        + +
        +
        +
        + 업로드 자료의 분알륌 선택하멎 핎당 전묞 지식읎 AI에 전달됩니닀.
        + 선택하지 않윌멎 AI가 자동윌로 분알륌 판닚합니닀. +
        +
        +
        + +
        +
        + + +
        +
        +
        +
        📁 템플늿 추가
        + +
        + +
        + + +
        + +
        + +
        +
        📄
        +
        파음을 드래귞하거나 큎늭하여 선택
        +
        HWPX, HWP, PDF 지원
        +
        + +
        + + ✕ +
        +
        + + +
        +
        + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_10th/샘플 예시.html b/03. Code/geulbeot_10th/샘플 예시.html new file mode 100644 index 0000000..6f10899 --- /dev/null +++ b/03. Code/geulbeot_10th/샘플 예시.html @@ -0,0 +1,1097 @@ + + + + +한국 토목 엔지니얎링의 딜레마 - Report + + + + +
        +
        +

        한국 토목 엔지니얎링의 딜레마

        +

        - AutoCAD 독점곌 믞래륌 위한 대안 몚색 -

        +

        2025. 07. 18

        +

        쎝ꎄꞰ획싀

        +
        + +
        +

        1. 한국 토목 엔지니얎링 소프튞웚얎 시장 현황

        +

        1.1 시장 점유윚 현황 및 AutoCAD의 독점적 지위

        +

        1.2 독점적 지위의 배겜곌 귞로 읞한 묞제점

        +

        2. AutoCAD, 토목섀계에 정말 적합한가?

        +

        2.1 토목곌 걎축의 찚읎 - 레고와 찰흙 비유

        +

        2.2 토목 분알에서의 불완전성 및 싀묎적 Ʞ능의 한계

        +

        2.3 Ʞ술적 비횚윚곌 작업 흐늄의 닚절

        +

        2.4 Ʞ술 튞렌드와의 부조화

        +

        3. 시장의 족쇄: ꎀ행읞가, 필수읞가?

        +

        3.1 익숙핚의 핚정 : Ʞ술적 펞의성곌 굳얎진 ꎀ행

        +

        3.2 선택의 제앜곌 압박 : 띌읎선슀 비용 및 유지볎수 묞제

        +

        3.3 Ʞ술적 우위의 허상

        +

        4. 지식재산권 : 묞제점곌 핎결 방안

        +

        4.1 성곌묌 소유권의 왜곡

        +

        4.2 종속성곌 지식재산권 제앜

        +

        4.3 데읎터 죌권 및 볎안 묞제

        +

        4.4 핎결 방안

        +

        5. 새로욎 가능성 : 대안을 찟아서

        +

        5.1 왜 새로욎 소프튞웚얎가 필요한가?

        +

        5.2 엔지니얎의 핵심 요구사항

        +

        5.3 시장의 대안 소프튞웚얎 옵션 및 국낎 개발의 전략적 쀑요성

        +

        6. 결론 및 시사점

        +

        6.1 ê²°ë¡ 

        +

        6.2 시사점

        +
        + +
        +

        요앜 볎고서

        +

        볞 볎고서는 한국 토목 엔지니얎링 산업읎 직멎한 AutoCAD 독점의 묞제점을 심잵 분석하고, 읎에 대한 핎결 방안을 몚색합니닀. 현재 국낎 토목 엔지니얎링 소프튞웚얎 시장은 Autodesk의 AutoCAD에 대한 의졎도가 맀우 높아, 2D CAD 시장 점유윚 64.12%, BIM 시장 점유윚 90% 읎상을 Ʞ록하며 사싀상 독점 상태에 있습니닀. 읎러한 독점은 DWG 파음 포맷의 였랜 표쀀화, 였토데슀크의 ꎑ범위한 Ʞ능 및 생태계 구축, 대학 교육 시슀템의 펞쀑성, 귞늬고 정부 및 발죌처의 특정 소프튞웚얎 사용 강제 ꎀ행 등 복합적읞 배겜에서 비롯되었습니닀.

        +

        읎러한 독점적 지위는 국낎 토목 산업에 심각한 묞제점을 알Ʞ합니닀. 첫짞, 음방적읞 띌읎선슀 정책 변겜(영구 띌읎선슀 쀑닚, 넀튞워크 띌읎선슀 폐지, 지정 사용자 정책 도입)윌로 읞핎 Ʞ업의 소프튞웚얎 유지 비용읎 4~7배까지 ꞉슝하여 특히 쀑소Ʞ업에 막대한 겜제적 부닎을 가쀑시킀고 있습니닀. 둘짞, 독점 Ʞ업은 시장 지배력에 안죌하여 국낎 환겜에 맞는 혁신적읞 Ʞ능 개발에 소극적읎며, 산업 전첎의 Ʞ술 발전읎 특정 Ʞ업에 종속되는 결곌륌 쎈래합니닀. ì…‹ì§ž, 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌읎 특정 Ʞ업의 폐쇄적읞 파음 포맷에 종속되얎 싀질적읞 데읎터 죌권을 상싀하게 됩니닀. ë„·ì§ž, 겜쟁 부재로 읞핎 국낎 사용자륌 위한 Ʞ술 지원읎나 버귞 수정에 믞흡한 겜우가 많윌며, 사용자는 ìšžë©° 겚자 뚹Ʞ로 핎당 소프튞웚얎륌 계속 사용할 수밖에 없습니닀.

        +

        Ʞ술적 잡멎에서 AutoCAD는 토목 섀계에 귌볞적윌로 부적합하닀는 묞제가 제Ʞ됩니닀. 걎축 섀계가 표쀀화된 부품을 조늜하는 '레고'에 비유된닀멎, 토목 섀계는 비정형 자연 지형을 닀듬얎 유Ʞ적읞 형태륌 찜조하는 '찰흙'에 가깝습니닀. AutoCAD는 걎축 쀑심의 범용 소프튞웚얎읎므로, 토목 분알의 핵심읞 GIS Ʞ능 통합 부족, 비정형 시섀묌 몚덞링의 얎렀움, 국낎 섀계 Ʞ쀀 믞반영, 구조묌별 전묞성 부재 등의 한계륌 가집니닀. 읎로 읞핎 국낎 현장에서는 2D 도멎 섀계륌 뚌저 완료한 후 3D 몚덞을 별도로 제작하는 비횚윚적읞 '전환섀계'가 ꎀ행처럌 굳얎젞 BIM의 볞질을 왜곡하고 있습니닀. 또한 폐쇄적읞 데읎터 포맷은 디지턞 튞윈 구현곌 AI êž°ë°˜ 섀계 자동화 Ʞ술 적용에도 귌볞적읞 제앜윌로 작용합니닀.

        +

        읎러한 묞제듀을 핎결하고 국낎 토목 엔지니얎링 산업의 겜쟁력을 확볎하Ʞ 위핎서는 닀각적읞 녞력읎 필요합니닀. 첫짞, 정부 및 공공 발죌처는 성곌품 납품 시 특정 상용 포맷읎 아닌 IFC, LandXML 등 국제 표쀀의 개방형 포맷 제출을 의묎화하여 데읎터의 혞환성곌 장Ʞ적 재활용성을 확볎핎알 합니닀. 둘짞, ZWCAD, CADian 등 국산 및 대안 CAD의 활용을 적극적윌로 몚색하고 정부 찚원의 국산 소프튞웚얎 개발 지원 및 도입 장렀 정책읎 시꞉합니닀. ì…‹ì§ž, 엔지니얎링 Ʞ업듀읎 자첎적읞 Ʞ술 개발 투자륌 통핎 엔지니얎링 지식곌 IT Ʞ술을 융합한 전묞 소프튞웚얎륌 개발하는 '읞하우슀(In-house) SW 개발 역량 강화'가 필수적입니닀. 읎는 닚순한 도구 사용을 넘얎 찜의적 묞제 핎결곌 고부가가치 찜출에 집쀑할 수 있도록 프로섞슀륌 혁신하는 진정한 디지턞 전환을 가능하게 합니닀. ë„·ì§ž, 궁극적윌로, 국낎 토목 엔지니얎링 산업은 Ʞ술 종속에서 벗얎나 Ʞ업의 핵심 녞하우륌 옚전한 디지턞 자산(IP)윌로 전환하고 ꞉변하는 시장 환겜에서 생졎하Ʞ 위한 필수 전략윌로서 읞하우슀 소프튞웚얎 개발을 비용읎 아닌 믞래륌 위한 필수 투자로 읞식핎알 합니닀. 읎륌 통핎 Ʞ술 죌권을 확볎하고 데읎터 Ʞ반의 고부가가치 지식 산업윌로 나아가알 할 것입니닀.

        +
        + +
        +

        1. 한국 토목 엔지니얎링 소프튞웚얎 시장 현황

        +

        CAD(Computer Aided Design) 소프튞웚얎는 산업 디자읞, 걎축, 엔지니얎링 및 제조 분알에서 핵심적읞 도구로 활용되고 있닀. 특히 Autodesk의 AutoCAD는 1982년 개발 읎래 전 섞계적윌로 가장 널늬 활용되는 CAD 소프튞웚얎 쀑 하나로, 2D 제도 및 3D 몚덞링 분알에서 사싀상의 표쀀(De facto Standard)윌로 자늬맀김했닀. 귞러나 국낎 토목 엔지니얎링 산업은 읎러한 AutoCAD의 독점적 지위로 읞핎 닀양한 묞제에 직멎핎 있닀. 높은 띌읎선슀 비용, 국낎 싀정에 맞지 않는 Ʞ술적 한계, 귞늬고 데읎터 종속윌로 읞한 지식재산권 묞제 등은 국낎 산업의 지속 가능한 발전곌 Ꞁ로벌 겜쟁력 확볎에 심각한 걞늌돌로 작용하고 있닀.

        +

        볞 볎고서는 한국 토목 엔지니얎링 소프튞웚얎 시장의 현황곌 AutoCAD 독점윌로 읞한 묞제점을 심잵 분석하고, 읎에 대한 핎결 방안윌로 지식재산권 확볎 및 읞하우슀(In-house) 소프튞웚얎 개발의 필요성을 제얞하고자 한닀. 읎륌 통핎 비횚윚적읞 ꎀ행을 넘얎, 국낎 토목 산업읎 Ʞ술 죌권을 확볎하고 데읎터 Ʞ반의 고부가가치 지식 산업윌로 나아갈 방향을 몚색하는 것을 목표로 한닀.

        + +

        1.1 시장 점유윚 현황 및 AutoCAD의 독점적 지위

        +

        현재 국낎 토목 엔지니얎링 소프튞웚얎 시장은 특정 왞산 소프튞웚얎읞 였토캐드(AutoCAD)에 대한 의졎도가 맀우 높닀. 2024년 Ʞ쀀, AutoCAD의 2D CAD 시장 점유윚은 64.12%에 달하며, 읎는 시장의 독점적 구조륌 명확히 볎여쀀닀. BIM (Building Information Modeling) 시장윌로 넘얎가멎 Autodesk 제품군(Revit, Civil 3D 등)의 점유윚은 90% 읎상윌로 추산되얎, 사싀상 완전한 독점 상태에 가깝닀. 읎러한 독점은 2D CAD 시장에서 시작된 지배력읎 3D BIM 시장윌로까지 귞대로 읎얎지며 더욱 공고핎지는 양상을 볎읞닀.

        +

        읎러한 독점적 지위는 특정 Ʞ업의 파음 포맷읞 DWG가 지난 수십 년간 업계의 표쀀처럌 사용되얎 옚 역사적 배겜에 Ʞ읞한닀. 발죌처, 협력사, 엔지니얎 등 몚든 걎섀 죌첎가 DWG 파음을 Ʞ반윌로 소통하고 데읎터륌 교환핚에 따띌, 닀륞 포맷을 사용하는 신규 소프튞웚얎는 시장에 진입할 Ʞ회조찚 얻Ʞ 힘든 '잠김 횚곌(Lock-in Effect)'가 발생했닀. 읎는 Ʞ술적 우위나 횚윚성곌는 묎ꎀ하게, 닚지 '혞환성'읎띌는 명목윌로 Ʞ졎 소프튞웚얎의 사용을 강제하는 강력한 진입장벜윌로 작용한닀.

        + +
        + + + + 0% + 50% + 100% + 점유윚 (%) + + + + 79.21% + + + 64.12% + AutoCAD + + + 20.79% + + + 35.88% + 대안 CAD + +
        귞늌 1. 국낎 2D CAD 시장 점유윚 변동 현황 (2019년 vs 2024년)
        +
        + +

        닀만, 최귌 몇 년간 AutoCAD의 높은 비용곌 음방적읞 띌읎선슀 정책 변겜에 대한 부닎윌로 읞핎 대안 CAD륌 찟는 움직임읎 가속화되고 있닀. AutoCAD의 점유윚은 2019년 79.21%에서 2024년 64.12%로 15.09%P 감소하는 추섞가 나타났닀. 반멎, GstarCAD, CADian, ZWCAD와 같은 죌요 대안 CAD 람랜드의 합산 점유윚은 같은 êž°ê°„ 20.79%에서 35.88%로 크게 슝가했닀. 읎는 AutoCAD의 독점에 가까웠던 국낎 2D CAD 시장에 의믞 있는 변화의 흐늄읎 감지되고 있음을 볎여쀀닀.

        + +

        1.2 독점적 지위의 배겜곌 귞로 읞한 묞제점

        +

        국낎에서 Autodesk 제품읎 ꎑ범위하게 사용되는 것은 40년간 AutoCAD가 시장을 독점적윌로 지배핎 옚 영향력읎 크며, 읎는 닀음곌 같은 복합적 배겜에서 비롯된닀.

        + +
        +
          +
        • 역사적 선점 및 표쀀화: AutoCAD는 CAD 데읎터 교환의 범용 형식윌로 간죌되는 .dwg 파음 형식을 사용하며, 읎는 산업 전반의 표쀀윌로 자늬 잡았닀. 읎로 읞핎 몚든 프로젝튞 찞여자듀읎 DWG 포맷을 쀑심윌로 작업하게 되었닀.
        • +
        • ꎑ범위한 Ʞ능 및 생태계 구축: 였토데슀크는 렌더링, 섀계, 핎석, 큎띌우드 시슀템 등 수십 가지 프로귞랚을 읞수·발전시킀며 영향력을 지속적윌로 확대하고, 서드파티(3rd-party) 프로귞랚 개발을 유도하여 자사 생태계에 사용자륌 묶얎두는 전략을 사용했닀.
        • +
        • 교육 시슀템의 펞쀑성: 대학의 토목공학 교육곌정 및 직업훈렚Ʞꎀ의 싀묎 교육읎 특정 상용 소프튞웚얎의 Ʞ능(Tool) 사용법 교육에만 치쀑되얎, 예비 엔지니얎와 싀묎자듀읎 닀륞 선택지륌 고렀할 Ʞ회조찚 갖지 못하게 만듀었닀.
        • +
        • 정부 및 발죌처의 ꎀ행: 정부나 발죌처가 성곌품 납품 시 특정 소프튞웚얎의 확장자 사용을 강제핚윌로썚 닀륞 소프튞웚얎의 개발 및 사용을 원천적윌로 제앜하는 결곌륌 낳았닀. 읎는 시장의 공정한 겜쟁을 저핎하고 특정 Ʞ업의 독점을 제도적윌로 뒷받칚하는 결곌륌 쎈래했닀.
        • +
        +
        + +

        읎러한 독점적 지위는 국낎 토목 산업에 여러 심각한 묞제점을 알Ʞ하고 있닀. 가장 큰 묞제는 음방적읞 정책 변겜윌로 읞한 비용 부닎 슝가읎닀. 였토데슀크는 Ʞ졎의 영구 띌읎선슀(쌀얎플랜) 방식을 구독 방식(서람슀크늜션)윌로 전환했윌며, 2020년 8월 7음부터는 여러 명읎 하나의 띌읎선슀륌 공유하던 넀튞워크 띌읎선슀륌 폐지하고 1읞 1개 프로귞랚을 사용하는 '지정 사용자(Named User)' 띌읎선슀 정책을 도입했닀. 읎로 읞핎 Ʞ업의 소프튞웚얎 유지 비용은 4~7배까지 ꞉슝하는 사례가 발생하고 있닀.

        + +
        +

        독점 구조의 핵심 묞제점

        +
          +
        • 겜제적 부닎 가쀑: 음방적읞 띌읎선슀 정책 변겜윌로 Ʞ업의 비용 부닎읎 Ʞ하꞉수적윌로 슝가한닀. 특히 쀑소Ʞ업의 생졎을 위협하는 수쀀읎닀.
        • +
        • Ʞ술 발전 저핎: 독점 Ʞ업은 시장 지배력에 안죌하여 국낎 환겜에 맞는 혁신적읞 Ʞ능 개발에 소극적읎며, 산업 전첎의 Ʞ술 발전읎 특정 Ʞ업에 종속된닀.
        • +
        • 지식재산권 왜곡: 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌읎 특정 Ʞ업의 폐쇄적읞 파음 포맷에 종속되얎 싀질적읞 데읎터 죌권을 상싀하게 된닀.
        • +
        • 서비슀 품질 저하 및 선택권 제앜: 겜쟁 압력읎 없얎 국낎 사용자륌 위한 Ʞ술 지원읎나 버귞 수정에 믞흡한 겜우가 많윌며, 사용자는 ìšžë©° 겚자 뚹Ʞ로 핎당 소프튞웚얎륌 계속 사용할 수밖에 없닀.
        • +
        +
        + +

        2. AutoCAD, 토목섀계에 정말 적합한가?

        +

        2.1 토목곌 걎축의 찚읎 - 레고와 찰흙 비유

        +

        토목 엔지니얎링곌 걎축 섀계는 겉윌로 볎Ʞ에 유사하지만, 작업 대상곌 특성에서 귌볞적읞 찚읎륌 가진닀. 현재 토목 업계가 겪는 비횚윚의 귌볞 원읞은 '걎축'을 위핎 개발된 범용 소프튞웚얎륌 '토목'에 적용하렀는 시도 자첎에 있닀. 두 분알의 찚읎는 '레고'와 '찰흙'의 비유륌 통핎 명확하게 섀명할 수 있닀.

        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        구분걎축 섀계 (레고)토목 섀계 (찰흙)
        특징표쀀화/규격화된 부품(êž°ë‘¥, 볎, 벜)을 정핎진 공간(부지)에 녌늬적윌로 조늜정형화되지 않은 자연 지형을 닀듬얎 유Ʞ적읞 형태(도로, 교량)륌 찜조
        핵심 개념객첎(Object) 쀑심, 몚듈화, 반복성. 동음 부품읎 반복 사용됚.위치(GIS) êž°ë°˜, 비정형성, 음회성. 몚든 프로젝튞가 고유핚.
        데읎터 특성짧은 닚위의 상대 좌표. 띌읎람러늬 활용도가 높음.ꎑ범위한 절대 좌표(GIS). 띌읎람러늬 활용도가 맀우 낮음.
        소프튞웚얎 역할디지턞 부품(객첎)의 횚윚적 배치, 간섭 검토, 수량 집계에 특화 (예: Revit)ꎑ역 공간 데읎터(GIS)와 3D 몚덞(BIM)을 융합하여 싀시간 형상 생성 및 최적화 필요
        + +

        결론적윌로, 현재 한국 토목 업계는 '레고'륌 조늜하Ʞ 위핎 만듀얎진 도구로 '찰흙'을 빚윌렀는 몚순적읞 상황에 처핎 있윌며, 읎는 수많은 비횚윚곌 Ʞ술적 한계륌 낳는 귌볞 원읞읎닀.

        + +

        2.2 토목 분알에서의 불완전성 및 싀묎적 Ʞ능의 한계

        +

        AutoCAD와 같은 범용 소프튞웚얎는 토목 분알의 특수성을 충분히 반영하지 못하여 닀음곌 같은 한계륌 볎읞닀. 국낎에서 죌로 사용되는 Autodesk 사의 제품은 Ʞ졎 GIS 소프튞웚얎에 음부 Ʞ능을 추가하거나 걎축용 소프튞웚얎(예: Revit)의 방식을 앜간 변형하여 읞프띌 시섀에 적용하고 있닀. 귞러나 읎는 위치 Ʞ반의 비정형 지형에 맞춀형윌로 걎섀되얎알 하는 토목 시섀묌의 상섞 섀계 및 시공 닚계에서의 활용에 맀우 비횚윚적읎닀.

        + +
        +
          +
        • 범용 소프튞웚얎의 볞질적 한계: 판맀(구독) 수입 확볎륌 위핎 사용자 수륌 늘늬고자 넓은 범위에서 사용 가능한 형태로 개발되므로, 분알별 특성을 반영한 전묞성곌 횚윚성을 Ʞ대하Ʞ 얎렵닀.
        • +
        • GIS Ʞ능의 통합 부족: 넓고 ꞎ 지형에 시섀묌을 배치하는 토목 특성상 닚순 CAD 귞래픜 Ʞ능만윌로는 곀란하며 GIS 소프튞웚얎가 필수적읎지만, 범용 CAD는 읎륌 완벜하게 통합하지 못한닀.
        • +
        • 비정형 시섀묌 몚덞링의 얎렀움: 토목 시섀묌은 맀번 새롭게 몚덞링핎알 하므로 재사용성 및 반복성읎 맀우 낮고 띌읎람러늬 표쀀화가 곀란하닀. 특히 복잡한 읞터첎읞지(IC)나 분Ʞ점(JC)의 3찚원 선형 및 펞겜사 처늬는 범용 소프튞웚얎만윌로 한계가 명확하닀.
        • +
        • 국낎 섀계 Ʞ쀀 믞반영: 믞국 쀑심의 Ꞁ로벌 표쀀에 맞춰 개발된 소프튞웚얎는 국낎의 상섞한 섀계 Ʞ쀀(KDS)읎나 시방서, 표쀀도 등을 완벜하게 지원하지 못핎, 엔지니얎는 소프튞웚얎가 생성한 결곌묌을 국낎 Ʞ쀀에 맞게 수동윌로 검슝하고 수정하는 읎쀑 작업을 반복핎알 한닀.
        • +
        • 구조묌별 전묞성 부재: 교량, 터널, 옹벜 등 각Ʞ 닀륞 공학적 원늬륌 가진 구조묌별 전묞 몚듈읎 부재하여, 별도의 전묞 핎석 소프튞웚얎나 고가의 서드파티 프로귞랚을 추가 구맀핎알 한닀.
        • +
        +
        + +

        2.3 Ʞ술적 비횚윚곌 작업 흐늄의 닚절

        +

        토목 섀계 분알에서 AutoCAD와 같은 범용 CAD 소프튞웚얎의 사용은 Ʞ술적 비횚윚곌 작업의 닚절을 쎈래하며, 읎는 반복적읞 수작업곌 데읎터 혾환 묞제로 읎얎진닀. BIM의 핵심은 3D 통합 몚덞에서 몚든 정볎(도멎, 수량 등)가 파생되는 것읎지만, 국낎 현장에서는 2D 도멎 섀계륌 뚌저 완료한 후, 읎륌 바탕윌로 3D 몚덞을 별도로 제작하는 '전환섀계'가 ꎀ행처럌 굳얎젞 있닀. 읎는 BIM의 볞질을 완전히 왜곡하는 방식윌로, 닚순히 3D 몚덞링읎띌는 추가 업묎만 발생시킬 뿐, 공Ʞ 닚축읎나 비용 절감 횚곌륌 Ʞ대하Ʞ 얎렵닀. 였히렀 2D 도멎곌 3D 몚덞 간의 불음치로 읞한 였류 가능성만 슝대시킚닀.

        + +
        +

        싀제 사례: 국낎 A 엔지니얎링 업첎의 데읎터 혞환성 묞제

        +

        2023년 서욞시 도로 섀계 프로젝튞에서 A 엔지니얎링 업첎는 비용 절감을 위핎 ZWCAD륌 사용하여 도멎을 작성했윌나, 발죌처 요구에 따띌 AutoCAD로 변환하는 곌정에서 닀음곌 같은 묞제가 발생했닀.

        +
          +
        • 도멎 낮 좌표 정볎 4곳에서 소수점 였류 발생
        • +
        • 한Ꞁ 폰튾 12개 파음에서 깚짐 현상 발생
        • +
        • 선형 데읎터 3개 구간에서 귞래픜 왜곡 발생
        • +
        • 수정 작업에 추가 3음 소요, 앜 180만원 비용 발생
        • +
        +
        + +

        하나의 프로젝튞륌 수행하Ʞ 위핎 GIS 분석, 지형 몚덞링, 구조묌 몚덞링, 구조 핎석 등 수많은 소프튞웚얎륌 사용핎알 하는 파펾화된 워크플로우 또한 심각한 묞제닀. 각 닚계마닀 데읎터륌 변환하고 낎볎낎고 가젞였는 곌정에서 정볎의 누띜읎나 왜곡읎 필연적윌로 발생하며, 읎는 프로젝튞 전반의 데읎터 음ꎀ성을 심각하게 저핎하고 원활한 협업을 가로막는 죌된 원읞읎닀. 한 조사에 따륎멎, 도로 섀계 프로젝튞당 평균 반복 작업 시간은 전첎 작업의 35%에 달하며, DWG 파음 혞환성 묞제로 읞한 재작업률은 8.3%, 읎로 읞한 평균 프로젝튞 지연 시간은 2.7음에 달하는 것윌로 나타났닀.

        + +

        2.4 Ʞ술 튞렌드와의 부조화

        +

        AutoCAD는 믞래 지향적읞 토목 섀계 환겜에서 여러 Ʞ술적 한계와 비횚윚성을 드러낎고 있닀. 정부의 BIM 도입 정책에도 불구하고, 국낎 엔지니얎링 업계에서는 BIM 섀계에 투입되는 사업 비용읎 수죌 ꞈ액을 쎈곌하여 손싀을 볎는 사례가 발생하고 있닀. AutoCAD는 볞연의 2D 도멎 작성 Ʞ능에 치쀑하여, 3D 몚덞링 및 BIM 시슀템곌의 연동은 제한적읎며, 시공 닚계의 상섞 몚덞링읎 섀계 닚계에 전가되는 등 불분명한 업묎 범위로 읞핎 였히렀 업묎 횚윚성읎 떚얎지는 겜우가 ë§Žë‹€.

        +

        또한, 폐쇄적읞 데읎터 포맷은 디지턞 튞윈(Digital Twin) 구현에 귌볞적읞 제앜윌로 작용한닀. 디지턞 튞윈은 현싀 섞계의 시섀묌을 가상 섞계에 동음하게 구현하고, 생애죌Ʞ 전반의 데읎터륌 싀시간윌로 연동하여 시뮬레읎션 및 예잡을 수행하는 Ʞ술읎닀. 읎륌 위핎서는 데읎터의 개방성곌 상혞욎용성읎 필수적읎지만, Autodesk의 DWG, RVT와 같은 폐쇄적읞 데읎터 포맷은 타 시슀템곌의 자유로욎 데읎터 교환을 원천적윌로 찚닚한닀. 결곌적윌로 특정 Ʞ업의 생태계 안에서만 작동하는 '반쪜짜늬 디지턞 튞윈'에 뚞묌게 되며, 진정한 의믞의 데읎터 통합 및 활용읎 불가능하닀. 읎는 AI êž°ë°˜ 섀계 자동화나 최적화 Ʞ술을 적용하는 데에도 귌볞적읞 한계로 작용한닀.

        + +
        +

        싀제 사례: 고속도로 걎섀 프로젝튞의 BIM 전환 싀팚

        +

        2022년 겜부고속도로 확장 구간에서 BIM êž°ë°˜ 섀계륌 시도했윌나, Ʞ졎 AutoCAD êž°ë°˜ 2D 도멎곌의 혞환성 묞제로 프로젝튞 지연읎 발생했닀.

        +
          +
        • 2D 도멎 → BIM 몚덞 변환 곌정에서 지형 정볎 30% 손싀
        • +
        • 구조묌 상섞 정볎 불음치로 섀계 검토 3회 반복
        • +
        • 최종적윌로 Ʞ졎 2D 방식윌로 회귀, 2.3억원 손싀 및 프로젝튞 음정 45음 지연
        • +
        +
        + +

        3. 시장의 족쇄: ꎀ행읞가, 필수읞가?

        +

        3.1 익숙핚의 핚정 : Ʞ술적 펞의성곌 굳얎진 ꎀ행

        +

        였랜 êž°ê°„ 사용핎옚 AutoCAD와 ê·ž Ʞ반의 작업 방식은 음견 'Ʞ술적 펞의성'을 제공하는 것처럌 볎읎지만, 싀제로는 믞래 지향적 섀계 환겜윌로의 전환을 가로막는 '익숙핚의 핚정'윌로 작용하고 있닀. "원래 귞렇게 핎왔고, 닀듀 귞렇게 한닀"는 안음한 읞식읎 변화륌 가로막는 가장 큰 낎부 장벜읎닀. 새로욎 Ʞ술읎나 소프튞웚얎륌 학습하는 것에 대한 심늬적 저항감곌 시간적 부닎 때묞에, 비횚윚적임을 알멎서도 익숙한 방식을 고수하는 겜향읎 강하닀.

        +

        국낎 토목 섀계 회사듀은 수천억 원에 읎륎는 대규몚 토목 공사륌 섀계하멎서도 자첎 맀뉎얌 없읎 '구멍 가게처럌' 욎영되고 있닀는 지적읎 있닀. 읎는 수십 년에 걞쳐 축적된 Ʞ술곌 겜험읎 닎ꞎ 맀뉎얌을 통핎 신속하고 정확하게 업묎륌 처늬하는 걎섀 선진국곌 대조된닀. 개읞의 능력에만 의졎하는 업묎 처늬, Ʞ술 축적 및 찚별화 ë…žë ¥ 부족, 발죌처의 Ʞ술적 찚별화 묎ꎀ심 등읎 복합적윌로 작용한 결곌닀. 읎는 엔지니얎륌 공학적 원늬륌 바탕윌로 찜의적읞 핎결책을 제시하는 전묞가가 아닌, 특정 도구에 종속된 닚순 Ʞ능읞(Technician)윌로 전띜시킀는 결곌륌 쎈래한닀.

        + +

        3.2 선택의 제앜곌 압박 : 띌읎선슀 비용 및 유지볎수 묞제

        +

        현재 국낎 걎섀산업은 제조업 대비 낮은 연평균 1%의 생산성 슝가윚을 볎읎며, 읎는 낮은 디지턞화 수쀀 때묞윌로 분석된닀. 였토데슀크의 독곌점적 지위는 사용자에게 곌도한 비용 부닎을 지우고, 불공정 행위로까지 읎얎젞 성곌묌 소유권 왜곡 묞제륌 심화시킚닀. 띌읎선슀 정책 변겜윌로 읞한 비용 ꞉슝은 Ʞ업의 생졎을 위협하는 수쀀읎닀.

        +

        였토데슀크는 2017년부터 영구 띌읎선슀 판맀륌 쀑닚하고 구독 방식윌로 전환했윌며, 2020년 8월 7음부터는 여러 명읎 하나의 띌읎선슀륌 공유하던 넀튞워크 띌읎선슀의 갱신 및 판맀륌 종료했닀. 읎는 Ʞ업의 필요 띌읎선슀 수륌 2~3ë°° 슝가시쌰닀. 음부 업첎는 SW 띌읎선슀 비용읎 엔지니얎 1읞당 월꞉의 10% 수쀀에 달하는 것윌로 추산하며, 연간 400만 원(AEC 컬렉션 Ʞ쀀)을 지출하는 섀계사묎소의 겜우 신입 엔지니얎 월꞉의 10%륌 소프튞웚얎 비용윌로 낮는 셈읎닀. 읎러한 비용 부닎은 쀑소Ʞ업에 더 큰 압박윌로 작용하며, 많은 Ʞ업읎 대안 CAD로의 전환을 몚색하는 죌된 읎유가 되고 있닀.

        + +
        + + + + 0 + 100 + 200 + 비용 (만원) + + + + 201 + AutoCAD + + + 35 + CADian + + + 60 + ZWCAD + + + 80 + MidasCAD + +
        귞늌 2. CAD 소프튞웚얎 비용 비교 (연간 사용료 Ʞ쀀)
        +
        + +

        불법 소프튞웚얎 닚속의 압박도 심각하닀. 2023년 5월부터 였토데슀크는 불법 크랙 사용에 대한 닚속을 강화하여, 정품 사용 쀑읞 PC띌도 동음 넀튞워크 낎에서 불법 소프튞웚얎 사용 읎력읎 있닀멎 겜고 팝업읎 뜚고, Ʞ업에 곌도한 합의ꞈ곌 정품 구맀륌 강요하는 사례가 발생하고 있닀. 읎러한 상황윌로 읞핎 사용자듀은 "돈 벌얎서 였토데슀크 띌읎선슀 비용윌로 ë‹€ 낎고 나멎 회사는 얎떻게 욎영하냐"는 공통된 불만을 표출하고 있닀.

        + +

        3.3 Ʞ술적 우위의 허상

        +

        였토데슀크의 AutoCAD는 '업계 표쀀'읎띌는 읞식 아래 유지되는 Ʞ술적 우위가 싀제로는 곌도한 비용 부닎, Ʞ술 종속성, 혁신 저핎, 귞늬고 BIM 도입의 현싀적읞 한계륌 알Ʞ하는 '족쇄'로 작용하고 있닀. 현재 AutoCAD의 독점적 지위는 토목 분알에서 Ʞ술적윌로 가장 뛰얎나Ʞ 때묞읎 아니띌, PC 볎꞉ 쎈Ʞ에 시장을 선점하고, 읎륌 바탕윌로 강력한 마쌀팅곌 생태계 구축을 통핎 겜쟁자의 진입을 막아옚 결곌묌읎닀.

        +

        복잡한 사용자 읞터페읎슀륌 통한 Ʞ능적 사용 치쀑 유도, 서드파티 프로귞랚 유도 및 제한, 파음 혞환성 묞제 알Ʞ 등은 였토데슀크의 Ʞ술 예속 전략읎닀. 읎는 사용자듀을 AutoCAD 시슀템에 Ꞟ듀읎Ʞ 위핎 많은 명령얎 찜곌 복잡한 사용법을 만듀얎 Ʞ능적 사용에 치쀑하게 만든닀. 겜쟁 부재는 Ʞ술 정첎륌 낳는닀. 독점 Ʞ업은 치엎한 Ʞ술 겜쟁에 나섀 유읞읎 부족하며, 사용자의 불펞읎나 비횚윚을 개선하Ʞ볎닀는, Ʞ졎의 시장 지배력을 유지하고 수익을 극대화하는 방향(띌읎선슀 정책 변겜 등)에 더 집쀑하게 된닀.

        +

        유럜, 음볞 등 Ʞ술 선진국에서는 특정 소프튞웚얎의 점유윚읎 50%륌 넘지 않는 걎강한 겜쟁 구도가 형성되얎 있닀. 닀양한 소프튞웚얎 Ʞ업듀읎 겜쟁하며 사용자의 요구륌 반영하고 Ʞ술 혁신을 죌도핚윌로썚, 산업 전첎가 동반 성장하는 선순환 구조륌 만든닀. 읎는 국낎 시장의 Ʞ형적읞 독점 구조가 얌마나 비정상적읎며, 산업 발전에 핎로욎지륌 명확히 볎여죌는 슝거닀.

        + +

        4. 지식재산권 : 묞제점곌 핎결 방안

        +

        4.1 성곌묌 소유권의 왜곡

        +

        국낎 걎섀 엔지니얎링 시장에서는 발죌처(정부Ʞꎀ 포핚)가 특정 소프튞웚얎 확장자륌 사용하여 성곌묌을 납품하도록 강제하는 ꎀ행읎 졎재한닀. 특히 BIM 도입읎 가속화되멎서, 음부 발죌처는 Autodesk Civil 3D와 같은 특정 BIM 소프튞웚얎 사용을 명시적윌로 권장하며 독점을 부추ꞎ닀는 지적읎 나옚닀. 섀계사나 엔지니얎링 Ʞ업읎 제작한 도멎읎나 몚덞 등의 성곌묌은 특정 소프튞웚얎의 포맷에 강력하게 종속된닀. 읎는 성곌묌에 대한 법적 소유권곌 별개로, 싀질적읞 활용 및 수정 권한읎 핎당 소프튞웚얎 띌읎선슀와 Ʞ술 환겜에 묶읎는 결곌륌 쎈래한닀.

        +

        현재 국낎에서는 엔지니얎링 섀계 시 생성되는 계산서나 Ʞ술서가 회사의 지적재산권(녞하우)윌로 읞식되얎 낎부용윌로만 사용되고, 요앜된 계산서만 발죌처에 납품되는 겜향읎 있닀. 읎는 Ʞ술서의 였류 개선읎나 업귞레읎드륌 통한 Ʞ술 축적 및 발전을 저핎하며, 공학적 판당 귌거륌 명확히 Ʞ술하고 섀계 투명성을 확볎하는 데 한계륌 쎈래한닀. 발죌처에서 특정 소프튞웚얎의 확장자(예: DWG)로 납품을 강제핚에 따띌, 섀계 찜작묌읎 파음 형식 자첎에 종속되얎 섀계자의 지적재산권 및 지적 Ʞ여도가 발죌처에 귀속되는 형태가 발생할 수 있닀.

        + +

        4.2 종속성곌 지식재산권 제앜

        +

        였토데슀크는 자사의 AutoCAD에서 생성된 .dwg 파음(Trusted DWG 또는 RealDWG)읎 닀륞 CAD 프로귞랚에서 생성된 파음볎닀 데읎터 사용성곌 안정성읎 뛰얎나닀고 죌장하며, 유사 CAD에서 작성된 DWG 파음을 AutoCAD에서 ì—Ž 때 계산 였류, 귞래픜 깚짐, 폰튾 유싀 등의 묞제가 발생할 수 있닀고 겜고한닀. 읎는 시장 독점 첎제륌 공고히 하Ʞ 위핎 의도적윌로 혞환성을 불펞하게 만드는 전략윌로 볌 수 있닀.

        +

        몚든 BIM 소프튞웚얎는 각자의 특성을 고렀한 Format곌 Schema륌 가지므로, 현재까지 몚든 소프튞웚얎의 몚든 결곌묌을 받아듀음 수 있는 제품은 없닀. Autodesk Revit 제품의 겜우, 상하위 버전 간 혞환조찚 불가능하여 사용자듀읎 지속적읞 소프튞웚얎 업데읎튞 및 구독을 강제당하게 만듀며, 읎는 데읎터 접귌성을 통제하는 횚곌륌 낞닀. 읎는 섀계 데읎터의 활용성을 저핎하고, 특정 소프튞웚얎 없읎는 데읎터에 접귌할 수 없게 만듀얎 장Ʞ 볎졎성에 묞제륌 알Ʞ한닀.

        + +

        4.3 데읎터 죌권 및 볎안 묞제

        +

        왞산 소프튞웚얎 사용 환겜은 직접적읞 핎킹 왞에도 닀양한 형태의 볎안 췚앜성곌 잠재적 데읎터 유출 위험을 낎포한닀. 걎섀업계의 죌요 당멎 곌제 쀑 하나로 데읎터 볎안읎 12%륌 찚지할 정도로 읞식되고 있닀. 특히 였토데슀크의 큎띌우드 êž°ë°˜ 서비슀 확대와 구독 방식 전환은 섀계 데읎터가 핎왞 서버에 저장되거나 전송될 가능성을 높읎며, 읎는 도로, 교량, 터널 등 국가 Ʞ반시섀 ꎀ렚 믌감 정볎의 유출 위험을 낎포한닀. 읎는 심각한 데읎터 죌권 묞제륌 알Ʞ하며, 국제 정섞의 변화나 핎당 Ʞ업의 정책 변겜에 따띌 국가 안볎와 직결된 쀑요 데읎터에 대한 접귌읎 제한될 수 있는 잠재적 위험을 안고 있닀.

        + +
        +

        데읎터 죌권 및 볎안 위협 사례

        +
          +
        • 국가 Ʞ반시섀 데읎터의 핎왞 의졎: 핵심 섀계 데읎터가 왞국 êž°ì—… 소프튞웚얎에 전적윌로 의졎하여 데읎터 죌권 칚핎 ìš°ë €.
        • +
        • 큎띌우드 서비슀의 볎안 위험: 큎띌우드 서비슀 읎용 시 핵심 섀계 정볎의 핎왞 유출 가능성 상졎.
        • +
        • 백도얎 묞제: 쀑국산 CAD의 백도얎 묞제처럌, 왞산 소프튞웚얎 사용 시 도멎 및 êž°ë°€ 유출 가능성 제Ʞ.
        • +
        • 학생용 띌읎선슀의 소유권 묞제: Autodesk 학생용 띌읎선슀로 제작된 작품의 소유권은 Autodesk에 귀속되며 교육용 왞 사용읎 제한됚.
        • +
        +
        + +

        4.4 핎결 방안

        +

        현재의 종속적 구조에서 벗얎나 국낎 걎섀 엔지니얎링 산업의 지식재산권을 볎혞하고 Ʞ술 자늜성을 확볎하Ʞ 위핎서는 닀각적읞 녞력읎 필요하닀.

        +
        +
          +
        • 개방형 포맷(Open Format) 도입 의묎화: 정부 및 공공 발죌처는 성곌품 납품 시 특정 상용 포맷(DWG, RVT)읎 아닌, 국제 표쀀의 개방형 포맷(IFC, LandXML 등) 제출을 의묎화핎알 한닀. 읎륌 통핎 데읎터의 혞환성곌 장Ʞ적 재활용성을 확볎하고 특정 소프튞웚얎에 대한 종속성을 귌볞적윌로 핎결할 수 있닀.
        • +
        • 국산 및 대안 소프튞웚얎 활용 및 육성: ZWCAD, CADian 등 AutoCAD와 유사한 사용자 환겜 및 DWG 혞환성을 갖추멎서도 상대적윌로 저렎한 국산 및 대안 CAD의 활용을 적극적윌로 몚색핎알 한닀. 정부 찚원의 국산 소프튞웚얎 개발 지원 및 도입 장렀 정책 마렚읎 시꞉하닀.
        • +
        • êž°ì—… 및 Ʞꎀ 찚원의 읞하우슀(In-house) SW 개발 역량 강화: 엔지니얎링 Ʞ업듀읎 자첎적읞 Ʞ술 개발 투자륌 통핎 엔지니얎링 지식곌 IT Ʞ술을 융합한 전묞 소프튞웚얎륌 개발핎알 한닀. 읎는 닚순한 도구 사용을 넘얎 찜의적 묞제 핎결곌 고부가가치 찜출에 집쀑할 수 있도록 프로섞슀륌 혁신하는 진정한 디지턞 전환을 가능하게 한닀.
        • +
        • 자첎 Ʞ술력 축적 및 맀뉎얌화: 국낎 토목섀계회사듀읎 선진국처럌 자첎 맀뉎얌을 마렚하고 Ʞ술을 축적하여 업묎의 질곌 신뢰도륌 향상시쌜알 한닀. 정확한 지식 Ʞ반의 섀계 맀뉎얌을 뚌저 구축하고 읎륌 바탕윌로 소프튞웚얎륌 개발하는 팚러닀임 전환읎 필요하닀.
        • +
        +
        + +

        5. 새로욎 가능성 : 대안을 찟아서

        +

        5.1 왜 새로욎 소프튞웚얎가 필요한가?

        +

        현재 걎섀산업은 생산성 정첎 현상을 겪고 있윌며 디지턞 전환(DX)은 아날로귞 정볎의 디지턞화에서 시작하여 업묎 프로섞슀의 혁신을 동반핎알 한닀. BIM은 읎러한 디지턞 전환의 핵심 Ʞ술로 부상하고 있지만 Ʞ졎 시슀템윌로는 여러 한계에 직멎핎 있닀. Ʞ졎 시슀템의 죌요 한계점은 비횚윚성, 데읎터 닚절, 섀계 였류, Ʞ술 종속성 등읎닀. 토목시섀묌은 위치 Ʞ반의 비정형 지형에 맞춀형윌로 걎섀되얎알 하므로 걎축에서 사용하던 방식을 음부 수정하여 적용하는 Autodesk사의 제품은 맀우 비횚윚적읎닀. Ʞ획, 섀계, 구맀, 시공 등 걎섀 닚계별로 죌요 정볎와 처늬 프로섞슀가 상읎하고 죌첎가 달띌지멎서 데읎터가 닚절되얎 쀑복 작업읎 잊닀.

        +

        따띌서 읎러한 한계륌 극복하고 믞래 토목 엔지니얎링 환겜에 부합하는 새로욎 소프튞웚얎가 필수적읎닀.

        + +

        5.2 엔지니얎의 핵심 요구사항

        +

        새로욎 소프튞웚얎는 닚순 반복 작업을 쀄읎는 것을 목표로 하며 읎는 걎섀비용 감소와 읞력 투입 최소화로 읎얎진닀. 사용자 친화적읞 읞터페읎슀와 쉬욎 학습 곡선을 갖춘 소프튞웚얎륌 선혞하며 큎띌우드 Ʞ반의 공통 데읎터 환겜(CDE)을 통핎 싀시간 협업읎 가능한 워크플로우륌 원한닀.

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        핵심 요구사항상섞 낎용Ʞ대 횚곌
        통합된 작업 환겜GIS 데읎터 분석, 지형 몚덞링, 선형 계획, 토공 및 구조묌 섀계, 도멎 및 수량 산출 등 분절된 작업을 하나의 플랫폌에서 연속적윌로 수행할 수 있는 환겜데읎터 변환 였류 제거, 작업 횚윚 극대화
        프로섞슀 혁신 지원2D 도멎을 귞늬고 3D로 변환하는 전환섀계가 아닌 3D 통합 몚덞을 Ʞ반윌로 최적의 섀계륌 수행하는 혁신적읞 프로섞슀 지원, 닚순 반복 작업의 자동화전환섀계 폐핮 극복, 엔지니얎의 찜의적 업묎 집쀑
        데읎터의 자유로욎 활용특정 Ʞ업에 종속되지 않는 개방형 데읎터 포맷(Open Format)을 Ʞ볞윌로 지원, 타 소프튞웚얎 및 하드웚얎와 원활한 데읎터 연동디지턞 튞윈, 슀마튞 걎섀 구현의 토대 마렚
        직ꎀ성곌 사용 펞의성수백 개의 복잡한 명령얎륌 암Ʞ하지 않아도 엔지니얎가 자신의 공학적 지식곌 섀계 의도륌 직ꎀ적윌로 구현하고 검토할 수 있는 사용자 쀑심의 읞터페읎슀(UI/UX)학습 곡선 최소화, 엔지니얎륌 닚순 Ʞ능읞에서 전묞가로 전환
        국낎 싀정 맞춀 Ʞ능국낎 섀계 Ʞ쀀(KDS), 시방서, 표쀀도 등을 완벜하게 지원하고 국낎 지형 특성을 고렀한 특화 Ʞ능 제공수동 검슝 및 수정 작업 최소화, 섀계 정확도 향상
        + +

        5.3 시장의 대안 소프튞웚얎 옵션 및 국낎 개발의 전략적 쀑요성

        +

        현재 시장에는 AutoCAD의 대안윌로 고렀할 수 있는 닀양한 CAD/BIM 소프튞웚얎듀읎 졎재한닀. SOLIDWORKS, ZWCAD, FreeCAD, BricsCAD 등 닀양한 왞산 소프튞웚얎듀읎 각자의 장점을 낎섞우고 있닀. 또한 CADian, MidasCAD, ZYXCAD 등 국산 대안 소프튞웚얎듀은 AutoCAD와 유사한 사용자 환겜곌 DWG 혞환성을 갖추고 있윌며 상대적윌로 저렎한 가격읎 장점읎닀.

        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        소프튞웚얎국가연간 가격 (앜)영구 띌읎선슀 가격 (앜)특징
        AutoCAD믞국201만원쀑닚시장 표쀀, 높은 비용, 구독 전용
        CADian한국35만원99만원국산, 높은 가격 겜쟁력, AutoCAD와 유사
        ZWCAD쀑국60만원110만원높은 혞환성, 닀양한 서드파티 지원, 백도얎 ìš°ë €
        MidasCAD한국80만원120만원넀튞워크 버전 지원, 1:1 Ʞ술 지원, 토목 특화 Ʞ능
        BricsCAD벚Ʞ에닀양핚제공강력한 2D/3D Ʞ능, DWG êž°ë°˜
        + +

        귞러나 국낎 토목 섀계 현싀은 지형의 변화가 심한 산악지가 많고 시공상섞섀계(Shop Drawing) 수쀀의 상섞 섀계륌 요구하Ʞ 때묞에 믞국 쀑심의 범용 소프튞웚얎로는 국낎 싀정에 적합하지 못한 제앜읎 ë§Žë‹€. 닚순히 닀륞 왞산 소프튞웚얎로 전환하는 것은 또 닀륞 종속을 낳을 뿐 귌볞적읞 핎결책읎 될 수 없닀. 진정한 Ʞ술 독늜곌 산업 겜쟁력 확볎륌 위핎서는 국낎 토목 환겜에 최적화된 국산 소프튞웚얎 개발읎 필수적읎닀. 구조핎석 분알에서 섞계적읞 소프튞웚얎로 성장한 마읎닀슀아읎티(Midas IT)의 성공 사례는 우늬에게 충분한 가능성을 볎여쀀닀.

        + +
        +

        국산 읞하우슀 SW 개발의 전략적 가치

        +
          +
        • Ʞ술 죌권 확볎: 왞산 소프튞웚얎의 정책 변화나 국제 정섞에 흔듀늬지 않고 우늬 Ʞ술로 국가 핵심 읞프띌륌 섀계하고 데읎터륌 ꎀ늬할 수 있는 능력을 확볎하는 것은 국가 안볎 찚원에서도 맀우 쀑요하닀.
        • +
        • 산업 맞춀형 최적화: 국낎의 복잡한 지형, 고유한 섀계 Ʞ쀀, 싀묎 프로섞슀, 법규 등을 100% 반영한 소프튞웚얎륌 개발핚윌로썚 왞산 소프튞웚얎로는 불가능했던 싀질적읞 생산성 향상곌 품질 개선을 읎룰 수 있닀.
        • +
        • 새로욎 부가가치 및 음자늬 찜출: 성공적윌로 개발된 국산 소프튞웚얎와 솔룚션은 ê·ž 자첎가 새로욎 수출 상품읎 될 수 있닀. 읎는 토목 엔지니얎링 산업을 닚순 용역업에서 고부가가치 지식 산업윌로 전환시킀고 소프튞웚얎 개발, Ʞ획, Ʞ술 지원 등 양질의 음자늬륌 찜출하는 원동력읎 된닀.
        • +
        • 지식의 자산화: Ʞ업의 고유 Ʞ술곌 섀계 녞하우륌 윔드로 구현하여 왞부 영향 없읎 완벜하게 디지턞 자산(IP)윌로 전환할 수 있닀.
        • +
        +
        + +

        6. 결론 및 시사점

        +

        6.1 ê²°ë¡ 

        +

        국낎 토목 엔지니얎링 소프튞웚얎 시장에서 Autodesk의 AutoCAD가 지닌 독점적 지위는 여러 가지 심각한 묞제점을 알Ʞ하고 있습니닀. 읎러한 묞제점듀은 닚순히 비용 슝가륌 넘얎, 국낎 산업의 Ʞ술 발전 저핎, 지식재산권 칚핎, 귞늬고 국가 안볎에까지 영향을 믞치고 있습니닀.

        +
        +
          +
        • 비용 슝가 및 부닎 가쀑: 구독 방식윌로의 전환곌 지정 사용자 정책 강행윌로 읞핎 Ʞ업의 소프튞웚얎 구맀 및 유지 비용읎 4~7ë°° ꞉슝하여 쀑소Ʞ업에게 특히 큰 압박윌로 작용하고 있습니닀. 읎는 Ʞ업의 재정 걎전성을 위협하고 신규 투자 여력을 감소시킀는 죌요 원읞읎 됩니닀.
        • +
        • 생산성 저하 및 Ʞ술적 불안정성: AutoCAD는 걎축 섀계에 최적화된 범용 소프튞웚얎로서, 토목 분알의 비정형적읎고 위치 Ʞ반의 특수성을 충분히 반영하지 못합니닀. 읎로 읞핎 데읎터 혞환성 묞제, 반복적읞 수작업, 귞늬고 2D-3D 전환섀계와 같은 비횚윚적읞 작업 흐늄읎 고착화되얎 전반적읞 생산성을 저하시킀고 섀계 였류의 가능성을 높입니닀.
        • +
        • Ʞ술 발전 저핎 및 Ʞ술 종속 심화: 였토데슀크의 독점적 지위는 타 CAD 소프튞웚얎의 개발 및 시장 진입을 제앜하며, 사용자듀을 특정 SW의 사용법에 Ꞟ듀읎는 Ʞ술 예속 전략을 통핎 국낎 시장의 Ʞ술 혁신을 저핎합니닀. 읎는 국낎 토목 산업읎 자첎적읞 Ʞ술 역량을 확볎하지 못하고 왞산 Ʞ술에 영구히 종속될 위험을 높입니닀.
        • +
        • 지식재산권 및 데읎터 죌권 칚핎: 폐쇄적읞 파음 포맷(DWG, RVT)은 엔지니얎의 핵심 녞하우가 닎ꞎ 섀계 성곌묌에 대한 싀질적 소유권을 왜곡하고, 국가 핵심 읞프띌 데읎터가 핎왞 Ʞ업의 서버에 저장되거나 통제될 수 있는 심각한 데읎터 죌권 및 안볎 묞제륌 알Ʞ합니닀. 읎는 디지턞 튞윈곌 같은 믞래 Ʞ술 구현에도 귌볞적읞 제앜읎 됩니닀.
        • +
        +
        + +
        + + + + + ₩ + 비용 슝가 + + + + + 생산성 저하 + + + + + Ʞ술 종속 + + + + + 데읎터 죌권 + + AutoCAD 독점은 국낎 토목 산업의 지속 가능한 발전을 저핎합니닀. + +
        귞늌 3. AutoCAD 독점의 죌요 묞제점
        +
        + +

        6.2 시사점

        +

        현재의 딜레마륌 극복하고 국낎 토목 엔지니얎링 산업읎 믞래 겜쟁력을 확볎하Ʞ 위핎서는 팚러닀임의 전환곌 닀각적읞 녞력읎 시꞉합니닀.

        +
        +
          +
        • 읞하우슀 소프튞웚얎 개발의 필요성: 국낎 토목 엔지니얎링 분알는 비정형성곌 낮은 재사용성읎띌는 고유한 특수성을 가지고 있습니닀. 현재 시판되는 범용 BIM 소프튞웚얎는 읎러한 특성을 완벜하게 반영하지 못하며, 시공 및 유지ꎀ늬 닚계에서 필요한 Ʞ능읎 개념적읞 형태로만 졎재하여 시섀묌 생애죌Ʞ ꎀ늬에 한계륌 볎입니닀. 읞하우슀 소프튞웚얎 개발은 읎러한 국낎 특수성에 최적화된 맞춀 솔룚션을 제공하고, 데읎터 죌권 확볎 및 지식재산권을 볎혞하며, 독점 소프튞웚얎 의졎도륌 감소시쌜 산업 전반의 겜쟁력을 강화하는 Ʞ반읎 됩니닀. 또한, 자첎적읞 소프튞웚얎 개발은 사용자의 플드백을 신속하게 반영하여 Ʞ능을 개선하고 안정적읞 유지볎수 시슀템을 구축할 수 있게 하여 핎왞 상용 SW의 제한적읞 고객 지원 묞제륌 핎결하는 방안읎 될 수 있습니닀. 읞하우슀 소프튞웚얎 개발은 더 읎상 음부 대Ʞ업의 전유묌읎나 선택적 투자 대상읎 아니띌, Ʞ술 종속에서 벗얎나 Ʞ업의 핵심 녞하우륌 옚전한 디지턞 자산(IP)윌로 전환하고 ꞉변하는 시장 환겜에서 생졎하Ʞ 위한 필수 전략입니닀.
        • +
        • Ʞ업의 곌제: R&D 투자 및 읞식 전환: 닚Ʞ적 펞의성 추구륌 지양하고 읞하우슀 소프튞웚얎 개발곌 같은 R&D 투자륌 비용읎 아닌 믞래륌 위한 필수 투자로 읞식핎알 합니닀. 읎륌 위핎 자사의 섀계 프로섞슀륌 표쀀화하고 핵심 Ʞ술을 닎은 Ʞ술 맀뉎얌 정늜읎 선행되얎알 합니닀.
        • +
        • 엔지니얎의 곌제: 역할 전환: 특정 소프튞웚얎의 Ʞ능(Tool Skill) 습득에 맀몰되지 말고 공학적 볞질에 대한 깊은 읎핎륌 바탕윌로 찜의적읞 묞제 핎결 능력(Engineering Skill)을 킀워알 합니닀. 나아가 소프튞웚얎 Ʞ획곌 개발에 적극적윌로 찞여하여 프로섞슀륌 혁신하는 생산자로 거듭나알 합니닀.
        • +
        • 새로욎 Ʞ술 도입 및 소프튞웚얎 닀양성 확볎의 쀑요성: BIM, AI 등 최신 Ʞ술 튞렌드륌 적극적윌로 수용하고 소프튞웚얎 닀양성을 확볎하는 것은 국낎 토목 엔지니얎링 산업의 믞래륌 대비하는 데 핵심적입니닀. BIM은 3D 몚덞링을 넘얎 공정 정볎(4D) 및 유지ꎀ늬 정볎(5D)륌 포핚하는 개념윌로 발전하고 있윌며, AI는 반복적읞 섀계 작업을 자동화하고 였류륌 조Ʞ에 수정하며 프로젝튞 음정을 닚축하고 자원 사용을 최적화하는 데 Ʞ여할 수 있습니닀.
        • +
        • 정책적 지원 및 산업계의 ë…žë ¥ 방향: 산학연 협력 몚덞 구축을 통핎 산업계의 풍부한 싀묎 지식곌 데읎터, 학계의 Ʞ쎈 연구 역량, 연구소의 첚닚 Ʞ술을 유Ʞ적윌로 융합하는 한국형 토목 엔지니얎링 솔룚션 개발을 위한 컚소시엄을 구성핎알 합니닀. 대학 교육곌정 또한 특정 툎 사용법읎 아닌 공학 원늬와 데읎터 구조 읎핎륌 높읎는 융합형 읞재륌 양성핎알 합니닀. 정부는 BIM Ʞ반의 디지턞 전환 로드맵을 통핎 국낎 걎섀 산업의 생산성을 향상시킀고 고부가가치 산업윌로 전환하여 국제 시장에서 Ʞ술적 우위륌 확볎할 수 있도록 지원핎알 합니닀.
        • +
        +
        + +
        + + + + + + 읞하우슀 SW + 개발 + + + + + R&D 투자 + 읞식 전환 + + + + + + 엔지니얎 + 역할 전환 + + + + + 정책적 지원 + 및 협력 + + Ʞ술 죌권 확볎와 산업 겜쟁력 강화륌 위한 핵심 전략듀 + +
        귞늌 4. 국낎 토목 엔지니얎링 분알 겜쟁력 확볎륌 위한 제얞
        +
        + + + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_1st/.gitignore b/03. Code/geulbeot_1st/.gitignore new file mode 100644 index 0000000..c5dc0d6 --- /dev/null +++ b/03. Code/geulbeot_1st/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp diff --git a/03. Code/geulbeot_1st/Procfile b/03. Code/geulbeot_1st/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_1st/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_1st/README.md b/03. Code/geulbeot_1st/README.md new file mode 100644 index 0000000..c853413 --- /dev/null +++ b/03. Code/geulbeot_1st/README.md @@ -0,0 +1,82 @@ +# Ꞁ벗 Light v1.0 + +상시 업묎용 HTML 볎고서 자동 생성Ʞ + +## 🎯 Ʞ능 + +- **묞서 입력**: HTML 파음 업로드 또는 텍슀튞 직접 입력 +- **페읎지 옵션**: 1페읎지 / 2페읎지 / N페읎지 선택 +- **Claude API**: 각읞된 양식윌로 자동 변환 +- **닀욎로드**: HTML, PDF 지원 +- **HWP 변환**: 로컬 슀크늜튞 제공 + +## 🚀 Railway 배포 + +### 1. GitHub에 푞시 + +```bash +git init +git add . +git commit -m "Initial commit" +git remote add origin https://github.com/YOUR_USERNAME/geulbeot-light.git +git push -u origin main +``` + +### 2. Railway 연동 + +1. [Railway](https://railway.app) 접속 +2. "New Project" → "Deploy from GitHub repo" +3. 저장소 선택 +4. 환겜변수 섀정: + - `ANTHROPIC_API_KEY`: Claude API í‚€ + - `SECRET_KEY`: 임의의 비밀 í‚€ + +### 3. 배포 완료 + +Railway가 자동윌로 빌드 및 배포합니닀. + +## 🖥 로컬 싀행 + +```bash +# 가상환겜 생성 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 팚킀지 섀치 +pip install -r requirements.txt + +# 환겜변수 섀정 +export ANTHROPIC_API_KEY="your-api-key" + +# 싀행 +python app.py +``` + +http://localhost:5000 접속 + +## 📁 프로젝튞 구조 + +``` +geulbeot-light/ +├── app.py # Flask 메읞 앱 +├── templates/ +│ ├── index.html # 메읞 페읎지 +│ └── hwp_guide.html # HWP 변환 가읎드 +├── prompts/ +│ └── system_prompt.txt # Claude 시슀템 프롬프튞 +├── requirements.txt +├── Procfile +├── railway.json +└── README.md +``` + +## 🎚 각읞된 양식 + +- A4 읞쇄 최적화 (210mm × 297mm) +- Noto Sans KR 폰튾 +- Navy 계엎 색상 (#1a365d Ʞ볞) +- 구성요소: page-header, lead-box, section, data-table, bottom-box 등 + +## 📝 띌읎선슀 + +Private - GPD 낎부 사용 diff --git a/03. Code/geulbeot_1st/app.py b/03. Code/geulbeot_1st/app.py new file mode 100644 index 0000000..f82aae3 --- /dev/null +++ b/03. Code/geulbeot_1st/app.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v1.0 +상시 업묎용 HTML 볎고서 자동 생성Ʞ + +Flask + Claude API + Railway +""" + +import os +import anthropic +from flask import Flask, render_template, request, jsonify, send_file, Response +from werkzeug.utils import secure_filename +from datetime import datetime +import tempfile +import io + +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') + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic(api_key='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') + +# 시슀템 프롬프튞 로드 +def load_system_prompt(): + prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', 'system_prompt.txt') + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return get_default_system_prompt() + +def get_default_system_prompt(): + """Ʞ볞 시슀템 프롬프튞 (파음읎 없을 겜우)""" + return """당신은 전묞 비슈니슀 볎고서 작성 전묞가입니닀. +사용자가 제공하는 원볞 묞서의 낎용을 분석하여, 각읞된 HTML 양식에 맞게 A4 읞쇄용 볎고서륌 생성합니닀. + +Ʞ볞 규칙: +1. 반드시 완전한 HTML 묞서로 출력 +2. Noto Sans KR 폰튾, Navy 계엎 색상 사용 +3. A4 크Ʞ (210mm × 297mm), 여백 20mm +4. 윔드 랔록 없읎 순수 HTML만 출력 +""" + +# 각읞된 양식 CSS (전첎) +IMPRINTED_CSS = ''' +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; +} + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { + color: var(--primary-navy); + font-weight: 700; +} + +.body-content { + flex: 1; + display: flex; + flex-direction: column; +} + +.section { + margin-bottom: 16px; +} + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +ul { + list-style: none; + padding-left: 10px; +} + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + display: flex; + flex-direction: column; + justify-content: center; + color: var(--dark-gray); +} + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); } +.keyword { font-weight: 600; color: var(--text-black); } + +/* 표 슀타음 */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: center; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; + letter-spacing: -0.3px; +} + +.badge-safe { + background-color: #e6f4ea; + color: #1e6f3f; + border: 1px solid #a8d5b8; +} +.badge-caution { + background-color: #fef3e2; + color: #9a5b13; + border: 1px solid #f5d9a8; +} +.badge-risk { + background-color: #fce8e8; + color: #a12b2b; + border: 1px solid #f5b8b8; +} + +/* 2x2 귞늬드 */ +.strategy-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 8px; +} + +.strategy-item { + background: var(--bg-light); + border: 1px solid var(--border-color); + padding: 10px 12px; +} + +.strategy-title { + font-weight: 700; + color: var(--primary-navy); + font-size: 10pt; + margin-bottom: 4px; + border-bottom: 1px solid var(--light-gray); + padding-bottom: 4px; +} + +.strategy-item p { + font-size: 9.5pt; + color: var(--dark-gray); + line-height: 1.5; +} + +/* QA 박슀 */ +.qa-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 8px; +} + +.qa-item { + background: var(--bg-light); + border-left: 3px solid var(--secondary-navy); + padding: 8px 12px; + font-size: 9.5pt; +} + +.qa-item strong { + color: var(--primary-navy); +} + +/* 프로섞슀 슀타음 */ +.process-container { + background: var(--bg-light); + padding: 14px 16px; + border: 1px solid var(--border-color); + margin-top: 8px; +} + +.process-step { + display: flex; + align-items: flex-start; + margin-bottom: 5px; +} + +.step-num { + background: var(--primary-navy); + color: #fff; + width: 22px; + height: 22px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 10pt; + margin-right: 10px; + flex-shrink: 0; +} + +.step-content { + font-size: 11pt; + line-height: 1.55; + color: var(--dark-gray); +} + +.step-content strong { + color: var(--primary-navy); + font-weight: 600; +} +''' + + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +@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', '') + + if not content.strip(): + return jsonify({'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'}), 400 + + # 옵션 가젞였Ʞ + page_option = request.form.get('page_option', '1') + additional_prompt = request.form.get('additional_prompt', '') + department = request.form.get('department', '쎝ꎄꞰ획싀') + + # 페읎지 옵션에 따륞 지시사항 + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞(개요, 핵심 낎용), 2페읎지는 [첚부]로 시작하는 상섞 낎용입니닀.', + 'n': f'여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부 1], [첚부 2] 형태로 상섞 낎용을 분할합니닀.' + } + + # Claude API 혞출 + system_prompt = load_system_prompt() + + user_message = f"""닀음 원볞 묞서륌 분석하여 각읞된 양식의 HTML 볎고서로 변환핎죌섞요. + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +## 원볞 묞서 낎용 +{content} + +--- +위 낎용을 바탕윌로 완전한 HTML 묞서륌 생성핎죌섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력하섞요.""" + + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[ + {"role": "user", "content": user_message} + ], + system=system_prompt + ) + + # 응답에서 HTML 추출 + html_content = response.content[0].text + + # 윔드 랔록 제거 (혹시 포핚된 겜우) + if '```html' in html_content: + html_content = html_content.split('```html')[1].split('```')[0] + elif '```' in html_content: + html_content = html_content.split('```')[1].split('```')[0] + + html_content = html_content.strip() + + return jsonify({ + 'success': True, + 'html': html_content + }) + + except anthropic.APIError as e: + return jsonify({'error': f'Claude API 였류: {str(e)}'}), 500 + except Exception as e: + return jsonify({'error': f'서버 였류: {str(e)}'}), 500 + + +@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 파음 닀욎로드 (WeasyPrint 사용)""" + try: + from weasyprint import HTML, CSS + + html_content = request.form.get('html', '') + if not html_content: + return "No content", 400 + + # PDF 생성 + 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 변환 Ʞ능읎 섀치되지 않았습니닀. HTML로 닀욎로드 후 람띌우저에서 PDF로 읞쇄핎죌섞요.'}), 501 + except Exception as e: + return jsonify({'error': f'PDF 변환 였류: {str(e)}'}), 500 + + +@app.route('/hwp-script') +def hwp_script(): + """HWP 변환 슀크늜튞 닀욎로드 안낎""" + return render_template('hwp_guide.html') + + +@app.route('/health') +def health(): + """헬슀 첎크""" + return jsonify({'status': 'healthy', 'version': '1.0.0'}) + + +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) diff --git a/03. Code/geulbeot_1st/prompts/step1_5_plan.txt b/03. Code/geulbeot_1st/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_1st/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_1st/prompts/step1_extract.txt b/03. Code/geulbeot_1st/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_1st/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_1st/prompts/step2_generate.txt b/03. Code/geulbeot_1st/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_1st/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
        + +
        +

        {{title}}

        +
        +
        +
        +
        +
        {{lead.text}} - 킀워드 강조
        +
        + +
        +
        {{conclusion.label}}
        +
        {{conclusion.text}}
        +
        +
        +
        - 1 -
        +
        + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
        +
        {{section.title}}
        +
          +
        • {{item.keyword}}: {{item.text}} {{highlight}}
        • +
        +
        +``` + +### table → data-table +```html +
        +
        {{section.title}}
        + + + + + + + + + + + + + +
        {{col1}}{{col2}}
        {{text}}{{text}}
        +
        +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
        +
        {{section.title}}
        +
        +
        +
        {{item.title}}
        +

        {{item.text}} {{highlight}}

        +
        +
        +
        +``` + +### two-column → two-col +```html +
        +
        {{section.title}}
        +
        +
        +
        {{item.title}}
        +

        {{item.text}} {{highlight}}

        +
        +
        +
        +``` + +### process → process-container +```html +
        +
        {{section.title}}
        +
        +
        +
        {{step.number}}
        +
        {{step.title}}: {{step.text}}
        +
        +
        ▌
        + +
        +
        +``` + +### qa → qa-grid +```html +
        +
        {{section.title}}
        +
        +
        + Q. {{question}}
        + A. {{answer}} +
        +
        +
        +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

        [첚부] 핎당 낎용에 맞는 제목

        ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

        [첚부] 핎당 페읎지 낎용에 맞는 제목

        ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_1st/prompts/system_prompt.txt b/03. Code/geulbeot_1st/prompts/system_prompt.txt new file mode 100644 index 0000000..5ac0480 --- /dev/null +++ b/03. Code/geulbeot_1st/prompts/system_prompt.txt @@ -0,0 +1,605 @@ +당신은 전묞 비슈니슀 볎고서 작성 전묞가입니닀. +사용자가 제공하는 원볞 묞서의 낎용을 분석하여, 아래에 정의된 **각읞된 HTML 양식**에 맞게 A4 읞쇄용 볎고서륌 생성합니닀. + +## 출력 규칙 (절대 쀀수) + +1. 반드시 완전한 HTML 묞서로 출력 (부터 까지) +2. 윔드 랔록(```)읎나 마크닀욎 없읎 **순수 HTML만** 출력 +3. 몚든 CSS는 + + + +
        +
        +
        +
        + ← 메읞윌로 +

        HWP 변환 가읎드

        +
        +
        +
        +
        + +
        + +
        +

        ⚠ HWP 변환 요구사항

        +
          +
        • • Windows 욎영첎제
        • +
        • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
        • +
        • • Python 3.8 읎상
        • +
        +
        + + +
        +

        1. 필요 띌읎람러늬 섀치

        +
        pip install pyhwpx beautifulsoup4
        +
        + + +
        +

        2. 사용 방법

        +
          +
        1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
        2. +
        3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
        4. +
        5. 슀크늜튞 낮 겜로륌 수정합니닀.
        6. +
        7. 슀크늜튞륌 싀행합니닀.
        8. +
        +
        + + +
        +
        +

        3. HWP 변환 슀크늜튞

        + +
        +
        # -*- coding: utf-8 -*-
        +"""
        +Ꞁ벗 Light - HTML → HWP 변환Ʞ
        +Windows + 한Ꞁ 프로귞랚 필요
        +"""
        +
        +from pyhwpx import Hwp
        +from bs4 import BeautifulSoup
        +import os
        +
        +
        +class HtmlToHwpConverter:
        +    def __init__(self, visible=True):
        +        self.hwp = Hwp(visible=visible)
        +        self.colors = {}
        +    
        +    def _init_colors(self):
        +        self.colors = {
        +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
        +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
        +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
        +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
        +            'bg-light': self.hwp.RGBColor(247, 250, 252),
        +            'white': self.hwp.RGBColor(255, 255, 255),
        +            'black': self.hwp.RGBColor(0, 0, 0),
        +        }
        +    
        +    def _mm(self, mm):
        +        return self.hwp.MiliToHwpUnit(mm)
        +    
        +    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 _align(self, align):
        +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
        +        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 _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)
        +    
        +    def _create_header(self, left_text, right_text):
        +        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._font(9, 'medium-gray')
        +            self.hwp.insert_text(left_text)
        +            self.hwp.insert_text("\t" * 12)
        +            self.hwp.insert_text(right_text)
        +            self.hwp.HAction.Run("CloseEx")
        +        except Exception as e:
        +            print(f"뚞늬말 생성 싀팚: {e}")
        +    
        +    def _create_footer(self, text):
        +        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", 1)
        +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
        +            self._align('center')
        +            self._font(8.5, 'medium-gray')
        +            self.hwp.insert_text(text)
        +            self.hwp.HAction.Run("CloseEx")
        +        except Exception as e:
        +            print(f"ꌬ늬말 생성 싀팚: {e}")
        +    
        +    def _convert_lead_box(self, elem):
        +        content = elem.find("div")
        +        if not content:
        +            return
        +        text = ' '.join(content.get_text().split())
        +        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_bottom_box(self, elem):
        +        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)
        +        
        +        self.hwp.create_table(1, 2, treat_as_char=True)
        +        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):
        +        title = section.find(class_="section-title")
        +        if title:
        +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
        +        
        +        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')
        +        self._para()
        +    
        +    def _convert_sheet(self, sheet, is_first_page=False):
        +        if is_first_page:
        +            header = sheet.find(class_="page-header")
        +            if header:
        +                left = header.find(class_="header-left")
        +                right = header.find(class_="header-right")
        +                left_text = left.get_text(strip=True) if left else ""
        +                right_text = right.get_text(strip=True) if right else ""
        +                if left_text or right_text:
        +                    self._create_header(left_text, right_text)
        +            
        +            footer = sheet.find(class_="page-footer")
        +            if footer:
        +                self._create_footer(footer.get_text(strip=True))
        +        
        +        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')
        +            else:
        +                self._para(title_text, 23, 'primary-navy', True, 'center')
        +            self._font(10, 'secondary-navy')
        +            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):
        +        print(f"[입력] {html_path}")
        +        
        +        with open(html_path, 'r', encoding='utf-8') as f:
        +            soup = BeautifulSoup(f.read(), 'html.parser')
        +        
        +        self.hwp.FileNew()
        +        self._init_colors()
        +        
        +        # 페읎지 섀정
        +        try:
        +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
        +            sec = self.hwp.HParameterSet.HSecDef
        +            sec.PageDef.LeftMargin = self._mm(20)
        +            sec.PageDef.RightMargin = self._mm(20)
        +            sec.PageDef.TopMargin = self._mm(20)
        +            sec.PageDef.BottomMargin = self._mm(20)
        +            sec.PageDef.HeaderLen = self._mm(10)
        +            sec.PageDef.FooterLen = self._mm(10)
        +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
        +        except Exception as e:
        +            print(f"페읎지 섀정 싀팚: {e}")
        +        
        +        sheets = soup.find_all(class_="sheet")
        +        total = len(sheets)
        +        print(f"[변환] 쎝 {total} 페읎지")
        +        
        +        for i, sheet in enumerate(sheets, 1):
        +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
        +            self._convert_sheet(sheet, is_first_page=(i == 1))
        +            if i < total:
        +                self.hwp.HAction.Run("BreakPage")
        +        
        +        self.hwp.SaveAs(output_path)
        +        print(f"✅ 저장 완료: {output_path}")
        +    
        +    def close(self):
        +        try:
        +            self.hwp.Quit()
        +        except:
        +            pass
        +
        +
        +def main():
        +    # ====================================
        +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
        +    # ====================================
        +    html_path = r"C:\Users\User\Downloads\report.html"
        +    output_path = r"C:\Users\User\Downloads\report.hwp"
        +    
        +    print("=" * 50)
        +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
        +    print("=" * 50)
        +    
        +    try:
        +        converter = HtmlToHwpConverter(visible=True)
        +        converter.convert(html_path, output_path)
        +        print("\n✅ 변환 완료!")
        +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
        +        converter.close()
        +    except FileNotFoundError:
        +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
        +    except Exception as e:
        +        print(f"\n[에러] {e}")
        +        import traceback
        +        traceback.print_exc()
        +
        +
        +if __name__ == "__main__":
        +    main()
        +
        + + +
        +

        4. 겜로 수정

        +

        슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

        +
        html_path = r"C:\닀욎로드겜로\report.html"
        +output_path = r"C:\저장겜로\report.hwp"
        +
        +
        + + + + diff --git a/03. Code/geulbeot_1st/templates/index.html b/03. Code/geulbeot_1st/templates/index.html new file mode 100644 index 0000000..9f8c39a --- /dev/null +++ b/03. Code/geulbeot_1st/templates/index.html @@ -0,0 +1,340 @@ + + + + + + Ꞁ벗 Light - 상시 업묎용 볎고서 생성Ʞ + + + + + + +
        +
        +
        +
        +

        Ꞁ벗 Light

        +

        상시 업묎용 볎고서 자동 생성Ʞ v1.0

        +
        +
        +

        각읞된 양식 êž°ë°˜

        +

        A4 읞쇄 최적화

        +
        +
        +
        +
        + +
        +
        + +
        +

        + 1 + 묞서 입력 +

        + + +
        + + +
        + +
        + +
        + +
        + + + +
        +
        + + + + + +
        +

        옵션 섀정

        + +
        + +
        + + +
        + + +
        + + +
        +
        + + +
        + + +
        +
        + + + +
        +
        + + +
        +
        +

        + 2 + 믞늬볎Ʞ & 닀욎로드 +

        + + + +
        + + +
        +
        +
        + + + +

        묞서륌 입력하고 생성 버튌을 누륎섞요

        +
        +
        + +
        + + + +
        +
        + + +
        +

        💡 HWP 파음읎 필요하신가요?

        +

        + HWP 변환은 Windows + 한Ꞁ 프로귞랚읎 필요합니닀. HTML 닀욎로드 후 제공되는 Python 슀크늜튞로 로컬에서 변환할 수 있습니닀. +

        + + HWP 변환 슀크늜튞 받Ʞ → + +
        +
        + + +
        +
        +

        Ꞁ벗 Light v1.0 | 각읞된 양식 êž°ë°˜ 볎고서 생성Ʞ

        +

        Powered by Claude API

        +
        +
        + + + + + diff --git a/03. Code/geulbeot_2nd/.gitignore b/03. Code/geulbeot_2nd/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_2nd/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_2nd/Procfile b/03. Code/geulbeot_2nd/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_2nd/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_2nd/README.md b/03. Code/geulbeot_2nd/README.md new file mode 100644 index 0000000..c853413 --- /dev/null +++ b/03. Code/geulbeot_2nd/README.md @@ -0,0 +1,82 @@ +# Ꞁ벗 Light v1.0 + +상시 업묎용 HTML 볎고서 자동 생성Ʞ + +## 🎯 Ʞ능 + +- **묞서 입력**: HTML 파음 업로드 또는 텍슀튞 직접 입력 +- **페읎지 옵션**: 1페읎지 / 2페읎지 / N페읎지 선택 +- **Claude API**: 각읞된 양식윌로 자동 변환 +- **닀욎로드**: HTML, PDF 지원 +- **HWP 변환**: 로컬 슀크늜튞 제공 + +## 🚀 Railway 배포 + +### 1. GitHub에 푞시 + +```bash +git init +git add . +git commit -m "Initial commit" +git remote add origin https://github.com/YOUR_USERNAME/geulbeot-light.git +git push -u origin main +``` + +### 2. Railway 연동 + +1. [Railway](https://railway.app) 접속 +2. "New Project" → "Deploy from GitHub repo" +3. 저장소 선택 +4. 환겜변수 섀정: + - `ANTHROPIC_API_KEY`: Claude API í‚€ + - `SECRET_KEY`: 임의의 비밀 í‚€ + +### 3. 배포 완료 + +Railway가 자동윌로 빌드 및 배포합니닀. + +## 🖥 로컬 싀행 + +```bash +# 가상환겜 생성 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 팚킀지 섀치 +pip install -r requirements.txt + +# 환겜변수 섀정 +export ANTHROPIC_API_KEY="your-api-key" + +# 싀행 +python app.py +``` + +http://localhost:5000 접속 + +## 📁 프로젝튞 구조 + +``` +geulbeot-light/ +├── app.py # Flask 메읞 앱 +├── templates/ +│ ├── index.html # 메읞 페읎지 +│ └── hwp_guide.html # HWP 변환 가읎드 +├── prompts/ +│ └── system_prompt.txt # Claude 시슀템 프롬프튞 +├── requirements.txt +├── Procfile +├── railway.json +└── README.md +``` + +## 🎚 각읞된 양식 + +- A4 읞쇄 최적화 (210mm × 297mm) +- Noto Sans KR 폰튾 +- Navy 계엎 색상 (#1a365d Ʞ볞) +- 구성요소: page-header, lead-box, section, data-table, bottom-box 등 + +## 📝 띌읎선슀 + +Private - GPD 낎부 사용 diff --git a/03. Code/geulbeot_2nd/api_config.py b/03. Code/geulbeot_2nd/api_config.py new file mode 100644 index 0000000..8efbe7e --- /dev/null +++ b/03. Code/geulbeot_2nd/api_config.py @@ -0,0 +1,17 @@ +"""API í‚€ ꎀ늬 - api_keys.json에서 읜Ʞ""" +import json +from pathlib import Path + +def load_api_keys(): + """프로젝튞 폎더의 api_keys.json에서 API í‚€ 로딩""" + search_path = Path(__file__).resolve().parent + for _ in range(5): + key_file = search_path / 'api_keys.json' + if key_file.exists(): + with open(key_file, 'r', encoding='utf-8') as f: + return json.load(f) + search_path = search_path.parent + print("warning: api_keys.json not found") + return {} + +API_KEYS = load_api_keys() diff --git a/03. Code/geulbeot_2nd/app.py b/03. Code/geulbeot_2nd/app.py new file mode 100644 index 0000000..81ab3ce --- /dev/null +++ b/03. Code/geulbeot_2nd/app.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +2닚계 API 변환 + 대화형 플드백 시슀템 + +Flask + Claude API + Railway +""" + +import os +import json +import anthropic +from flask import Flask, render_template, request, jsonify, Response, session +from datetime import datetime +import io +import re +from api_config import API_KEYS + +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') + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic(api_key=API_KEYS.get('CLAUDE_API_KEY', '')) + + +# ============== 프롬프튞 로드 ============== + +def load_prompt(filename): + """프롬프튞 파음 로드""" + prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None + + +def get_step1_prompt(): + """1닚계: 구조 추출 프롬프튞""" + prompt = load_prompt('step1_extract.txt') + if prompt: + return prompt + # Ʞ볞 프롬프튞 (파음 없을 겜우) + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + +def get_step2_prompt(): + """2닚계: HTML 생성 프롬프튞""" + prompt = load_prompt('step2_generate.txt') + if prompt: + return prompt + # Ʞ볞 프롬프튞 (파음 없을 겜우) + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + +def get_step1_5_prompt(): + """1.5닚계: 배치 계획 프롬프튞""" + prompt = load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + +def get_refine_prompt(): + """플드백 반영 프롬프튞""" + return """당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + +# ============== API 혞출 핚수 ============== + +def call_claude(system_prompt, user_message, max_tokens=8000): + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text): + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text): + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + +def content_too_long(html, max_sections_per_page=4): + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + # 늬슀튞 항목 첎크 + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + # 프로섞슀 슀텝 첎크 + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + +# ============== 띌우튞 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +@app.route('/generate', methods=['POST']) +def generate(): + """볎고서 생성 API (2닚계 처늬)""" + 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', '') + + if not content.strip(): + return jsonify({'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'}), 400 + + # 옵션 + page_option = request.form.get('page_option', '1') + department = request.form.get('department', '쎝ꎄꞰ획싀') + additional_prompt = request.form.get('additional_prompt', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + # JSON 추출 싀팚 시 원볞 귞대로 전달 + structure_json = {"raw_content": content, "parse_failed": True} + + +# ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요. 낎용읎 넘치멎 텍슀튞륌 쀄읎거나 쀄간격을 조정하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞(개요, 핵심 낎용), 2페읎지는 [첚부]로 시작하는 상섞 낎용입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부 1], [첚부 2] 형태로 분할합니닀.' + } + + step2_prompt = get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝: 윘텐잠가 너묎 많윌멎 압축 재요청 + if content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. +텍슀튞륌 쀄읎거나 덜 쀑요한 낎용은 생략하섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 (플드백용) + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return jsonify({ + 'success': True, + 'html': html_content, + 'structure': structure_json + }) + + except anthropic.APIError as e: + return jsonify({'error': f'Claude API 였류: {str(e)}'}), 500 + except Exception as e: + import traceback + return jsonify({'error': f'서버 였류: {str(e)}', 'trace': traceback.format_exc()}), 500 + + +@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', '') + + if not feedback.strip(): + return jsonify({'error': '플드백 낎용을 입력핎죌섞요.'}), 400 + + if not current_html: + return jsonify({'error': '수정할 HTML읎 없습니닀. 뚌저 변환을 싀행핎죌섞요.'}), 400 + + # 원볞 HTML도 컚텍슀튞에 포핚 + original_html = session.get('original_html', '') + + # 플드백 반영 프롬프튞 + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + # 섞션 업데읎튞 + session['current_html'] = new_html + + # 대화 히슀토늬 저장 + conversation = session.get('conversation', []) + conversation.append({'role': 'user', 'content': feedback}) + conversation.append({'role': 'assistant', 'content': '수정 완료'}) + session['conversation'] = conversation + + return jsonify({ + 'success': True, + 'html': new_html + }) + + except anthropic.APIError as e: + return jsonify({'error': f'Claude API 였류: {str(e)}'}), 500 + except Exception as e: + return jsonify({'error': f'서버 였류: {str(e)}'}), 500 + + +@app.route('/refine-selection', methods=['POST']) +def refine_selection(): + """선택된 부분만 수정""" + try: + data = request.json + current_html = data.get('current_html', '') + selected_text = data.get('selected_text', '') + user_request = data.get('request', '') + + if not current_html or not selected_text or not user_request: + return jsonify({'error': '필수 데읎터가 없습니닀.'}), 400 + + # Claude API 혞출 + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", +"content" : f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + # TYPE곌 CONTENT 파싱 + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return jsonify({ + 'success': True, + 'type': edit_type, + 'html': content + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@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 변환 믞지원. HTML 닀욎로드 후 람띌우저에서 읞쇄하섞요.'}), 501 + except Exception as e: + return jsonify({'error': f'PDF 변환 였류: {str(e)}'}), 500 + + +@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'}) + + +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) diff --git a/03. Code/geulbeot_2nd/prompts/step1_5_plan.txt b/03. Code/geulbeot_2nd/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_2nd/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_2nd/prompts/step1_extract.txt b/03. Code/geulbeot_2nd/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_2nd/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_2nd/prompts/step2_generate.txt b/03. Code/geulbeot_2nd/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_2nd/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
        + +
        +

        {{title}}

        +
        +
        +
        +
        +
        {{lead.text}} - 킀워드 강조
        +
        + +
        +
        {{conclusion.label}}
        +
        {{conclusion.text}}
        +
        +
        +
        - 1 -
        +
        + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
        +
        {{section.title}}
        +
          +
        • {{item.keyword}}: {{item.text}} {{highlight}}
        • +
        +
        +``` + +### table → data-table +```html +
        +
        {{section.title}}
        + + + + + + + + + + + + + +
        {{col1}}{{col2}}
        {{text}}{{text}}
        +
        +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
        +
        {{section.title}}
        +
        +
        +
        {{item.title}}
        +

        {{item.text}} {{highlight}}

        +
        +
        +
        +``` + +### two-column → two-col +```html +
        +
        {{section.title}}
        +
        +
        +
        {{item.title}}
        +

        {{item.text}} {{highlight}}

        +
        +
        +
        +``` + +### process → process-container +```html +
        +
        {{section.title}}
        +
        +
        +
        {{step.number}}
        +
        {{step.title}}: {{step.text}}
        +
        +
        ▌
        + +
        +
        +``` + +### qa → qa-grid +```html +
        +
        {{section.title}}
        +
        +
        + Q. {{question}}
        + A. {{answer}} +
        +
        +
        +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

        [첚부] 핎당 낎용에 맞는 제목

        ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

        [첚부] 핎당 페읎지 낎용에 맞는 제목

        ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_2nd/railway.json b/03. Code/geulbeot_2nd/railway.json new file mode 100644 index 0000000..4667ab2 --- /dev/null +++ b/03. Code/geulbeot_2nd/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "gunicorn app:app", + "healthcheckPath": "/health", + "healthcheckTimeout": 100, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/03. Code/geulbeot_2nd/requirements.txt b/03. Code/geulbeot_2nd/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_2nd/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_2nd/static/css/editor.css b/03. Code/geulbeot_2nd/static/css/editor.css new file mode 100644 index 0000000..fc9d982 --- /dev/null +++ b/03. Code/geulbeot_2nd/static/css/editor.css @@ -0,0 +1,205 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 4px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_2nd/static/js/editor.js b/03. Code/geulbeot_2nd/static/js/editor.js new file mode 100644 index 0000000..0aafac8 --- /dev/null +++ b/03. Code/geulbeot_2nd/static/js/editor.js @@ -0,0 +1,554 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
        + + +
        + + + + +
        + + +
        +
        + A + + Ꞁ자 색상 +
        +
        + A + + 배겜 색상 +
        +
        + + + +
        + + + + +
        + + + +
        + +
        + `; + return formatBarHTML; +} + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
        +
        +
        ▩ 표 삜입
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
        헀더낎용
        '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
        + +
        귞늌 섀명
        +
        `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
        '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.preview-container'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); \ No newline at end of file diff --git a/03. Code/geulbeot_2nd/templates/hwp_guide.html b/03. Code/geulbeot_2nd/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_2nd/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
        +
        +
        +
        + ← 메읞윌로 +

        HWP 변환 가읎드

        +
        +
        +
        +
        + +
        + +
        +

        ⚠ HWP 변환 요구사항

        +
          +
        • • Windows 욎영첎제
        • +
        • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
        • +
        • • Python 3.8 읎상
        • +
        +
        + + +
        +

        1. 필요 띌읎람러늬 섀치

        +
        pip install pyhwpx beautifulsoup4
        +
        + + +
        +

        2. 사용 방법

        +
          +
        1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
        2. +
        3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
        4. +
        5. 슀크늜튞 낮 겜로륌 수정합니닀.
        6. +
        7. 슀크늜튞륌 싀행합니닀.
        8. +
        +
        + + +
        +
        +

        3. HWP 변환 슀크늜튞

        + +
        +
        # -*- coding: utf-8 -*-
        +"""
        +Ꞁ벗 Light - HTML → HWP 변환Ʞ
        +Windows + 한Ꞁ 프로귞랚 필요
        +"""
        +
        +from pyhwpx import Hwp
        +from bs4 import BeautifulSoup
        +import os
        +
        +
        +class HtmlToHwpConverter:
        +    def __init__(self, visible=True):
        +        self.hwp = Hwp(visible=visible)
        +        self.colors = {}
        +    
        +    def _init_colors(self):
        +        self.colors = {
        +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
        +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
        +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
        +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
        +            'bg-light': self.hwp.RGBColor(247, 250, 252),
        +            'white': self.hwp.RGBColor(255, 255, 255),
        +            'black': self.hwp.RGBColor(0, 0, 0),
        +        }
        +    
        +    def _mm(self, mm):
        +        return self.hwp.MiliToHwpUnit(mm)
        +    
        +    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 _align(self, align):
        +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
        +        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 _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)
        +    
        +    def _create_header(self, left_text, right_text):
        +        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._font(9, 'medium-gray')
        +            self.hwp.insert_text(left_text)
        +            self.hwp.insert_text("\t" * 12)
        +            self.hwp.insert_text(right_text)
        +            self.hwp.HAction.Run("CloseEx")
        +        except Exception as e:
        +            print(f"뚞늬말 생성 싀팚: {e}")
        +    
        +    def _create_footer(self, text):
        +        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", 1)
        +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
        +            self._align('center')
        +            self._font(8.5, 'medium-gray')
        +            self.hwp.insert_text(text)
        +            self.hwp.HAction.Run("CloseEx")
        +        except Exception as e:
        +            print(f"ꌬ늬말 생성 싀팚: {e}")
        +    
        +    def _convert_lead_box(self, elem):
        +        content = elem.find("div")
        +        if not content:
        +            return
        +        text = ' '.join(content.get_text().split())
        +        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_bottom_box(self, elem):
        +        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)
        +        
        +        self.hwp.create_table(1, 2, treat_as_char=True)
        +        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):
        +        title = section.find(class_="section-title")
        +        if title:
        +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
        +        
        +        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')
        +        self._para()
        +    
        +    def _convert_sheet(self, sheet, is_first_page=False):
        +        if is_first_page:
        +            header = sheet.find(class_="page-header")
        +            if header:
        +                left = header.find(class_="header-left")
        +                right = header.find(class_="header-right")
        +                left_text = left.get_text(strip=True) if left else ""
        +                right_text = right.get_text(strip=True) if right else ""
        +                if left_text or right_text:
        +                    self._create_header(left_text, right_text)
        +            
        +            footer = sheet.find(class_="page-footer")
        +            if footer:
        +                self._create_footer(footer.get_text(strip=True))
        +        
        +        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')
        +            else:
        +                self._para(title_text, 23, 'primary-navy', True, 'center')
        +            self._font(10, 'secondary-navy')
        +            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):
        +        print(f"[입력] {html_path}")
        +        
        +        with open(html_path, 'r', encoding='utf-8') as f:
        +            soup = BeautifulSoup(f.read(), 'html.parser')
        +        
        +        self.hwp.FileNew()
        +        self._init_colors()
        +        
        +        # 페읎지 섀정
        +        try:
        +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
        +            sec = self.hwp.HParameterSet.HSecDef
        +            sec.PageDef.LeftMargin = self._mm(20)
        +            sec.PageDef.RightMargin = self._mm(20)
        +            sec.PageDef.TopMargin = self._mm(20)
        +            sec.PageDef.BottomMargin = self._mm(20)
        +            sec.PageDef.HeaderLen = self._mm(10)
        +            sec.PageDef.FooterLen = self._mm(10)
        +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
        +        except Exception as e:
        +            print(f"페읎지 섀정 싀팚: {e}")
        +        
        +        sheets = soup.find_all(class_="sheet")
        +        total = len(sheets)
        +        print(f"[변환] 쎝 {total} 페읎지")
        +        
        +        for i, sheet in enumerate(sheets, 1):
        +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
        +            self._convert_sheet(sheet, is_first_page=(i == 1))
        +            if i < total:
        +                self.hwp.HAction.Run("BreakPage")
        +        
        +        self.hwp.SaveAs(output_path)
        +        print(f"✅ 저장 완료: {output_path}")
        +    
        +    def close(self):
        +        try:
        +            self.hwp.Quit()
        +        except:
        +            pass
        +
        +
        +def main():
        +    # ====================================
        +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
        +    # ====================================
        +    html_path = r"C:\Users\User\Downloads\report.html"
        +    output_path = r"C:\Users\User\Downloads\report.hwp"
        +    
        +    print("=" * 50)
        +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
        +    print("=" * 50)
        +    
        +    try:
        +        converter = HtmlToHwpConverter(visible=True)
        +        converter.convert(html_path, output_path)
        +        print("\n✅ 변환 완료!")
        +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
        +        converter.close()
        +    except FileNotFoundError:
        +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
        +    except Exception as e:
        +        print(f"\n[에러] {e}")
        +        import traceback
        +        traceback.print_exc()
        +
        +
        +if __name__ == "__main__":
        +    main()
        +
        + + +
        +

        4. 겜로 수정

        +

        슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

        +
        html_path = r"C:\닀욎로드겜로\report.html"
        +output_path = r"C:\저장겜로\report.hwp"
        +
        +
        + + + + diff --git a/03. Code/geulbeot_2nd/templates/index.html b/03. Code/geulbeot_2nd/templates/index.html new file mode 100644 index 0000000..3268249 --- /dev/null +++ b/03. Code/geulbeot_2nd/templates/index.html @@ -0,0 +1,2099 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + +
        + + +
        + + + +
        + + + +
        + + + +
        + + +
        + + + + +
        + +
        + + + +
        + + + +
        + +
        +
        +
        + +
        +
        📄
        +
        HTML을 입력하고 생성하섞요
        +
        좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
        +
        +
        +
        +
        + + + +
        + + +
        +
        + 묞서 섀정 +
        + +
        + +
        +
        묞서 유형
        +
        + +
        + + 📋 Ʞ획서 + + +
        +
        +
        + +
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        [첚부]
        +
        +
        +
        +
        +
        +
        +
        +
        Ʞ획서 (볎고자료)
        +
        임원볎고용 정형화된 1~2페읎지 묞서
        +
        +
        📄 1p 볞묞만 / 1p+1p첚부 / 1p+np첚부
        +
        🎚 Navy 양식 (A4 읞쇄 최적화)
        +
        ✍ 개조식 자동 변환
        +
        +
        +
        + + +
        + + 📄 볎고서 + 쀀비쀑 + +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        볎고서 (HWP)
        +
        RAG êž°ë°˜ 장묞 볎고서 → HWPX 출력
        +
        +
        🏷 AI 슀타음 자동 태깅
        +
        📝 대제목/쀑제목/소제목/볞묞
        +
        ✹ 한Ꞁ에서 슀타음 음ꎄ 변겜
        +
        +
        +
        + + +
        + + 📊 발표자료 + 쀀비쀑 + +
        +
        +
        +
        제목
        +
        +
        +
        +
        +
        볞묞
        +
        +
        +
        +
        +
        +
        ê²°ë¡ 
        +
        +
        +
        +
        발표자료 (PPT)
        +
        프레젠테읎션 형식 슬띌읎드
        +
        +
        📊 슬띌읎드 자동 구성
        +
        🎯 핵심 낎용 추출
        +
        🖌 도식화 자동 생성
        +
        +
        +
        +
        + + + +
        + + +
        + +
        +
        페읎지 구성
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + + +
        +
        요청사항
        + +
        +
        + + + +
        +
        +
        + + +
        +
        + + 쀀비됚 +
        +
        Ꞁ벗 Light v2.0
        +
        + + + + + + + + + + + + +
        + +
        🀖 AI로 수정하Ʞ
        +
        선택된 텍슀튞:
        +
        + + +
        + + + \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/.gitignore b/03. Code/geulbeot_3rd/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_3rd/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_3rd/Procfile b/03. Code/geulbeot_3rd/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_3rd/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_3rd/README.md b/03. Code/geulbeot_3rd/README.md new file mode 100644 index 0000000..aeb961a --- /dev/null +++ b/03. Code/geulbeot_3rd/README.md @@ -0,0 +1,146 @@ +# Ꞁ벗 Light v3.0 + +AI êž°ë°˜ 묞서 자동화 시슀템 — 9닚계 RAG 파읎프띌읞 + 웹 펞집Ʞ + HWP 변환 + +## 🎯 개요 + +닀양한 형식의 입력 묞서(PDF, HWP, 읎믞지, 동영상 등)륌 분석하여 표쀀 HTML 볎고서륌 자동 생성하고, 웹 펞집Ʞ로 수정한 ë’€ HTML/PDF/HWP로 출력하는 시슀템입니닀. + +## 📁 프로젝튞 구조 + +``` +geulbeot_3rd/ +├── app.py # Flask 메읞 서버 (579쀄) +├── api_config.py # API í‚€ 로더 +├── converters/ +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ │ ├── router.py # ë¶„êž° 판당 (ꞎ/짧은 묞서) +│ │ ├── step1_convert.py # 파음→PDF 변환 (783쀄) +│ │ ├── step2_extract.py # 텍슀튞/읎믞지 추출 (788쀄) +│ │ ├── step3_domain.py # 도메읞 분석 (265쀄) +│ │ ├── step4_chunk.py # 청킹 (356쀄) +│ │ ├── step5_rag.py # RAG 임베딩 (141쀄) +│ │ ├── step6_corpus.py # 윔퍌슀 생성 (231쀄) +│ │ ├── step7_index.py # 읞덱싱 + 목찚 생성 (504쀄) +│ │ ├── step8_content.py # 윘텐잠 생성 (1020쀄) +│ │ └── step9_html.py # HTML 생성 (1248쀄) +│ ├── html_to_hwp.py # 볎고서→HWP 변환 (572쀄) +│ └── html_to_hwp_briefing.py # Ʞ획서→HWP 변환 (572쀄) +├── prompts/ +│ ├── step1_extract.txt # 구조 추출 프롬프튞 +│ ├── step1_5_plan.txt # 배치 계획 프롬프튞 +│ └── step2_generate.txt # HTML 생성 프롬프튞 +├── static/ +│ ├── css/editor.css # 펞집Ʞ 슀타음 +│ └── js/editor.js # 펞집Ʞ Ʞ능 +├── templates/ +│ ├── index.html # 메읞 UI +│ └── hwp_guide.html # HWP 변환 가읎드 +├── output/assets/ # 읎믞지 에셋 +├── requirements.txt +├── Procfile +└── railway.json +``` + +## ⚙ 프로섞슀 플로우 + +```mermaid +flowchart TB + subgraph INPUT["📥 Input"] + direction TB + A["🗂 묞서 입력\nPDF, HWP, 읎믞지, 동영상"] --> B["step1: 파음 변환\nPDF 통음"] + B --> C["step2: 텍슀튞/읎믞지 추출\n(GPT API)"] + C --> D{"router.py\n분량 판당\n5000자 Ʞ쀀"} + D -->|"ꞎ 묞서"| E["step3: 도메읞 분석"] + D -->|"짧은 묞서"| H + E --> F["step4: 청킹"] + F --> G["step5: RAG 임베딩"] + G --> H["step6: 윔퍌슀 생성"] + H --> I["step7: 읞덱싱 + 목찚 생성\n(GPT API)"] + end + + subgraph OUTPUT["📀 Output"] + direction TB + I --> J["step8: 윘텐잠 생성\n(Gemini API)"] + J --> K["step9: HTML 생성\n(Gemini API)"] + end + + subgraph EDIT["✏ Edit"] + direction TB + K --> M["웹 펞집Ʞ\neditor.js"] + K --> N["AI 펞집\n/refine (Claude API)"] + end + + subgraph EXPORT["📊 Export"] + direction TB + M & N --> P["HTML / PDF"] + M & N --> Q["HWP 변환\nhtml_to_hwp.py"] + end +``` + +## 🌐 API 띌우튞 + +| 띌우튞 | 메서드 | Ʞ능 | +|--------|--------|------| +| `/` | GET | 메읞 페읎지 | +| `/generate` | POST | Ʞ획서 생성 (1닚계→1.5닚계→2닚계) | +| `/generate-report` | POST | 볎고서 생성 (9닚계 파읎프띌읞) | +| `/refine` | POST | AI 전첎 수정 | +| `/refine-selection` | POST | AI 부분 수정 | +| `/export-hwp` | POST | HWP 변환 | +| `/download/html` | POST | HTML 닀욎로드 | +| `/download/pdf` | POST | PDF 닀욎로드 | +| `/health` | GET | 서버 상태 확읞 | + +## 🀖 활용 AI + +| 닚계 | AI | 역할 | +|------|-----|------| +| step2 (추출) | GPT | PDF에서 텍슀튞/읎믞지 메타데읎터 추출 | +| step7 (목찚) | GPT | 읞덱싱 및 목찚 자동 생성 | +| step8 (윘텐잠) | Gemini | 섹션별 볞묞 쎈안 생성 | +| step9 (HTML) | Gemini | 최종 HTML 볎고서 생성 | +| Ʞ획서 생성 | Claude | HTML 구조 추출 + 변환 | +| AI 펞집 | Claude | 플드백 반영 수정 | + +## 🎚 Ꞁ벗 표쀀 HTML 양식 + +- A4 읞쇄 최적화 (210mm × 297mm) +- Noto Sans KR 폰튾 +- Navy 계엎 색상 (#1a365d Ʞ볞) +- 구성: page-header, lead-box, section, data-table, bottom-box, footer + +## 🖥 로컬 싀행 + +```bash +pip install -r requirements.txt +python app.py +``` + +http://localhost:5000 접속 + +## 🔑 API í‚€ 섀정 + +`api_keys.json` 파음을 프로젝튞 룚튞에 생성: + +```json +{ + "CLAUDE_API_KEY": "sk-ant-...", + "GPT_API_KEY": "sk-proj-...", + "GEMINI_API_KEY": "AIzaSy..." +} +``` + +> ⚠ `api_keys.json`은 `.gitignore`에 포핚되얎 Git에 올띌가지 않습니닀. + +## 📝 v1 → v3 변겜 읎력 + +| 버전 | 변겜 낎용 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ (12파음, 422쀄) | +| v2 | 웹 펞집Ʞ 추가 (editor.js, editor.css) | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 추가 (40파음+, 6000쀄+) | + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/api_config.py b/03. Code/geulbeot_3rd/api_config.py new file mode 100644 index 0000000..8efbe7e --- /dev/null +++ b/03. Code/geulbeot_3rd/api_config.py @@ -0,0 +1,17 @@ +"""API í‚€ ꎀ늬 - api_keys.json에서 읜Ʞ""" +import json +from pathlib import Path + +def load_api_keys(): + """프로젝튞 폎더의 api_keys.json에서 API í‚€ 로딩""" + search_path = Path(__file__).resolve().parent + for _ in range(5): + key_file = search_path / 'api_keys.json' + if key_file.exists(): + with open(key_file, 'r', encoding='utf-8') as f: + return json.load(f) + search_path = search_path.parent + print("warning: api_keys.json not found") + return {} + +API_KEYS = load_api_keys() diff --git a/03. Code/geulbeot_3rd/app.py b/03. Code/geulbeot_3rd/app.py new file mode 100644 index 0000000..27c2e80 --- /dev/null +++ b/03. Code/geulbeot_3rd/app.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +2닚계 API 변환 + 대화형 플드백 시슀템 + +Flask + Claude API + Railway +""" + +import os +import json +import anthropic +from flask import Flask, render_template, request, jsonify, Response, session +from datetime import datetime +import io +import re +from flask import send_file +from datetime import datetime +import tempfile +from converters.pipeline.router import process_document +from api_config import API_KEYS + + +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') + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic(api_key=API_KEYS.get('CLAUDE_API_KEY', '')) + + +# ============== 프롬프튞 로드 ============== + +def load_prompt(filename): + """프롬프튞 파음 로드""" + prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None + + +def get_step1_prompt(): + """1닚계: 구조 추출 프롬프튞""" + prompt = load_prompt('step1_extract.txt') + if prompt: + return prompt + # Ʞ볞 프롬프튞 (파음 없을 겜우) + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + +def get_step2_prompt(): + """2닚계: HTML 생성 프롬프튞""" + prompt = load_prompt('step2_generate.txt') + if prompt: + return prompt + # Ʞ볞 프롬프튞 (파음 없을 겜우) + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + +def get_step1_5_prompt(): + """1.5닚계: 배치 계획 프롬프튞""" + prompt = load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + +def get_refine_prompt(): + """플드백 반영 프롬프튞""" + return """당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + +# ============== API 혞출 핚수 ============== + +def call_claude(system_prompt, user_message, max_tokens=8000): + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text): + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text): + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + +def content_too_long(html, max_sections_per_page=4): + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + # 늬슀튞 항목 첎크 + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + # 프로섞슀 슀텝 첎크 + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + +# ============== 띌우튞 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +@app.route('/generate', methods=['POST']) +def generate(): + """볎고서 생성 API (2닚계 처늬)""" + 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', '') + + if not content.strip(): + return jsonify({'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'}), 400 + + # 옵션 + page_option = request.form.get('page_option', '1') + department = request.form.get('department', '쎝ꎄꞰ획싀') + additional_prompt = request.form.get('additional_prompt', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + # JSON 추출 싀팚 시 원볞 귞대로 전달 + structure_json = {"raw_content": content, "parse_failed": True} + + +# ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요. 낎용읎 넘치멎 텍슀튞륌 쀄읎거나 쀄간격을 조정하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞(개요, 핵심 낎용), 2페읎지는 [첚부]로 시작하는 상섞 낎용입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부 1], [첚부 2] 형태로 분할합니닀.' + } + + step2_prompt = get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝: 윘텐잠가 너묎 많윌멎 압축 재요청 + if content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. +텍슀튞륌 쀄읎거나 덜 쀑요한 낎용은 생략하섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 (플드백용) + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return jsonify({ + 'success': True, + 'html': html_content, + 'structure': structure_json + }) + + except anthropic.APIError as e: + return jsonify({'error': f'Claude API 였류: {str(e)}'}), 500 + except Exception as e: + import traceback + return jsonify({'error': f'서버 였류: {str(e)}', 'trace': traceback.format_exc()}), 500 + + +@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', '') + + if not feedback.strip(): + return jsonify({'error': '플드백 낎용을 입력핎죌섞요.'}), 400 + + if not current_html: + return jsonify({'error': '수정할 HTML읎 없습니닀. 뚌저 변환을 싀행핎죌섞요.'}), 400 + + # 원볞 HTML도 컚텍슀튞에 포핚 + original_html = session.get('original_html', '') + + # 플드백 반영 프롬프튞 + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + # 섞션 업데읎튞 + session['current_html'] = new_html + + # 대화 히슀토늬 저장 + conversation = session.get('conversation', []) + conversation.append({'role': 'user', 'content': feedback}) + conversation.append({'role': 'assistant', 'content': '수정 완료'}) + session['conversation'] = conversation + + return jsonify({ + 'success': True, + 'html': new_html + }) + + except anthropic.APIError as e: + return jsonify({'error': f'Claude API 였류: {str(e)}'}), 500 + except Exception as e: + return jsonify({'error': f'서버 였류: {str(e)}'}), 500 + + +@app.route('/refine-selection', methods=['POST']) +def refine_selection(): + """선택된 부분만 수정""" + try: + data = request.json + current_html = data.get('current_html', '') + selected_text = data.get('selected_text', '') + user_request = data.get('request', '') + + if not current_html or not selected_text or not user_request: + return jsonify({'error': '필수 데읎터가 없습니닀.'}), 400 + + # Claude API 혞출 + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", +"content" : f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + # TYPE곌 CONTENT 파싱 + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return jsonify({ + 'success': True, + 'type': edit_type, + 'html': content + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@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 변환 믞지원. HTML 닀욎로드 후 람띌우저에서 읞쇄하섞요.'}), 501 + except Exception as e: + return jsonify({'error': f'PDF 변환 였류: {str(e)}'}), 500 + + +@app.route('/hwp-script') +def hwp_script(): + """HWP 변환 슀크늜튞 안낎""" + return render_template('hwp_guide.html') + +@app.route('/generate-report', methods=['POST']) +def generate_report_api(): + """볎고서 생성 API (router êž°ë°˜)""" + try: + data = request.get_json() or {} + + # HTML 낎용 (폎더에서 읜거나 직접 입력) + 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', '') + } + + if not content.strip(): + return jsonify({'error': '낎용읎 비얎있습니닀.'}), 400 + + # router로 처늬 + result = process_document(content, options) + + if result.get('success'): + return jsonify(result) + else: + return jsonify({'error': result.get('error', '처늬 싀팚')}), 500 + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + +@app.route('/assets/') +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('/health') +def health(): + """헬슀 첎크""" + return jsonify({'status': 'healthy', 'version': '2.0.0'}) + + +# ===== HWP 변환 ===== +@app.route('/export-hwp', methods=['POST']) +def export_hwp(): + try: + data = request.get_json() + html_content = data.get('html', '') + doc_type = data.get('doc_type', 'briefing') + + 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') + + # HTML 저장 + with open(html_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + # 변환Ʞ import 및 싀행 + if doc_type == 'briefing': + from converters.html_to_hwp_briefing import HtmlToHwpConverter + else: + from converters.html_to_hwp import HtmlToHwpConverter + + converter = HtmlToHwpConverter(visible=False) + converter.convert(html_path, hwp_path) + converter.close() + + # 파음 전송 + 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 + + +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) diff --git a/03. Code/geulbeot_3rd/converters/__init__.py b/03. Code/geulbeot_3rd/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_3rd/converters/html_to_hwp.py b/03. Code/geulbeot_3rd/converters/html_to_hwp.py new file mode 100644 index 0000000..0c143d8 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/html_to_hwp.py @@ -0,0 +1,573 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + +class StyleParser: + def __init__(self): + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0 + for ri, tr in enumerate(table_elem.find_all('tr')): + row, ci = [], 0 + for cell in tr.find_all(['td','th']): + while (ri,ci) in occupied: row.append(""); ci+=1 + txt = cell.get_text(strip=True) + cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1)) + cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0} + row.append(txt) + for dr in range(rs): + for dc in range(cs): + if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True + for _ in range(cs-1): row.append("") + ci += cs + rows_data.append(row) + max_cols = max(max_cols, len(row)) + for row in rows_data: + while len(row) < max_cols: row.append("") + + rc = len(rows_data) + if rc == 0 or max_cols == 0: return + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue + txt = row[ci] if ci < len(row) else "" + hdr = cell_styles.get((ri,ci),{}).get('is_header', False) + if hdr: self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight") + + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + print(f" 📷 읎믞지 #{self.image_count}: {os.path.basename(src)}") + + if not src: + return + + # 상대겜로 → 절대겜로 + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def close(self): + try: self.hwp.Quit() + except: pass + + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp" + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert(html_path, output_path) + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") # ← 선택사항 + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_3rd/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..0c143d8 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/html_to_hwp_briefing.py @@ -0,0 +1,573 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + +class StyleParser: + def __init__(self): + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0 + for ri, tr in enumerate(table_elem.find_all('tr')): + row, ci = [], 0 + for cell in tr.find_all(['td','th']): + while (ri,ci) in occupied: row.append(""); ci+=1 + txt = cell.get_text(strip=True) + cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1)) + cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0} + row.append(txt) + for dr in range(rs): + for dc in range(cs): + if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True + for _ in range(cs-1): row.append("") + ci += cs + rows_data.append(row) + max_cols = max(max_cols, len(row)) + for row in rows_data: + while len(row) < max_cols: row.append("") + + rc = len(rows_data) + if rc == 0 or max_cols == 0: return + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue + txt = row[ci] if ci < len(row) else "" + hdr = cell_styles.get((ri,ci),{}).get('is_header', False) + if hdr: self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight") + + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + print(f" 📷 읎믞지 #{self.image_count}: {os.path.basename(src)}") + + if not src: + return + + # 상대겜로 → 절대겜로 + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def close(self): + try: self.hwp.Quit() + except: pass + + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_v12.hwp" + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert(html_path, output_path) + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") # ← 선택사항 + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/__init__.py b/03. Code/geulbeot_3rd/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_3rd/converters/pipeline/router.py b/03. Code/geulbeot_3rd/converters/pipeline/router.py new file mode 100644 index 0000000..ef41136 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/router.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 상대 읎믞지 겜로륌 서버 겜로로 변환 + assets/xxx.png → /assets/xxx.png + """ + result = re.sub(r'src="assets/', 'src="/assets/', html_content) + return result + + def replace_src(match): + original_path = match.group(1) + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')): + return match.group(0) + + # assets/로 시작하멎 절대 겜로로 변환 + if original_path.startswith('assets/'): + filename = original_path.replace('assets/', '') + absolute_path = os.path.join(ASSETS_BASE_PATH, filename) + return f'src="{absolute_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step1_convert.py b/03. Code/geulbeot_3rd/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..a3b57b6 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\테슀튞 쀑(잡량)\잡량_GIS_드론 ꎀ렚 자료듀" + OUTPUT_DIR = r"D:\for python\테슀튞 쀑(잡량)\추출" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step2_extract.py b/03. Code/geulbeot_3rd/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..be4d6d6 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step3_domain.py b/03. Code/geulbeot_3rd/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..e01a87a --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\extract") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_3rd/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..9680692 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step5_rag.py b/03. Code/geulbeot_3rd/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..30ef48e --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_3rd/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..d3e33d0 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step7_index.py b/03. Code/geulbeot_3rd/converters/pipeline/step7_index.py new file mode 100644 index 0000000..3180719 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
        구분번혞제목킀워드
        대목찚{num}{title}
        쀑목찚{num}{title}
        소목찚{num}{title}{kw}
        + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
        + + +
        +

        {html_escape(report_title)}

        +
        +
        + +
        +
        +
        확정 목찚 표 형태 정늬볞
        +
        +
        목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
        + +
        +
        목찚
        + {table_html} +
        +
        + +
        + + + +
        +
        + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step8_content.py b/03. Code/geulbeot_3rd/converters/pipeline/step8_content.py new file mode 100644 index 0000000..5f66190 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/converters/pipeline/step9_html.py b/03. Code/geulbeot_3rd/converters/pipeline/step9_html.py new file mode 100644 index 0000000..3ee7365 --- /dev/null +++ b/03. Code/geulbeot_3rd/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
        변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
          '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
          ') + lines.append(f'
        • {item.number}. {item.title}
        • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
        • {item.number} {item.title}
        • ') + elif item.level == 3: + lines.append(f'
        • {item.number} {item.title}
        • ') + + if current_l1 is not None: + lines.append('
          ') # 마지막 귞룹 ë‹«êž° + + lines.append('
        ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
        처늬 + cell = cell.replace('
        ', '
        ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
        {cell}
        {cell}
        ') + + # 캡션 추가 + if caption: + html_lines.append(f'
        {caption}
        ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
        변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
        + {caption} +
        {caption_text}
        +
        ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
          ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
        • {content}
        • ') + elif ol_match: + if not in_list: + html_lines.append('
            ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
          1. {content}
          2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

            변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

            {text}

            ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

            {title}

            ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

            {title}

            ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

            {h3_match.group(1)}

            ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

            {h4_match.group(1)}

            ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
            +
            {box_cover}
            +
            {box_toc}
            +
            {box_summary}
            +
            {box_content}
            +
            + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

            {main_title}

            +

            {sub_title}

            +

            {cover_date}

            + {f'

            {cover_author}

            ' if cover_author else ''} + {f'

            {cover_dept}

            ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

            요앜

            \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_1_img01.png b/03. Code/geulbeot_3rd/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_1_img02.png b/03. Code/geulbeot_3rd/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_1_img03.png b/03. Code/geulbeot_3rd/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_2_img01.png b/03. Code/geulbeot_3rd/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_2_img02.png b/03. Code/geulbeot_3rd/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_2_img03.png b/03. Code/geulbeot_3rd/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_3_img01.png b/03. Code/geulbeot_3rd/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_1_3_img02.png b/03. Code/geulbeot_3rd/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_2_1_img03.png b/03. Code/geulbeot_3rd/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_2_2_img01.png b/03. Code/geulbeot_3rd/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_2_2_img02.png b/03. Code/geulbeot_3rd/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_3rd/output/assets/1_2_2_img03.png b/03. Code/geulbeot_3rd/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_3rd/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_3rd/prompts/step1_5_plan.txt b/03. Code/geulbeot_3rd/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_3rd/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/prompts/step1_extract.txt b/03. Code/geulbeot_3rd/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_3rd/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_3rd/prompts/step2_generate.txt b/03. Code/geulbeot_3rd/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_3rd/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
            + +
            +

            {{title}}

            +
            +
            +
            +
            +
            {{lead.text}} - 킀워드 강조
            +
            + +
            +
            {{conclusion.label}}
            +
            {{conclusion.text}}
            +
            +
            +
            - 1 -
            +
            + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
            +
            {{section.title}}
            +
              +
            • {{item.keyword}}: {{item.text}} {{highlight}}
            • +
            +
            +``` + +### table → data-table +```html +
            +
            {{section.title}}
            + + + + + + + + + + + + + +
            {{col1}}{{col2}}
            {{text}}{{text}}
            +
            +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
            +
            {{section.title}}
            +
            +
            +
            {{item.title}}
            +

            {{item.text}} {{highlight}}

            +
            +
            +
            +``` + +### two-column → two-col +```html +
            +
            {{section.title}}
            +
            +
            +
            {{item.title}}
            +

            {{item.text}} {{highlight}}

            +
            +
            +
            +``` + +### process → process-container +```html +
            +
            {{section.title}}
            +
            +
            +
            {{step.number}}
            +
            {{step.title}}: {{step.text}}
            +
            +
            ▌
            + +
            +
            +``` + +### qa → qa-grid +```html +
            +
            {{section.title}}
            +
            +
            + Q. {{question}}
            + A. {{answer}} +
            +
            +
            +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

            [첚부] 핎당 낎용에 맞는 제목

            ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

            [첚부] 핎당 페읎지 낎용에 맞는 제목

            ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/railway.json b/03. Code/geulbeot_3rd/railway.json new file mode 100644 index 0000000..4667ab2 --- /dev/null +++ b/03. Code/geulbeot_3rd/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "gunicorn app:app", + "healthcheckPath": "/health", + "healthcheckTimeout": 100, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/03. Code/geulbeot_3rd/requirements.txt b/03. Code/geulbeot_3rd/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_3rd/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_3rd/static/css/editor.css b/03. Code/geulbeot_3rd/static/css/editor.css new file mode 100644 index 0000000..fc9d982 --- /dev/null +++ b/03. Code/geulbeot_3rd/static/css/editor.css @@ -0,0 +1,205 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 4px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/static/js/editor.js b/03. Code/geulbeot_3rd/static/js/editor.js new file mode 100644 index 0000000..0aafac8 --- /dev/null +++ b/03. Code/geulbeot_3rd/static/js/editor.js @@ -0,0 +1,554 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
            + + +
            + + + + +
            + + +
            +
            + A + + Ꞁ자 색상 +
            +
            + A + + 배겜 색상 +
            +
            + + + +
            + + + + +
            + + + +
            + +
            + `; + return formatBarHTML; +} + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
            +
            +
            ▩ 표 삜입
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            +
            + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
            헀더낎용
            '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
            + +
            귞늌 섀명
            +
            `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
            '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.preview-container'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); \ No newline at end of file diff --git a/03. Code/geulbeot_3rd/templates/hwp_guide.html b/03. Code/geulbeot_3rd/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_3rd/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
            +
            +
            +
            + ← 메읞윌로 +

            HWP 변환 가읎드

            +
            +
            +
            +
            + +
            + +
            +

            ⚠ HWP 변환 요구사항

            +
              +
            • • Windows 욎영첎제
            • +
            • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
            • +
            • • Python 3.8 읎상
            • +
            +
            + + +
            +

            1. 필요 띌읎람러늬 섀치

            +
            pip install pyhwpx beautifulsoup4
            +
            + + +
            +

            2. 사용 방법

            +
              +
            1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
            2. +
            3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
            4. +
            5. 슀크늜튞 낮 겜로륌 수정합니닀.
            6. +
            7. 슀크늜튞륌 싀행합니닀.
            8. +
            +
            + + +
            +
            +

            3. HWP 변환 슀크늜튞

            + +
            +
            # -*- coding: utf-8 -*-
            +"""
            +Ꞁ벗 Light - HTML → HWP 변환Ʞ
            +Windows + 한Ꞁ 프로귞랚 필요
            +"""
            +
            +from pyhwpx import Hwp
            +from bs4 import BeautifulSoup
            +import os
            +
            +
            +class HtmlToHwpConverter:
            +    def __init__(self, visible=True):
            +        self.hwp = Hwp(visible=visible)
            +        self.colors = {}
            +    
            +    def _init_colors(self):
            +        self.colors = {
            +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
            +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
            +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
            +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
            +            'bg-light': self.hwp.RGBColor(247, 250, 252),
            +            'white': self.hwp.RGBColor(255, 255, 255),
            +            'black': self.hwp.RGBColor(0, 0, 0),
            +        }
            +    
            +    def _mm(self, mm):
            +        return self.hwp.MiliToHwpUnit(mm)
            +    
            +    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 _align(self, align):
            +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
            +        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 _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)
            +    
            +    def _create_header(self, left_text, right_text):
            +        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._font(9, 'medium-gray')
            +            self.hwp.insert_text(left_text)
            +            self.hwp.insert_text("\t" * 12)
            +            self.hwp.insert_text(right_text)
            +            self.hwp.HAction.Run("CloseEx")
            +        except Exception as e:
            +            print(f"뚞늬말 생성 싀팚: {e}")
            +    
            +    def _create_footer(self, text):
            +        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", 1)
            +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
            +            self._align('center')
            +            self._font(8.5, 'medium-gray')
            +            self.hwp.insert_text(text)
            +            self.hwp.HAction.Run("CloseEx")
            +        except Exception as e:
            +            print(f"ꌬ늬말 생성 싀팚: {e}")
            +    
            +    def _convert_lead_box(self, elem):
            +        content = elem.find("div")
            +        if not content:
            +            return
            +        text = ' '.join(content.get_text().split())
            +        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_bottom_box(self, elem):
            +        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)
            +        
            +        self.hwp.create_table(1, 2, treat_as_char=True)
            +        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):
            +        title = section.find(class_="section-title")
            +        if title:
            +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
            +        
            +        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')
            +        self._para()
            +    
            +    def _convert_sheet(self, sheet, is_first_page=False):
            +        if is_first_page:
            +            header = sheet.find(class_="page-header")
            +            if header:
            +                left = header.find(class_="header-left")
            +                right = header.find(class_="header-right")
            +                left_text = left.get_text(strip=True) if left else ""
            +                right_text = right.get_text(strip=True) if right else ""
            +                if left_text or right_text:
            +                    self._create_header(left_text, right_text)
            +            
            +            footer = sheet.find(class_="page-footer")
            +            if footer:
            +                self._create_footer(footer.get_text(strip=True))
            +        
            +        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')
            +            else:
            +                self._para(title_text, 23, 'primary-navy', True, 'center')
            +            self._font(10, 'secondary-navy')
            +            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):
            +        print(f"[입력] {html_path}")
            +        
            +        with open(html_path, 'r', encoding='utf-8') as f:
            +            soup = BeautifulSoup(f.read(), 'html.parser')
            +        
            +        self.hwp.FileNew()
            +        self._init_colors()
            +        
            +        # 페읎지 섀정
            +        try:
            +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
            +            sec = self.hwp.HParameterSet.HSecDef
            +            sec.PageDef.LeftMargin = self._mm(20)
            +            sec.PageDef.RightMargin = self._mm(20)
            +            sec.PageDef.TopMargin = self._mm(20)
            +            sec.PageDef.BottomMargin = self._mm(20)
            +            sec.PageDef.HeaderLen = self._mm(10)
            +            sec.PageDef.FooterLen = self._mm(10)
            +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
            +        except Exception as e:
            +            print(f"페읎지 섀정 싀팚: {e}")
            +        
            +        sheets = soup.find_all(class_="sheet")
            +        total = len(sheets)
            +        print(f"[변환] 쎝 {total} 페읎지")
            +        
            +        for i, sheet in enumerate(sheets, 1):
            +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
            +            self._convert_sheet(sheet, is_first_page=(i == 1))
            +            if i < total:
            +                self.hwp.HAction.Run("BreakPage")
            +        
            +        self.hwp.SaveAs(output_path)
            +        print(f"✅ 저장 완료: {output_path}")
            +    
            +    def close(self):
            +        try:
            +            self.hwp.Quit()
            +        except:
            +            pass
            +
            +
            +def main():
            +    # ====================================
            +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
            +    # ====================================
            +    html_path = r"C:\Users\User\Downloads\report.html"
            +    output_path = r"C:\Users\User\Downloads\report.hwp"
            +    
            +    print("=" * 50)
            +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
            +    print("=" * 50)
            +    
            +    try:
            +        converter = HtmlToHwpConverter(visible=True)
            +        converter.convert(html_path, output_path)
            +        print("\n✅ 변환 완료!")
            +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
            +        converter.close()
            +    except FileNotFoundError:
            +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
            +    except Exception as e:
            +        print(f"\n[에러] {e}")
            +        import traceback
            +        traceback.print_exc()
            +
            +
            +if __name__ == "__main__":
            +    main()
            +
            + + +
            +

            4. 겜로 수정

            +

            슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

            +
            html_path = r"C:\닀욎로드겜로\report.html"
            +output_path = r"C:\저장겜로\report.hwp"
            +
            +
            + + + + diff --git a/03. Code/geulbeot_3rd/templates/index.html b/03. Code/geulbeot_3rd/templates/index.html new file mode 100644 index 0000000..0be4db3 --- /dev/null +++ b/03. Code/geulbeot_3rd/templates/index.html @@ -0,0 +1,2247 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + +
            + + +
            + + + +
            + + + +
            + + + + + +
            + + +
            + + + + +
            + +
            + + + +
            + + + +
            + +
            +
            +
            + +
            +
            📄
            +
            HTML을 입력하고 생성하섞요
            +
            좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
            +
            +
            +
            +
            + + + +
            + + +
            +
            + 묞서 섀정 +
            + +
            + +
            +
            묞서 유형
            +
            + +
            + + 📋 Ʞ획서 + + +
            +
            +
            + +
            +
            +
            +
            +
            +
            +
            +
            +
            + +
            [첚부]
            +
            +
            +
            +
            +
            +
            +
            +
            Ʞ획서 (볎고자료)
            +
            임원볎고용 정형화된 1~2페읎지 묞서
            +
            +
            📄 1p 볞묞만 / 1p+1p첚부 / 1p+np첚부
            +
            🎚 Navy 양식 (A4 읞쇄 최적화)
            +
            ✍ 개조식 자동 변환
            +
            +
            +
            + + +
            + + 📄 볎고서 + +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            볎고서 (HWP)
            +
            RAG êž°ë°˜ 장묞 볎고서 → HWPX 출력
            +
            +
            🏷 AI 슀타음 자동 태깅
            +
            📝 대제목/쀑제목/소제목/볞묞
            +
            ✹ 한Ꞁ에서 슀타음 음ꎄ 변겜
            +
            +
            +
            + + +
            + + 📊 발표자료 + 쀀비쀑 + +
            +
            +
            +
            제목
            +
            +
            +
            +
            +
            볞묞
            +
            +
            +
            +
            +
            +
            ê²°ë¡ 
            +
            +
            +
            +
            발표자료 (PPT)
            +
            프레젠테읎션 형식 슬띌읎드
            +
            +
            📊 슬띌읎드 자동 구성
            +
            🎯 핵심 낎용 추출
            +
            🖌 도식화 자동 생성
            +
            +
            +
            +
            + + + +
            + + +
            + +
            +
            페읎지 구성
            +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            +
            + + +
            +
            요청사항
            + +
            +
            + + + + + + + + + + + +
            +
            +
            + + +
            +
            + + 쀀비됚 +
            +
            Ꞁ벗 Light v2.0
            +
            + + + + + + + + + + + + +
            + +
            🀖 AI로 수정하Ʞ
            +
            선택된 텍슀튞:
            +
            + + +
            + + + \ No newline at end of file diff --git a/03. Code/geulbeot_4th/.env.sample b/03. Code/geulbeot_4th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_4th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_4th/.gitignore b/03. Code/geulbeot_4th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_4th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_4th/Procfile b/03. Code/geulbeot_4th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_4th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_4th/README.md b/03. Code/geulbeot_4th/README.md new file mode 100644 index 0000000..6634aca --- /dev/null +++ b/03. Code/geulbeot_4th/README.md @@ -0,0 +1,309 @@ +# Ꞁ벗 (Geulbeot) v4.0 + +**AI êž°ë°˜ 묞서 자동화 시슀템 — GPD 쎝ꎄꞰ획싀** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +RAG 파읎프띌읞 (9닚계) ─── 공통 처늬 + │ + â–Œ +묞서 유형 선택 + ├─ Ʞ획서 (Ʞ볞) + ├─ 볎고서 (Ʞ볞) + ├─ 발표자료 (Ʞ볞) + └─ 사용자 등록 (확장 가능) + │ + â–Œ +Ꞁ벗 표쀀 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 파읎프띌읞 (파음 변환 → 추출 → 도메읞 분석 → 청킹 → 임베딩 → 윔퍌슀 → 읞덱싱 → 윘텐잠 생성 → HTML 조늜) + - 묞서 유형별 생성: Ʞ획서 (Claude 3닚계), 볎고서 (Gemini 2닚계) + - AI 펞집: 전첎 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - HWP 변환: HTML 슀타음 분석 → 역할 맀핑 → HWPX 생성 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 람띌우저에서 생성된 묞서 직접 수정 + - 페읎지 넘김·듀여쓰Ʞ·정렬 등 서식 도구 + - HTML / PDF / HWP 닀욎로드 + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 — ꞎ 묞서는 전첎 파읎프띌읞, 짧은 묞서는 축앜 파읎프띌읞 +- **HWP 변환**: pyhwpx êž°ë°˜ + v4에서 추가된 슀타음 분석Ʞ·HWPX 생성Ʞ·맀핑 몚듈 + +### 4. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: 텍슀튞 또는 파음을 입력하멎, RAG 분석 후 Claude API가 구조 추출 → 페읎지 배치 계획 → Ꞁ벗 표쀀 HTML Ʞ획서륌 생성. 1~N페읎지 옵션 지원 +2. **볎고서 생성**: 폮더 겜로의 자료듀을 RAG 파읎프띌읞윌로 분석하고, Gemini API가 섹션별 윘텐잠 쎈안 → 표지·목찚·간지·별첚읎 포핚된 닀페읎지 HTML 볎고서륌 생성 +3. **AI 펞집**: 생성된 묞서륌 웹 펞집Ʞ에서 확읞 후, "읎 부분을 표로 바꿔쀘" 같은 플드백윌로 전첎 또는 선택 부분을 AI가 수정 +4. **HWP 낎볎낎Ʞ**: Ꞁ벗 HTML을 슀타음 분석Ʞ가 요소별 역할을 분류하고, HWP 슀타음로 맀핑하여 서식읎 유지된 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 +``` + +#### 묞서 유형별 생성 → 펞집 → 출력 + +```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 + + A(["📋 RAG 분석 결곌"]):::startEnd + B{"묞서 유형 선택"}:::decision + + C["Ʞ획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude + D["볎고서 생성\n윘텐잠→HTML 조늜\n⚡ Gemini API"]:::aiGemini + E["발표자료 생성\n예정"]:::planned + F["사용자 등록 유형\n확장 가능"]:::planned + + G["Ꞁ벗 표쀀 HTML\nA4·Navy·Noto Sans KR"]:::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 --> B + B -->|"Ʞ획서"| C --> G + B -->|"볎고서"| D --> G + B -->|"발표자료"| E -.-> G + B -->|"확장"| F -.-> G + + G --> H + H -->|"수Ʞ"| I --> K + H -->|"AI"| J --> K + K -->|"웹/읞쇄"| L --> O + K -->|"HWP"| M --> O + K -->|"PPT"| N -.-> O +``` + +--- + +## 🔄 v3 → v4 변겜사항 + +| 영역 | v3 | v4 | +|------|------|------| +| app.py | 몚든 로직 포핚 (579쀄) | 띌우팅 전닎 (291쀄) | +| 비슈니슀 로직 | app.py 낎부 | handlers/ 팚킀지로 분늬 (briefing + report + common) | +| 프롬프튞 | prompts/ 공용 1ê³³ | handlers/*/prompts/ 몚듈별 분늬 | +| HWP 변환 | pyhwpx 직접 변환만 | + 슀타음 분석Ʞ·HWPX 생성Ʞ·맀핑 몚듈 추가 | +| 환겜섀정 | 없음 | .env + api_config.py (python-dotenv) | + +--- + +## 🗺 상태 및 로드맵 (Status & Roadmap) + +- **Phase 1**: RAG 파읎프띌읞 — 9닚계 파읎프띌읞, 도메읞 분석, 분량 자동 판당 (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 2**: 묞서 생성 — Ʞ획서·볎고서 AI 생성 + Ꞁ벗 표쀀 HTML 양식 (🔧 Ʞ볞 구현) +- **Phase 3**: 출력 — HTML/PDF 닀욎로드, HWP 변환 (🔧 Ʞ볞 구현) +- **Phase 4**: HWP/HWPX/HTML 맀핑 — 슀타음 분석Ʞ, HWPX 생성Ʞ, 역할→HWP 맀핑 (🔧 Ʞ볞 구현) +- **Phase 5**: 묞서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) +- **Phase 6**: HWPX 템플늿 ꎀ늬 — 파싱·시맚틱 맀핑·슀타음 추출·표 맀칭·윘텐잠 죌입 (예정) +- **Phase 7**: UI 고도화 — 프론튞 몚듈화, 데몚 몚드, AI 펞집 개선, 도메읞 선택Ʞ (예정) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v4.git +cd geulbeot-v4 + +# 가상환겜 +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_4th/ +├── app.py # Flask 웹 서버 — API 띌우팅 +├── api_config.py # .env 환겜변수 로더 +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출, JSON/HTML 추출 +│ ├── briefing/ # Ʞ획서 처늬 +│ │ ├── processor.py # 구조추출 → 배치계획 → HTML 생성 +│ │ └── prompts/ # 각 닚계별 AI 프롬프튞 +│ └── report/ # 볎고서 처늬 +│ ├── processor.py # RAG 파읎프띌읞 연동 + AI 펞집 +│ └── prompts/ +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ │ ├── router.py # 분량 판당 (5,000자 Ʞ쀀) +│ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→윔퍌슀→읞덱싱→윘텐잠→HTML +│ ├── style_analyzer.py # HTML 요소 역할 분류 (v4 신규) +│ ├── hwpx_generator.py # HWPX 파음 직접 생성 (v4 신규) +│ ├── hwp_style_mapping.py # 역할 → HWP 슀타음 맀핑 (v4 신규) +│ ├── html_to_hwp.py # 볎고서 → HWP 변환 +│ └── html_to_hwp_briefing.py # Ʞ획서 → HWP 변환 +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 펞집Ʞ +│ └── css/editor.css # 펞집Ʞ 슀타음 +├── templates/ +│ ├── index.html # 메읞 UI +│ └── hwp_guide.html # HWP 변환 가읎드 +│ +├── .env / .env.sample # API í‚€ ꎀ늬 +├── .gitignore +├── requirements.txt +├── Procfile # 배포 섀정 (Gunicorn) +└── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 묞서 유형: Ʞ획서·볎고서만 구현, 발표자료·사용자 등록 유형 믞구현 +- 레거시 잔졎: prompts/ 디렉토늬, dkdl.py 테슀튞 윔드 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 9,780 | +| 프론튞엔드 (JS + CSS + HTML) | 3,859 | +| **합계** | **~13,600** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| **v4** | **윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_4th/api_config.py b/03. Code/geulbeot_4th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_4th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_4th/app.py b/03. Code/geulbeot_4th/app.py new file mode 100644 index 0000000..e2de47f --- /dev/null +++ b/03. Code/geulbeot_4th/app.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +Flask 띌우팅 + 공통 Ʞ능 +""" + +import os +import io +import tempfile +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response, session, send_file + +# 묞서 유형별 프로섞서 +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor + +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') + +# 프로섞서 읞슀턎슀 +processors = { + 'briefing': BriefingProcessor(), + 'report': ReportProcessor() +} + + +# ============== 메읞 페읎지 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +# ============== 생성 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', '') + + options = { + 'page_option': request.form.get('page_option', '1'), + 'department': request.form.get('department', '쎝ꎄꞰ획싀'), + 'instruction': request.form.get('instruction', '') + } + + result = processors['briefing'].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', '') + } + + 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/') +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: + converter.convert_with_styles(html_path, hwp_path) + 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 + + +@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 + + +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) \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/__init__.py b/03. Code/geulbeot_4th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_4th/converters/dkdl.py b/03. Code/geulbeot_4th/converters/dkdl.py new file mode 100644 index 0000000..1ba3302 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/dkdl.py @@ -0,0 +1,37 @@ +from pyhwpx import Hwp + +hwp = Hwp() +hwp.FileNew() + +# HTML 헀딩 레벚 → 한Ꞁ Ʞ볞 슀타음 맀핑 +heading_style_map = { + 'h1': 1, # 개요 1 + 'h2': 2, # 개요 2 + 'h3': 3, # 개요 3 + 'h4': 4, # 개요 4 + 'h5': 5, # 개요 5 + 'h6': 6, # 개요 6 +} + +def apply_heading_style(text, tag): + """HTML 태귞에 맞는 슀타음 적용""" + hwp.insert_text(text) + hwp.HAction.Run("MoveLineBegin") + hwp.HAction.Run("MoveSelLineEnd") + + # 핎당 태귞의 슀타음 번혞로 적용 + style_num = heading_style_map.get(tag, 0) + if style_num: + hwp.HAction.Run(f"StyleShortcut{style_num}") + + hwp.HAction.Run("MoveLineEnd") + hwp.BreakPara() + +# 테슀튞 +apply_heading_style("1장 서론", 'h1') +apply_heading_style("1.1 연구의 배겜", 'h2') +apply_heading_style("1.1.1 섞부 낎용", 'h3') +apply_heading_style("볞묞 텍슀튞", 'p') # 음반 텍슀튞 + +hwp.SaveAs(r"D:\test_output.hwp") +print("완료!") \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/html_to_hwp.py b/03. Code/geulbeot_4th/converters/html_to_hwp.py new file mode 100644 index 0000000..73af99a --- /dev/null +++ b/03. Code/geulbeot_4th/converters/html_to_hwp.py @@ -0,0 +1,1013 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + rows_data, cell_styles, occupied, max_cols = [], {}, {}, 0 + for ri, tr in enumerate(table_elem.find_all('tr')): + row, ci = [], 0 + for cell in tr.find_all(['td','th']): + while (ri,ci) in occupied: row.append(""); ci+=1 + txt = cell.get_text(strip=True) + cs, rs = int(cell.get('colspan',1)), int(cell.get('rowspan',1)) + cell_styles[(ri,ci)] = {'is_header': cell.name=='th' or ri==0} + row.append(txt) + for dr in range(rs): + for dc in range(cs): + if dr>0 or dc>0: occupied[(ri+dr,ci+dc)] = True + for _ in range(cs-1): row.append("") + ci += cs + rows_data.append(row) + max_cols = max(max_cols, len(row)) + for row in rows_data: + while len(row) < max_cols: row.append("") + + rc = len(rows_data) + if rc == 0 or max_cols == 0: return + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + if (ri,ci) in occupied: self.hwp.HAction.Run("MoveRight"); continue + txt = row[ci] if ci < len(row) else "" + hdr = cell_styles.get((ri,ci),{}).get('is_header', False) + if hdr: self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + if not (ri==rc-1 and ci==max_cols-1): self.hwp.HAction.Run("MoveRight") + + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 + + ✅ 수정: Ʞ졎 convert() 로직 + 슀타음 적용 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + + # 1. HTML 파음 읜Ʞ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # 2. 슀타음 분석 + from converters.style_analyzer import StyleAnalyzer + from converters.hwp_style_mapping import HwpStyGenerator + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + html_styles = analyzer.extract_css_styles(html_content) + + print(f"\n📊 분석 결곌: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # 3. 슀타음 맀핑 생성 + sty_gen = HwpStyGenerator() + sty_gen.update_from_html(html_styles) + self.style_map = sty_gen.apply_to_hwp(self.hwp) # Dict[str, HwpStyle] + self.sty_gen = sty_gen # 나쀑에 사용 + + # 4. ★ Ʞ졎 convert() 로직 귞대로 사용 ★ + soup = BeautifulSoup(html_content, '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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: + self._process(ch) + self.hwp.HAction.Run("BreakPage") + + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara() + self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + # 5. 저장 + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + + def _insert_styled_element(self, elem: 'StyledElement'): + """슀타음읎 지정된 요소 삜입 (수정됚)""" + role = elem.role + text = elem.text + + # ═══ 특수 요소 처늬 ═══ + + # 귞늌 + if role == 'FIGURE': + src = elem.attributes.get('src', '') + if src: + self._insert_image(src) + return + + # 표 + if role == 'TABLE': + self._insert_table_from_element(elem) + return + + # 표 셀/캡션은 TABLE에서 처늬 + if role in ['TH', 'TD']: + return + + # 빈 텍슀튞 슀킵 + if not text: + return + + # ═══ 텍슀튞 요소 처늬 ═══ + + # 번혞 제거 (HWP 개요가 자동 생성하멎) + # clean_text = strip_numbering(text, role) # 필요시 활성화 + clean_text = text # 음닚 원볞 유지 + + # 1. 슀타음 섀정 가젞였Ʞ + style_config = self._get_style_config(role) + + # 2. 묞닚 몚양 뚌저 적용 + self._set_para( + align=style_config.get('align', 'justify'), + lh=style_config.get('line_height', 160), + left=style_config.get('indent_left', 0), + indent=style_config.get('indent_first', 0), + before=style_config.get('space_before', 0), + after=style_config.get('space_after', 0) + ) + + # 3. Ꞁ자 몚양 적용 + self._set_font( + size=style_config.get('font_size', 11), + bold=style_config.get('bold', False), + color=style_config.get('color', '#000000') + ) + + # 4. 텍슀튞 삜입 + self.hwp.insert_text(clean_text) + + # 5. 슀타음 적용 (F6 목록에서 찞조되도록) + style_name = self.style_map.get(role) + if style_name: + try: + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HAction.Run("MoveLineEnd") + except: + pass # 슀타음 없윌멎 묎시 + + # 6. 쀄바꿈 + self.hwp.BreakPara() + + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_4th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/hwp_style_mapping.py b/03. Code/geulbeot_4th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/hwpx_generator.py b/03. Code/geulbeot_4th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_4th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
            +

            걎섀·토목 잡량 DX 싀묎지칚

            +

            드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

            +

            2024년 1월

            +
            + +

            1. 개요

            +

            볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

            + +

            1.1 배겜

            +

            최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

            + +

            1.1.1 Ʞ술 동향

            +

            1) 드론 잡량의 발전

            +

            드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

            + +

            (1) RTK 드론

            +

            싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

            + +
              +
            • 고정밀 GPS 수신Ʞ 낎장
            • +
            • 섌티믞터 닚위 정확도
            • +
            + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/__init__.py b/03. Code/geulbeot_4th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_4th/converters/pipeline/router.py b/03. Code/geulbeot_4th/converters/pipeline/router.py new file mode 100644 index 0000000..ef41136 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/router.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 상대 읎믞지 겜로륌 서버 겜로로 변환 + assets/xxx.png → /assets/xxx.png + """ + result = re.sub(r'src="assets/', 'src="/assets/', html_content) + return result + + def replace_src(match): + original_path = match.group(1) + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')): + return match.group(0) + + # assets/로 시작하멎 절대 겜로로 변환 + if original_path.startswith('assets/'): + filename = original_path.replace('assets/', '') + absolute_path = os.path.join(ASSETS_BASE_PATH, filename) + return f'src="{absolute_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_4th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..a3b57b6 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\테슀튞 쀑(잡량)\잡량_GIS_드론 ꎀ렚 자료듀" + OUTPUT_DIR = r"D:\for python\테슀튞 쀑(잡량)\추출" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_4th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..be4d6d6 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_4th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..e01a87a --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\extract") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_4th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_4th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..9680692 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_4th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..30ef48e --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_4th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_4th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..d3e33d0 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step7_index.py b/03. Code/geulbeot_4th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..3180719 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
            구분번혞제목킀워드
            대목찚{num}{title}
            쀑목찚{num}{title}
            소목찚{num}{title}{kw}
            + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
            + + +
            +

            {html_escape(report_title)}

            +
            +
            + +
            +
            +
            확정 목찚 표 형태 정늬볞
            +
            +
            목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
            + +
            +
            목찚
            + {table_html} +
            +
            + +
            + + + +
            +
            + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_4th/converters/pipeline/step8_content.py b/03. Code/geulbeot_4th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..5f66190 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/pipeline/step9_html.py b/03. Code/geulbeot_4th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..3ee7365 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
            변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
              '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
              ') + lines.append(f'
            • {item.number}. {item.title}
            • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
            • {item.number} {item.title}
            • ') + elif item.level == 3: + lines.append(f'
            • {item.number} {item.title}
            • ') + + if current_l1 is not None: + lines.append('
              ') # 마지막 귞룹 ë‹«êž° + + lines.append('
            ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
            처늬 + cell = cell.replace('
            ', '
            ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
            {cell}
            {cell}
            ') + + # 캡션 추가 + if caption: + html_lines.append(f'
            {caption}
            ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
            변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
            + {caption} +
            {caption_text}
            +
            ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
              ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
            • {content}
            • ') + elif ol_match: + if not in_list: + html_lines.append('
                ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
              1. {content}
              2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                {text}

                ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                {title}

                ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                {title}

                ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                {h3_match.group(1)}

                ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                {h4_match.group(1)}

                ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                +
                {box_cover}
                +
                {box_toc}
                +
                {box_summary}
                +
                {box_content}
                +
                + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                {main_title}

                +

                {sub_title}

                +

                {cover_date}

                + {f'

                {cover_author}

                ' if cover_author else ''} + {f'

                {cover_dept}

                ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                요앜

                \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_4th/converters/style_analyzer.py b/03. Code/geulbeot_4th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_4th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                + +
                +

                1 DX 개요와 Ʞ볞 개념

                +

                1.1 잡량 DX 프레임

                +

                1.1.1 잡량 DX 발전 닚계

                +

                1) Digitization 정의

                +

                볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                +

                (1) 닚계별 정의 및 진화

                +

                잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                +
                + +
                + +
                + +
                +

                ① 첫 번짞 항목

                + + + + +
                표 1. 데읎터 비교
                구분낎용
                항목1섀명1
                +
                + +
                + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/__init__.py b/03. Code/geulbeot_4th/handlers/__init__.py new file mode 100644 index 0000000..7c7e687 --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +handlers 팚킀지 +묞서 유형별 처늬 로직을 분늬하여 ꎀ늬 +""" \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/briefing/__init__.py b/03. Code/geulbeot_4th/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 몚듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/briefing/processor.py b/03. Code/geulbeot_4th/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 로직 +- 1~2페읎지 압축형 볎고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """Ʞ획서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1닚계: 구조 추출 프롬프튞""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5닚계: 배치 계획 프롬프튞""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + + def _get_step2_prompt(self) -> str: + """2닚계: HTML 생성 프롬프튞""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """Ʞ획서 생성""" + try: + if not content.strip(): + return {'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '쎝ꎄꞰ획싀') + additional_prompt = options.get('instruction', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞, 2페읎지는 [첚부]입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부] 형태로 분할합니닀.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝 + if self._content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_5_plan.txt b/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_extract.txt b/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_4th/handlers/briefing/prompts/step2_generate.txt b/03. Code/geulbeot_4th/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                + +
                +

                {{title}}

                +
                +
                +
                +
                +
                {{lead.text}} - 킀워드 강조
                +
                + +
                +
                {{conclusion.label}}
                +
                {{conclusion.text}}
                +
                +
                +
                - 1 -
                +
                + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                +
                {{section.title}}
                +
                  +
                • {{item.keyword}}: {{item.text}} {{highlight}}
                • +
                +
                +``` + +### table → data-table +```html +
                +
                {{section.title}}
                + + + + + + + + + + + + + +
                {{col1}}{{col2}}
                {{text}}{{text}}
                +
                +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                +
                {{section.title}}
                +
                +
                +
                {{item.title}}
                +

                {{item.text}} {{highlight}}

                +
                +
                +
                +``` + +### two-column → two-col +```html +
                +
                {{section.title}}
                +
                +
                +
                {{item.title}}
                +

                {{item.text}} {{highlight}}

                +
                +
                +
                +``` + +### process → process-container +```html +
                +
                {{section.title}}
                +
                +
                +
                {{step.number}}
                +
                {{step.title}}: {{step.text}}
                +
                +
                ▌
                + +
                +
                +``` + +### qa → qa-grid +```html +
                +
                {{section.title}}
                +
                +
                + Q. {{question}}
                + A. {{answer}} +
                +
                +
                +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                [첚부] 핎당 낎용에 맞는 제목

                ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                [첚부] 핎당 페읎지 낎용에 맞는 제목

                ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/common.py b/03. Code/geulbeot_4th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/report/__init__.py b/03. Code/geulbeot_4th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/report/processor.py b/03. Code/geulbeot_4th/handlers/report/processor.py new file mode 100644 index 0000000..eeaa2f7 --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/report/processor.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_4th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_4th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_4th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_4th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_4th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_4th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_4th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_4th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_4th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_4th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_4th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_4th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_4th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_4th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_4th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_4th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_4th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_4th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_4th/prompts/step1_5_plan.txt b/03. Code/geulbeot_4th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_4th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_4th/prompts/step1_extract.txt b/03. Code/geulbeot_4th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_4th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_4th/prompts/step2_generate.txt b/03. Code/geulbeot_4th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_4th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                + +
                +

                {{title}}

                +
                +
                +
                +
                +
                {{lead.text}} - 킀워드 강조
                +
                + +
                +
                {{conclusion.label}}
                +
                {{conclusion.text}}
                +
                +
                +
                - 1 -
                +
                + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                +
                {{section.title}}
                +
                  +
                • {{item.keyword}}: {{item.text}} {{highlight}}
                • +
                +
                +``` + +### table → data-table +```html +
                +
                {{section.title}}
                + + + + + + + + + + + + + +
                {{col1}}{{col2}}
                {{text}}{{text}}
                +
                +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                +
                {{section.title}}
                +
                +
                +
                {{item.title}}
                +

                {{item.text}} {{highlight}}

                +
                +
                +
                +``` + +### two-column → two-col +```html +
                +
                {{section.title}}
                +
                +
                +
                {{item.title}}
                +

                {{item.text}} {{highlight}}

                +
                +
                +
                +``` + +### process → process-container +```html +
                +
                {{section.title}}
                +
                +
                +
                {{step.number}}
                +
                {{step.title}}: {{step.text}}
                +
                +
                ▌
                + +
                +
                +``` + +### qa → qa-grid +```html +
                +
                {{section.title}}
                +
                +
                + Q. {{question}}
                + A. {{answer}} +
                +
                +
                +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                [첚부] 핎당 낎용에 맞는 제목

                ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                [첚부] 핎당 페읎지 낎용에 맞는 제목

                ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_4th/requirements.txt b/03. Code/geulbeot_4th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_4th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_4th/static/css/editor.css b/03. Code/geulbeot_4th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_4th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_4th/static/js/editor.js b/03. Code/geulbeot_4th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_4th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                + + + +
                + + + + +
                + + +
                +
                + A + +
                +
                + A + +
                + + +
                + + + +
                + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                +
                +
                ▩ 표 삜입
                +
                + + +
                +
                + + +
                +
                + + +
                +
                + + +
                +
                +
                + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                헀더낎용
                '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                + +
                귞늌 섀명
                +
                `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_4th/templates/hwp_guide.html b/03. Code/geulbeot_4th/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_4th/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
                +
                +
                +
                + ← 메읞윌로 +

                HWP 변환 가읎드

                +
                +
                +
                +
                + +
                + +
                +

                ⚠ HWP 변환 요구사항

                +
                  +
                • • Windows 욎영첎제
                • +
                • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
                • +
                • • Python 3.8 읎상
                • +
                +
                + + +
                +

                1. 필요 띌읎람러늬 섀치

                +
                pip install pyhwpx beautifulsoup4
                +
                + + +
                +

                2. 사용 방법

                +
                  +
                1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
                2. +
                3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
                4. +
                5. 슀크늜튞 낮 겜로륌 수정합니닀.
                6. +
                7. 슀크늜튞륌 싀행합니닀.
                8. +
                +
                + + +
                +
                +

                3. HWP 변환 슀크늜튞

                + +
                +
                # -*- coding: utf-8 -*-
                +"""
                +Ꞁ벗 Light - HTML → HWP 변환Ʞ
                +Windows + 한Ꞁ 프로귞랚 필요
                +"""
                +
                +from pyhwpx import Hwp
                +from bs4 import BeautifulSoup
                +import os
                +
                +
                +class HtmlToHwpConverter:
                +    def __init__(self, visible=True):
                +        self.hwp = Hwp(visible=visible)
                +        self.colors = {}
                +    
                +    def _init_colors(self):
                +        self.colors = {
                +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
                +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
                +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
                +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
                +            'bg-light': self.hwp.RGBColor(247, 250, 252),
                +            'white': self.hwp.RGBColor(255, 255, 255),
                +            'black': self.hwp.RGBColor(0, 0, 0),
                +        }
                +    
                +    def _mm(self, mm):
                +        return self.hwp.MiliToHwpUnit(mm)
                +    
                +    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 _align(self, align):
                +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
                +        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 _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)
                +    
                +    def _create_header(self, left_text, right_text):
                +        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._font(9, 'medium-gray')
                +            self.hwp.insert_text(left_text)
                +            self.hwp.insert_text("\t" * 12)
                +            self.hwp.insert_text(right_text)
                +            self.hwp.HAction.Run("CloseEx")
                +        except Exception as e:
                +            print(f"뚞늬말 생성 싀팚: {e}")
                +    
                +    def _create_footer(self, text):
                +        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", 1)
                +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
                +            self._align('center')
                +            self._font(8.5, 'medium-gray')
                +            self.hwp.insert_text(text)
                +            self.hwp.HAction.Run("CloseEx")
                +        except Exception as e:
                +            print(f"ꌬ늬말 생성 싀팚: {e}")
                +    
                +    def _convert_lead_box(self, elem):
                +        content = elem.find("div")
                +        if not content:
                +            return
                +        text = ' '.join(content.get_text().split())
                +        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_bottom_box(self, elem):
                +        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)
                +        
                +        self.hwp.create_table(1, 2, treat_as_char=True)
                +        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):
                +        title = section.find(class_="section-title")
                +        if title:
                +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
                +        
                +        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')
                +        self._para()
                +    
                +    def _convert_sheet(self, sheet, is_first_page=False):
                +        if is_first_page:
                +            header = sheet.find(class_="page-header")
                +            if header:
                +                left = header.find(class_="header-left")
                +                right = header.find(class_="header-right")
                +                left_text = left.get_text(strip=True) if left else ""
                +                right_text = right.get_text(strip=True) if right else ""
                +                if left_text or right_text:
                +                    self._create_header(left_text, right_text)
                +            
                +            footer = sheet.find(class_="page-footer")
                +            if footer:
                +                self._create_footer(footer.get_text(strip=True))
                +        
                +        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')
                +            else:
                +                self._para(title_text, 23, 'primary-navy', True, 'center')
                +            self._font(10, 'secondary-navy')
                +            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):
                +        print(f"[입력] {html_path}")
                +        
                +        with open(html_path, 'r', encoding='utf-8') as f:
                +            soup = BeautifulSoup(f.read(), 'html.parser')
                +        
                +        self.hwp.FileNew()
                +        self._init_colors()
                +        
                +        # 페읎지 섀정
                +        try:
                +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
                +            sec = self.hwp.HParameterSet.HSecDef
                +            sec.PageDef.LeftMargin = self._mm(20)
                +            sec.PageDef.RightMargin = self._mm(20)
                +            sec.PageDef.TopMargin = self._mm(20)
                +            sec.PageDef.BottomMargin = self._mm(20)
                +            sec.PageDef.HeaderLen = self._mm(10)
                +            sec.PageDef.FooterLen = self._mm(10)
                +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
                +        except Exception as e:
                +            print(f"페읎지 섀정 싀팚: {e}")
                +        
                +        sheets = soup.find_all(class_="sheet")
                +        total = len(sheets)
                +        print(f"[변환] 쎝 {total} 페읎지")
                +        
                +        for i, sheet in enumerate(sheets, 1):
                +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
                +            self._convert_sheet(sheet, is_first_page=(i == 1))
                +            if i < total:
                +                self.hwp.HAction.Run("BreakPage")
                +        
                +        self.hwp.SaveAs(output_path)
                +        print(f"✅ 저장 완료: {output_path}")
                +    
                +    def close(self):
                +        try:
                +            self.hwp.Quit()
                +        except:
                +            pass
                +
                +
                +def main():
                +    # ====================================
                +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
                +    # ====================================
                +    html_path = r"C:\Users\User\Downloads\report.html"
                +    output_path = r"C:\Users\User\Downloads\report.hwp"
                +    
                +    print("=" * 50)
                +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
                +    print("=" * 50)
                +    
                +    try:
                +        converter = HtmlToHwpConverter(visible=True)
                +        converter.convert(html_path, output_path)
                +        print("\n✅ 변환 완료!")
                +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
                +        converter.close()
                +    except FileNotFoundError:
                +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
                +    except Exception as e:
                +        print(f"\n[에러] {e}")
                +        import traceback
                +        traceback.print_exc()
                +
                +
                +if __name__ == "__main__":
                +    main()
                +
                + + +
                +

                4. 겜로 수정

                +

                슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

                +
                html_path = r"C:\닀욎로드겜로\report.html"
                +output_path = r"C:\저장겜로\report.hwp"
                +
                +
                + + + + diff --git a/03. Code/geulbeot_4th/templates/index.html b/03. Code/geulbeot_4th/templates/index.html new file mode 100644 index 0000000..e496d71 --- /dev/null +++ b/03. Code/geulbeot_4th/templates/index.html @@ -0,0 +1,2356 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + + + +
                + + +
                + + + +
                + + + +
                + + + + + +
                + + +
                + + + + +
                + +
                +
                +
                + +
                +
                📄
                +
                HTML을 입력하고 생성하섞요
                +
                좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                +
                +
                +
                +
                + + + +
                + + +
                +
                + 묞서 섀정 +
                + +
                + +
                +
                묞서 유형
                +
                + +
                + + 📋 Ʞ획서 + + +
                +
                +
                + +
                +
                +
                +
                +
                +
                +
                +
                +
                + +
                [첚부]
                +
                +
                +
                +
                +
                +
                +
                +
                Ʞ획서 (볎고자료)
                +
                임원볎고용 정형화된 1~2페읎지 묞서
                +
                +
                📄 1p 볞묞만 / 1p+1p첚부 / 1p+np첚부
                +
                🎚 Navy 양식 (A4 읞쇄 최적화)
                +
                ✍ 개조식 자동 변환
                +
                +
                +
                + + +
                + + 📄 볎고서 + +
                +
                +
                +
                +
                +
                +
                +
                +
                +
                +
                +
                +
                볎고서 (HWP)
                +
                RAG êž°ë°˜ 장묞 볎고서 → HWPX 출력
                +
                +
                🏷 AI 슀타음 자동 태깅
                +
                📝 대제목/쀑제목/소제목/볞묞
                +
                ✹ 한Ꞁ에서 슀타음 음ꎄ 변겜
                +
                +
                +
                + + +
                + + 📊 발표자료 + 쀀비쀑 + +
                +
                +
                +
                제목
                +
                +
                +
                +
                +
                볞묞
                +
                +
                +
                +
                +
                +
                ê²°ë¡ 
                +
                +
                +
                +
                발표자료 (PPT)
                +
                프레젠테읎션 형식 슬띌읎드
                +
                +
                📊 슬띌읎드 자동 구성
                +
                🎯 핵심 낎용 추출
                +
                🖌 도식화 자동 생성
                +
                +
                +
                +
                + + + +
                + + +
                + +
                +
                페읎지 구성
                +
                +
                + + +
                +
                + + +
                +
                + + +
                +
                +
                + + +
                +
                요청사항
                + +
                +
                + + + + + + + + + + + +
                +
                +
                + + +
                +
                + + 쀀비됚 +
                +
                Ꞁ벗 Light v2.0
                +
                + + + + + + + + + + + + +
                + +
                🀖 AI로 수정하Ʞ
                +
                선택된 텍슀튞:
                +
                + + +
                + + + \ No newline at end of file diff --git a/03. Code/geulbeot_5th/.env.sample b/03. Code/geulbeot_5th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_5th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_5th/.gitignore b/03. Code/geulbeot_5th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_5th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_5th/Procfile b/03. Code/geulbeot_5th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_5th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_5th/README.md b/03. Code/geulbeot_5th/README.md new file mode 100644 index 0000000..e401f77 --- /dev/null +++ b/03. Code/geulbeot_5th/README.md @@ -0,0 +1,338 @@ +# Ꞁ벗 (Geulbeot) v5.0 + +**HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +v5에서는 HWP 변환 품질을 고도화했습니닀. Ʞ졎 pyhwpx Ʞ볞 변환에 HWPX 후처늬륌 추가하여, +컀슀텀 슀타음 죌입곌 표 ì—Ž 너비 정밀 조정읎 가능핎졌습니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +RAG 파읎프띌읞 (9닚계) ─── 공통 처늬 + │ + â–Œ +묞서 유형 선택 + ├─ Ʞ획서 (Ʞ볞) + ├─ 볎고서 (Ʞ볞) + ├─ 발표자료 (Ʞ볞) + └─ 사용자 등록 (확장 가능) + │ + â–Œ +Ꞁ벗 표쀀 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 파읎프띌읞 (파음 변환 → 추출 → 도메읞 분석 → 청킹 → 임베딩 → 윔퍌슀 → 읞덱싱 → 윘텐잠 생성 → HTML 조늜) + - 묞서 유형별 생성: Ʞ획서 (Claude 3닚계), 볎고서 (Gemini 2닚계) + - AI 펞집: 전첎 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - HWP 변환: 하읎람늬드 방식 — pyhwpx Ʞ볞 생성 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 람띌우저에서 생성된 묞서 직접 수정 + - 페읎지 넘김·듀여쓰Ʞ·정렬 등 서식 도구 + - HTML / PDF / HWP 닀욎로드 + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 — ꞎ 묞서는 전첎 파읎프띌읞, 짧은 묞서는 축앜 파읎프띌읞 +- **HWP 변환 (v5 하읎람늬드 방식)**: + 1. HTML 분석 → StyleAnalyzer로 역할 분류 + 2. pyhwpx Ʞ볞 변환 (표·읎믞지·뚞늬말·ꌬ늬말 정상 처늬) + 3. HWP → HWPX 변환 + 4. HWPX 후처늬 — header.xml에 컀슀텀 슀타음 정의 죌입, section*.xml에 역할별 styleIDRef 맀핑 + 5. HWPX 후처늬 — 표 ì—Ž 너비 정밀 수정 (px/mm/% → HWPML 닚위 변환) + +### 4. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: 텍슀튞 또는 파음을 입력하멎, RAG 분석 후 Claude API가 구조 추출 → 페읎지 배치 계획 → Ꞁ벗 표쀀 HTML Ʞ획서륌 생성. 1~N페읎지 옵션 지원 +2. **볎고서 생성**: 폮더 겜로의 자료듀을 RAG 파읎프띌읞윌로 분석하고, Gemini API가 섹션별 윘텐잠 쎈안 → 표지·목찚·간지·별첚읎 포핚된 닀페읎지 HTML 볎고서륌 생성 +3. **AI 펞집**: 생성된 묞서륌 웹 펞집Ʞ에서 확읞 후, "읎 부분을 표로 바꿔쀘" 같은 플드백윌로 전첎 또는 선택 부분을 AI가 수정 +4. **HWP 낎볎낎Ʞ (v5 개선)**: Ʞ졎 pyhwpx 변환 후 HWPX륌 ì—Žì–Ž 컀슀텀 슀타음(제목 계잵·볞묞·표 등)을 죌입하고, 표 ì—Ž 너비륌 원볞 HTML곌 음치시쌜 서식 정확도륌 높임 + +### 프로섞슀 플로우 + +#### 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 +``` + +#### 묞서 유형별 생성 → 펞집 → 출력 + +```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:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 + + A(["📋 RAG 분석 결곌"]):::startEnd + B{"묞서 유형 선택"}:::decision + + C["Ʞ획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude + D["볎고서 생성\n윘텐잠→HTML 조늜\n⚡ Gemini API"]:::aiGemini + E["발표자료 생성\n예정"]:::planned + F["사용자 등록 유형\n확장 가능"]:::planned + + G["Ꞁ벗 표쀀 HTML\nA4·Navy·Noto Sans KR"]:::startEnd + + H{"펞집 방식"}:::decision + I["웹 펞집Ʞ\n수Ʞ 펞집"]:::editStyle + J["AI 펞집\n전첎·부분 수정\n⚡ Claude API"]:::aiClaude + + K{"출력 형식"}:::decision + L["HTML / PDF"]:::exportStyle + M["HWP 변환 (v5 하읎람늬드)\npyhwpx→슀타음죌입→표죌입"]:::newModule + N["PPT 변환\n예정"]:::planned + O(["✅ 최종 산출묌"]):::startEnd + + A --> B + B -->|"Ʞ획서"| C --> G + B -->|"볎고서"| D --> G + B -->|"발표자료"| E -.-> G + B -->|"확장"| F -.-> G + + G --> H + H -->|"수Ʞ"| I --> K + H -->|"AI"| J --> K + K -->|"웹/읞쇄"| L --> O + K -->|"HWP"| M --> O + K -->|"PPT"| N -.-> O +``` + +#### HWP 변환 (v5 하읎람늬드 방식) + +```mermaid +flowchart TD + classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d + classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 + classDef exportStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:1.5px,color:#4a148c + classDef startEnd fill:#1a365d,stroke:#1a365d,color:#fff,stroke-width:2px + + A(["📄 Ꞁ벗 HTML"]):::startEnd + B["① StyleAnalyzer\nHTML 요소 역할 분류"]:::process + C["② pyhwpx Ʞ볞 변환\n표·읎믞지·뚞늬말 처늬"]:::process + D["③ HWP → HWPX 변환"]:::process + E["④ 슀타음 죌입\nhwpx_style_injector\nheader.xml + section.xml"]:::newModule + F["â‘€ 표 ì—Ž 너비 수정\nhwpx_table_injector\npx/mm/% → HWPML"]:::newModule + G([".hwpx 파음"]):::exportStyle + + A --> B --> C --> D --> E --> F --> G +``` + +--- + +## 🔄 v4 → v5 변겜사항 + +| 영역 | v4 | v5 | +|------|------|------| +| HWP 변환 방식 | pyhwpx Ʞ볞 변환만 | 하읎람늬드: pyhwpx → HWPX 후처늬 | +| 슀타음 죌입 | style_analyzer로 분석만 | + **hwpx_style_injector** — header.xml 슀타음 정의, section.xml 맀핑 | +| 표 ì—Ž 너비 | HTML 원볞곌 불음치 | + **hwpx_table_injector** — px/mm/% → HWPML 정밀 변환 | +| 표 너비 파싱 | 없음 | html_to_hwp.py에 `_parse_width()` 유틞 추가 | +| HWP 출력 형식 | .hwp만 | .hwpx 출력 지원 (mimetype 추가) | +| 테슀튞 윔드 | dkdl.py 잔졎 | 삭제 (정늬) | + +--- + +## 🗺 상태 및 로드맵 (Status & Roadmap) + +- **Phase 1**: RAG 파읎프띌읞 — 9닚계 파읎프띌읞, 도메읞 분석, 분량 자동 판당 (🔧 Ʞ볞 구현) +- **Phase 2**: 묞서 생성 — Ʞ획서·볎고서 AI 생성 + Ꞁ벗 표쀀 HTML 양식 (🔧 Ʞ볞 구현) +- **Phase 3**: 출력 — HTML/PDF 닀욎로드, HWP 변환 (🔧 Ʞ볞 구현) +- **Phase 4**: HWP/HWPX/HTML 맀핑 — 슀타음 분석·HWPX 생성·슀타음 죌입·표 죌입 (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 5**: 묞서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) +- **Phase 6**: HWPX 템플늿 ꎀ늬 — 파싱·시맚틱 맀핑·슀타음 추출·표 맀칭·윘텐잠 죌입 (예정) +- **Phase 7**: UI 고도화 — 프론튞 몚듈화, 데몚 몚드, AI 펞집 개선, 도메읞 선택Ʞ (예정) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v5.git +cd geulbeot-v5 + +# 가상환겜 +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_5th/ +├── app.py # Flask 웹 서버 — API 띌우팅 +├── api_config.py # .env 환겜변수 로더 +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출, JSON/HTML 추출 +│ ├── briefing/ # Ʞ획서 처늬 (구조추출 → 배치 → HTML) +│ └── report/ # 볎고서 처늬 (RAG 파읎프띌읞 연동) +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ │ ├── router.py # 분량 판당 (5,000자 Ʞ쀀) +│ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→윔퍌슀→읞덱싱→윘텐잠→HTML +│ ├── style_analyzer.py # HTML 요소 역할 분류 +│ ├── hwpx_generator.py # HWPX 파음 직접 생성 +│ ├── hwp_style_mapping.py # 역할 → HWP 슀타음 맀핑 +│ ├── hwpx_style_injector.py # ★ v5 신규 — HWPX 컀슀텀 슀타음 죌입 +│ ├── hwpx_table_injector.py # ★ v5 신규 — HWPX 표 ì—Ž 너비 정밀 수정 +│ ├── html_to_hwp.py # 볎고서 → HWP 변환 (하읎람늬드 워크플로우) +│ └── html_to_hwp_briefing.py # Ʞ획서 → HWP 변환 +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 펞집Ʞ +│ └── css/editor.css # 펞집Ʞ 슀타음 +├── templates/ +│ ├── index.html # 메읞 UI +│ └── hwp_guide.html # HWP 변환 가읎드 +│ +├── .env / .env.sample # API í‚€ ꎀ늬 +├── .gitignore +├── requirements.txt +├── Procfile # 배포 섀정 (Gunicorn) +└── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 묞서 유형: Ʞ획서·볎고서만 구현, 발표자료·사용자 등록 유형 믞구현 +- 레거시 잔졎: prompts/ 디렉토늬 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 10,782 (+1,002) | +| 프론튞엔드 (JS + CSS + HTML) | 3,859 | +| **합계** | **~14,600** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| v4 | 윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ | +| **v5** | **HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_5th/api_config.py b/03. Code/geulbeot_5th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_5th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_5th/app.py b/03. Code/geulbeot_5th/app.py new file mode 100644 index 0000000..9afcbb3 --- /dev/null +++ b/03. Code/geulbeot_5th/app.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +Flask 띌우팅 + 공통 Ʞ능 +""" + +import os +import io +import tempfile +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response, session, send_file + +# 묞서 유형별 프로섞서 +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor + +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') + +# 프로섞서 읞슀턎슀 +processors = { + 'briefing': BriefingProcessor(), + 'report': ReportProcessor() +} + + +# ============== 메읞 페읎지 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +# ============== 생성 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', '') + + options = { + 'page_option': request.form.get('page_option', '1'), + 'department': request.form.get('department', '쎝ꎄꞰ획싀'), + 'instruction': request.form.get('instruction', '') + } + + result = processors['briefing'].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', '') + } + + 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/') +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 + + +@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 + + +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) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/__init__.py b/03. Code/geulbeot_5th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_5th/converters/html_to_hwp.py b/03. Code/geulbeot_5th/converters/html_to_hwp.py new file mode 100644 index 0000000..d0a9afa --- /dev/null +++ b/03. Code/geulbeot_5th/converters/html_to_hwp.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 컀슀텀 슀타음 죌입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 슀타음 죌입 완료") + + except Exception as e: + print(f" [겜고] 슀타음 죌입 싀팚: {e}") + import traceback + traceback.print_exc() + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_5th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/hwp_style_mapping.py b/03. Code/geulbeot_5th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/hwpx_generator.py b/03. Code/geulbeot_5th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_5th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
                +

                걎섀·토목 잡량 DX 싀묎지칚

                +

                드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

                +

                2024년 1월

                +
                + +

                1. 개요

                +

                볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

                + +

                1.1 배겜

                +

                최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

                + +

                1.1.1 Ʞ술 동향

                +

                1) 드론 잡량의 발전

                +

                드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

                + +

                (1) RTK 드론

                +

                싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

                + +
                  +
                • 고정밀 GPS 수신Ʞ 낎장
                • +
                • 섌티믞터 닚위 정확도
                • +
                + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/hwpx_style_injector.py b/03. Code/geulbeot_5th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/hwpx_table_injector.py b/03. Code/geulbeot_5th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_5th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/__init__.py b/03. Code/geulbeot_5th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_5th/converters/pipeline/router.py b/03. Code/geulbeot_5th/converters/pipeline/router.py new file mode 100644 index 0000000..ef41136 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/router.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 상대 읎믞지 겜로륌 서버 겜로로 변환 + assets/xxx.png → /assets/xxx.png + """ + result = re.sub(r'src="assets/', 'src="/assets/', html_content) + return result + + def replace_src(match): + original_path = match.group(1) + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:')): + return match.group(0) + + # assets/로 시작하멎 절대 겜로로 변환 + if original_path.startswith('assets/'): + filename = original_path.replace('assets/', '') + absolute_path = os.path.join(ASSETS_BASE_PATH, filename) + return f'src="{absolute_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_5th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..a3b57b6 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\테슀튞 쀑(잡량)\잡량_GIS_드론 ꎀ렚 자료듀" + OUTPUT_DIR = r"D:\for python\테슀튞 쀑(잡량)\추출" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_5th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..be4d6d6 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_5th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..e01a87a --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\extract") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_5th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_5th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..9680692 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_5th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..30ef48e --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_5th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_5th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..d3e33d0 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step7_index.py b/03. Code/geulbeot_5th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..3180719 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
                구분번혞제목킀워드
                대목찚{num}{title}
                쀑목찚{num}{title}
                소목찚{num}{title}{kw}
                + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
                + + +
                +

                {html_escape(report_title)}

                +
                +
                + +
                +
                +
                확정 목찚 표 형태 정늬볞
                +
                +
                목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
                + +
                +
                목찚
                + {table_html} +
                +
                + +
                + + + +
                +
                + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_5th/converters/pipeline/step8_content.py b/03. Code/geulbeot_5th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..5f66190 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/pipeline/step9_html.py b/03. Code/geulbeot_5th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..3ee7365 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
                변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
                  '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
                  ') + lines.append(f'
                • {item.number}. {item.title}
                • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
                • {item.number} {item.title}
                • ') + elif item.level == 3: + lines.append(f'
                • {item.number} {item.title}
                • ') + + if current_l1 is not None: + lines.append('
                  ') # 마지막 귞룹 ë‹«êž° + + lines.append('
                ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
                처늬 + cell = cell.replace('
                ', '
                ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
                {cell}
                {cell}
                ') + + # 캡션 추가 + if caption: + html_lines.append(f'
                {caption}
                ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
                변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
                + {caption} +
                {caption_text}
                +
                ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
                  ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                • {content}
                • ') + elif ol_match: + if not in_list: + html_lines.append('
                    ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                  1. {content}
                  2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                    변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                    {text}

                    ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                    {title}

                    ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                    {title}

                    ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                    {h3_match.group(1)}

                    ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                    {h4_match.group(1)}

                    ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                    +
                    {box_cover}
                    +
                    {box_toc}
                    +
                    {box_summary}
                    +
                    {box_content}
                    +
                    + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                    {main_title}

                    +

                    {sub_title}

                    +

                    {cover_date}

                    + {f'

                    {cover_author}

                    ' if cover_author else ''} + {f'

                    {cover_dept}

                    ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                    요앜

                    \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_5th/converters/style_analyzer.py b/03. Code/geulbeot_5th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_5th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                    + +
                    +

                    1 DX 개요와 Ʞ볞 개념

                    +

                    1.1 잡량 DX 프레임

                    +

                    1.1.1 잡량 DX 발전 닚계

                    +

                    1) Digitization 정의

                    +

                    볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                    +

                    (1) 닚계별 정의 및 진화

                    +

                    잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                    +
                    + +
                    + +
                    + +
                    +

                    ① 첫 번짞 항목

                    + + + + +
                    표 1. 데읎터 비교
                    구분낎용
                    항목1섀명1
                    +
                    + +
                    + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/__init__.py b/03. Code/geulbeot_5th/handlers/__init__.py new file mode 100644 index 0000000..7c7e687 --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +handlers 팚킀지 +묞서 유형별 처늬 로직을 분늬하여 ꎀ늬 +""" \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/briefing/__init__.py b/03. Code/geulbeot_5th/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 몚듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/briefing/processor.py b/03. Code/geulbeot_5th/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 로직 +- 1~2페읎지 압축형 볎고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """Ʞ획서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1닚계: 구조 추출 프롬프튞""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5닚계: 배치 계획 프롬프튞""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + + def _get_step2_prompt(self) -> str: + """2닚계: HTML 생성 프롬프튞""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """Ʞ획서 생성""" + try: + if not content.strip(): + return {'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '쎝ꎄꞰ획싀') + additional_prompt = options.get('instruction', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞, 2페읎지는 [첚부]입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부] 형태로 분할합니닀.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝 + if self._content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_5_plan.txt b/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_extract.txt b/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_5th/handlers/briefing/prompts/step2_generate.txt b/03. Code/geulbeot_5th/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                    + +
                    +

                    {{title}}

                    +
                    +
                    +
                    +
                    +
                    {{lead.text}} - 킀워드 강조
                    +
                    + +
                    +
                    {{conclusion.label}}
                    +
                    {{conclusion.text}}
                    +
                    +
                    +
                    - 1 -
                    +
                    + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                    +
                    {{section.title}}
                    +
                      +
                    • {{item.keyword}}: {{item.text}} {{highlight}}
                    • +
                    +
                    +``` + +### table → data-table +```html +
                    +
                    {{section.title}}
                    + + + + + + + + + + + + + +
                    {{col1}}{{col2}}
                    {{text}}{{text}}
                    +
                    +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{item.title}}
                    +

                    {{item.text}} {{highlight}}

                    +
                    +
                    +
                    +``` + +### two-column → two-col +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{item.title}}
                    +

                    {{item.text}} {{highlight}}

                    +
                    +
                    +
                    +``` + +### process → process-container +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{step.number}}
                    +
                    {{step.title}}: {{step.text}}
                    +
                    +
                    ▌
                    + +
                    +
                    +``` + +### qa → qa-grid +```html +
                    +
                    {{section.title}}
                    +
                    +
                    + Q. {{question}}
                    + A. {{answer}} +
                    +
                    +
                    +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                    [첚부] 핎당 낎용에 맞는 제목

                    ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                    [첚부] 핎당 페읎지 낎용에 맞는 제목

                    ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/common.py b/03. Code/geulbeot_5th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/report/__init__.py b/03. Code/geulbeot_5th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/report/processor.py b/03. Code/geulbeot_5th/handlers/report/processor.py new file mode 100644 index 0000000..eeaa2f7 --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/report/processor.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_5th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_5th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_5th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_5th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_5th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_5th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_5th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_5th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_5th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_5th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_5th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_5th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_5th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_5th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_5th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_5th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_5th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_5th/prompts/step1_5_plan.txt b/03. Code/geulbeot_5th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_5th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_5th/prompts/step1_extract.txt b/03. Code/geulbeot_5th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_5th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_5th/prompts/step2_generate.txt b/03. Code/geulbeot_5th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_5th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                    + +
                    +

                    {{title}}

                    +
                    +
                    +
                    +
                    +
                    {{lead.text}} - 킀워드 강조
                    +
                    + +
                    +
                    {{conclusion.label}}
                    +
                    {{conclusion.text}}
                    +
                    +
                    +
                    - 1 -
                    +
                    + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                    +
                    {{section.title}}
                    +
                      +
                    • {{item.keyword}}: {{item.text}} {{highlight}}
                    • +
                    +
                    +``` + +### table → data-table +```html +
                    +
                    {{section.title}}
                    + + + + + + + + + + + + + +
                    {{col1}}{{col2}}
                    {{text}}{{text}}
                    +
                    +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{item.title}}
                    +

                    {{item.text}} {{highlight}}

                    +
                    +
                    +
                    +``` + +### two-column → two-col +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{item.title}}
                    +

                    {{item.text}} {{highlight}}

                    +
                    +
                    +
                    +``` + +### process → process-container +```html +
                    +
                    {{section.title}}
                    +
                    +
                    +
                    {{step.number}}
                    +
                    {{step.title}}: {{step.text}}
                    +
                    +
                    ▌
                    + +
                    +
                    +``` + +### qa → qa-grid +```html +
                    +
                    {{section.title}}
                    +
                    +
                    + Q. {{question}}
                    + A. {{answer}} +
                    +
                    +
                    +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                    [첚부] 핎당 낎용에 맞는 제목

                    ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                    [첚부] 핎당 페읎지 낎용에 맞는 제목

                    ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_5th/requirements.txt b/03. Code/geulbeot_5th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_5th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_5th/static/css/editor.css b/03. Code/geulbeot_5th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_5th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_5th/static/js/editor.js b/03. Code/geulbeot_5th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_5th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                    + + + +
                    + + + + +
                    + + +
                    +
                    + A + +
                    +
                    + A + +
                    + + +
                    + + + +
                    + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                    +
                    +
                    ▩ 표 삜입
                    +
                    + + +
                    +
                    + + +
                    +
                    + + +
                    +
                    + + +
                    +
                    +
                    + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                    헀더낎용
                    '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                    + +
                    귞늌 섀명
                    +
                    `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                    '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                    + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                    + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_5th/templates/hwp_guide.html b/03. Code/geulbeot_5th/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_5th/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
                    +
                    +
                    +
                    + ← 메읞윌로 +

                    HWP 변환 가읎드

                    +
                    +
                    +
                    +
                    + +
                    + +
                    +

                    ⚠ HWP 변환 요구사항

                    +
                      +
                    • • Windows 욎영첎제
                    • +
                    • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
                    • +
                    • • Python 3.8 읎상
                    • +
                    +
                    + + +
                    +

                    1. 필요 띌읎람러늬 섀치

                    +
                    pip install pyhwpx beautifulsoup4
                    +
                    + + +
                    +

                    2. 사용 방법

                    +
                      +
                    1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
                    2. +
                    3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
                    4. +
                    5. 슀크늜튞 낮 겜로륌 수정합니닀.
                    6. +
                    7. 슀크늜튞륌 싀행합니닀.
                    8. +
                    +
                    + + +
                    +
                    +

                    3. HWP 변환 슀크늜튞

                    + +
                    +
                    # -*- coding: utf-8 -*-
                    +"""
                    +Ꞁ벗 Light - HTML → HWP 변환Ʞ
                    +Windows + 한Ꞁ 프로귞랚 필요
                    +"""
                    +
                    +from pyhwpx import Hwp
                    +from bs4 import BeautifulSoup
                    +import os
                    +
                    +
                    +class HtmlToHwpConverter:
                    +    def __init__(self, visible=True):
                    +        self.hwp = Hwp(visible=visible)
                    +        self.colors = {}
                    +    
                    +    def _init_colors(self):
                    +        self.colors = {
                    +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
                    +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
                    +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
                    +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
                    +            'bg-light': self.hwp.RGBColor(247, 250, 252),
                    +            'white': self.hwp.RGBColor(255, 255, 255),
                    +            'black': self.hwp.RGBColor(0, 0, 0),
                    +        }
                    +    
                    +    def _mm(self, mm):
                    +        return self.hwp.MiliToHwpUnit(mm)
                    +    
                    +    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 _align(self, align):
                    +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
                    +        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 _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)
                    +    
                    +    def _create_header(self, left_text, right_text):
                    +        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._font(9, 'medium-gray')
                    +            self.hwp.insert_text(left_text)
                    +            self.hwp.insert_text("\t" * 12)
                    +            self.hwp.insert_text(right_text)
                    +            self.hwp.HAction.Run("CloseEx")
                    +        except Exception as e:
                    +            print(f"뚞늬말 생성 싀팚: {e}")
                    +    
                    +    def _create_footer(self, text):
                    +        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", 1)
                    +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
                    +            self._align('center')
                    +            self._font(8.5, 'medium-gray')
                    +            self.hwp.insert_text(text)
                    +            self.hwp.HAction.Run("CloseEx")
                    +        except Exception as e:
                    +            print(f"ꌬ늬말 생성 싀팚: {e}")
                    +    
                    +    def _convert_lead_box(self, elem):
                    +        content = elem.find("div")
                    +        if not content:
                    +            return
                    +        text = ' '.join(content.get_text().split())
                    +        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_bottom_box(self, elem):
                    +        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)
                    +        
                    +        self.hwp.create_table(1, 2, treat_as_char=True)
                    +        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):
                    +        title = section.find(class_="section-title")
                    +        if title:
                    +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
                    +        
                    +        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')
                    +        self._para()
                    +    
                    +    def _convert_sheet(self, sheet, is_first_page=False):
                    +        if is_first_page:
                    +            header = sheet.find(class_="page-header")
                    +            if header:
                    +                left = header.find(class_="header-left")
                    +                right = header.find(class_="header-right")
                    +                left_text = left.get_text(strip=True) if left else ""
                    +                right_text = right.get_text(strip=True) if right else ""
                    +                if left_text or right_text:
                    +                    self._create_header(left_text, right_text)
                    +            
                    +            footer = sheet.find(class_="page-footer")
                    +            if footer:
                    +                self._create_footer(footer.get_text(strip=True))
                    +        
                    +        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')
                    +            else:
                    +                self._para(title_text, 23, 'primary-navy', True, 'center')
                    +            self._font(10, 'secondary-navy')
                    +            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):
                    +        print(f"[입력] {html_path}")
                    +        
                    +        with open(html_path, 'r', encoding='utf-8') as f:
                    +            soup = BeautifulSoup(f.read(), 'html.parser')
                    +        
                    +        self.hwp.FileNew()
                    +        self._init_colors()
                    +        
                    +        # 페읎지 섀정
                    +        try:
                    +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
                    +            sec = self.hwp.HParameterSet.HSecDef
                    +            sec.PageDef.LeftMargin = self._mm(20)
                    +            sec.PageDef.RightMargin = self._mm(20)
                    +            sec.PageDef.TopMargin = self._mm(20)
                    +            sec.PageDef.BottomMargin = self._mm(20)
                    +            sec.PageDef.HeaderLen = self._mm(10)
                    +            sec.PageDef.FooterLen = self._mm(10)
                    +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
                    +        except Exception as e:
                    +            print(f"페읎지 섀정 싀팚: {e}")
                    +        
                    +        sheets = soup.find_all(class_="sheet")
                    +        total = len(sheets)
                    +        print(f"[변환] 쎝 {total} 페읎지")
                    +        
                    +        for i, sheet in enumerate(sheets, 1):
                    +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
                    +            self._convert_sheet(sheet, is_first_page=(i == 1))
                    +            if i < total:
                    +                self.hwp.HAction.Run("BreakPage")
                    +        
                    +        self.hwp.SaveAs(output_path)
                    +        print(f"✅ 저장 완료: {output_path}")
                    +    
                    +    def close(self):
                    +        try:
                    +            self.hwp.Quit()
                    +        except:
                    +            pass
                    +
                    +
                    +def main():
                    +    # ====================================
                    +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
                    +    # ====================================
                    +    html_path = r"C:\Users\User\Downloads\report.html"
                    +    output_path = r"C:\Users\User\Downloads\report.hwp"
                    +    
                    +    print("=" * 50)
                    +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
                    +    print("=" * 50)
                    +    
                    +    try:
                    +        converter = HtmlToHwpConverter(visible=True)
                    +        converter.convert(html_path, output_path)
                    +        print("\n✅ 변환 완료!")
                    +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
                    +        converter.close()
                    +    except FileNotFoundError:
                    +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
                    +    except Exception as e:
                    +        print(f"\n[에러] {e}")
                    +        import traceback
                    +        traceback.print_exc()
                    +
                    +
                    +if __name__ == "__main__":
                    +    main()
                    +
                    + + +
                    +

                    4. 겜로 수정

                    +

                    슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

                    +
                    html_path = r"C:\닀욎로드겜로\report.html"
                    +output_path = r"C:\저장겜로\report.hwp"
                    +
                    +
                    + + + + diff --git a/03. Code/geulbeot_5th/templates/index.html b/03. Code/geulbeot_5th/templates/index.html new file mode 100644 index 0000000..e496d71 --- /dev/null +++ b/03. Code/geulbeot_5th/templates/index.html @@ -0,0 +1,2356 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + + + +
                    + + +
                    + + + +
                    + + + +
                    + + + + + +
                    + + +
                    + + + + +
                    + +
                    +
                    +
                    + +
                    +
                    📄
                    +
                    HTML을 입력하고 생성하섞요
                    +
                    좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                    +
                    +
                    +
                    +
                    + + + +
                    + + +
                    +
                    + 묞서 섀정 +
                    + +
                    + +
                    +
                    묞서 유형
                    +
                    + +
                    + + 📋 Ʞ획서 + + +
                    +
                    +
                    + +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    + +
                    [첚부]
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    Ʞ획서 (볎고자료)
                    +
                    임원볎고용 정형화된 1~2페읎지 묞서
                    +
                    +
                    📄 1p 볞묞만 / 1p+1p첚부 / 1p+np첚부
                    +
                    🎚 Navy 양식 (A4 읞쇄 최적화)
                    +
                    ✍ 개조식 자동 변환
                    +
                    +
                    +
                    + + +
                    + + 📄 볎고서 + +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    +
                    볎고서 (HWP)
                    +
                    RAG êž°ë°˜ 장묞 볎고서 → HWPX 출력
                    +
                    +
                    🏷 AI 슀타음 자동 태깅
                    +
                    📝 대제목/쀑제목/소제목/볞묞
                    +
                    ✹ 한Ꞁ에서 슀타음 음ꎄ 변겜
                    +
                    +
                    +
                    + + +
                    + + 📊 발표자료 + 쀀비쀑 + +
                    +
                    +
                    +
                    제목
                    +
                    +
                    +
                    +
                    +
                    볞묞
                    +
                    +
                    +
                    +
                    +
                    +
                    ê²°ë¡ 
                    +
                    +
                    +
                    +
                    발표자료 (PPT)
                    +
                    프레젠테읎션 형식 슬띌읎드
                    +
                    +
                    📊 슬띌읎드 자동 구성
                    +
                    🎯 핵심 낎용 추출
                    +
                    🖌 도식화 자동 생성
                    +
                    +
                    +
                    +
                    + + + +
                    + + +
                    + +
                    +
                    페읎지 구성
                    +
                    +
                    + + +
                    +
                    + + +
                    +
                    + + +
                    +
                    +
                    + + +
                    +
                    요청사항
                    + +
                    +
                    + + + + + + + + + + + +
                    +
                    +
                    + + +
                    +
                    + + 쀀비됚 +
                    +
                    Ꞁ벗 Light v2.0
                    +
                    + + + + + + + + + + + + +
                    + +
                    🀖 AI로 수정하Ʞ
                    +
                    선택된 텍슀튞:
                    +
                    + + +
                    + + + \ No newline at end of file diff --git a/03. Code/geulbeot_6th/.env.sample b/03. Code/geulbeot_6th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_6th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_6th/.gitignore b/03. Code/geulbeot_6th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_6th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_6th/Procfile b/03. Code/geulbeot_6th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_6th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_6th/README.md b/03. Code/geulbeot_6th/README.md new file mode 100644 index 0000000..d221f83 --- /dev/null +++ b/03. Code/geulbeot_6th/README.md @@ -0,0 +1,359 @@ +# Ꞁ벗 (Geulbeot) v6.0 + +**HWPX 템플늿 분석·저장·ꎀ늬** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +v6에서는 HWPX 템플늿 ꎀ늬 Ʞ능을 추가했습니닀. +HWPX 파음을 업로드하멎 XML을 파싱하여 폰튞·색상·여백·표 구조·테두늬 등을 자동 분석하고, +재사용 가능한 템플늿윌로 저장합니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +RAG 파읎프띌읞 (9닚계) ─── 공통 처늬 + │ + â–Œ +묞서 유형 선택 + ├─ Ʞ획서 (Ʞ볞) + ├─ 볎고서 (Ʞ볞) + ├─ 발표자료 (Ʞ볞) + └─ 사용자 등록 (확장 가능) + │ + â–Œ +Ꞁ벗 표쀀 HTML 생성 ◀── 템플늿 슀타음 ì°žì¡° (v6 신규) + │ + â–Œ +웹 펞집Ʞ (수Ʞ 펞집 / 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 파읎프띌읞 (파음 변환 → 추출 → 도메읞 분석 → 청킹 → 임베딩 → 윔퍌슀 → 읞덱싱 → 윘텐잠 생성 → HTML 조늜) + - 묞서 유형별 생성: Ʞ획서 (Claude 3닚계), 볎고서 (Gemini 2닚계) + - AI 펞집: 전첎 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - HWPX 템플늿 분석·저장·ꎀ늬 (v6 신규) + - HWP 변환: 하읎람늬드 방식 — pyhwpx Ʞ볞 생성 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 람띌우저에서 생성된 묞서 직접 수정 + - 페읎지 넘김·듀여쓰Ʞ·정렬 등 서식 도구 + - HTML / PDF / HWP 닀욎로드 + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 — ꞎ 묞서는 전첎 파읎프띌읞, 짧은 묞서는 축앜 파읎프띌읞 +- **HWP 변환 (하읎람늬드 방식)**: HTML 분석 → pyhwpx 변환 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + +### 4. 템플늿 ꎀ늬 (v6 신규) + +- **HWPX 파싱**: 업로드된 HWPX륌 압축 핎제하여 header.xml + section*.xml 구조 분석 +- **자동 추출 항목**: + - 폰튾 정볎 (읎늄·크Ʞ·굵Ʞ·색상) + - 묞닚 슀타음 (정렬·쀄간격·듀여쓰Ʞ·번혞 첎계) + - 표 구조 (ì—Ž 너비·행 수·셀 병합·테두늬 슀타음·선 종류) + - 배겜 (색상·읎믞지 채우Ʞ) + - 테두늬 (ARGB 8자늬 색상 정규화, NONE 제왞) + - 페읎지 섀정 (여백·용지 크Ʞ) +- **CSS 자동 생성**: 분석된 슀타음을 CSS로 변환하여 HTML 생성 시 ì°žì¡° 가능 +- **저장소**: `templates_store/` 디렉토늬에 메타데읎터(meta.json) + 원볞 파음 + 분석 결곌 저장 + +### 5. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: 텍슀튞 또는 파음을 입력하멎, RAG 분석 후 Claude API가 구조 추출 → 페읎지 배치 계획 → Ꞁ벗 표쀀 HTML Ʞ획서륌 생성. 1~N페읎지 옵션 지원 +2. **볎고서 생성**: 폮더 겜로의 자료듀을 RAG 파읎프띌읞윌로 분석하고, Gemini API가 섹션별 윘텐잠 쎈안 → 표지·목찚·간지·별첚읎 포핚된 닀페읎지 HTML 볎고서륌 생성 +3. **AI 펞집**: 생성된 묞서륌 웹 펞집Ʞ에서 확읞 후, 플드백윌로 전첎 또는 선택 부분을 AI가 수정 +4. **템플늿 등록 (v6 신규)**: HWPX 파음을 업로드하멎 XML을 파싱하여 폰튞·표·테두늬·색상 등을 자동 분석하고, 재사용 가능한 템플늿윌로 저장. 등록된 템플늿은 조회·삭제 가능 +5. **HWP 낎볎낎Ʞ**: pyhwpx 변환 후 HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 수정 + +### 프로섞슀 플로우 + +#### 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 +``` + +#### 묞서 유형별 생성 → 펞집 → 출력 + +```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 + + A(["📋 RAG 분석 결곌"]):::startEnd + B{"묞서 유형 선택"}:::decision + + C["Ʞ획서 생성\n구조추출→배치→HTML\n⚡ Claude API"]:::aiClaude + D["볎고서 생성\n윘텐잠→HTML 조늜\n⚡ Gemini API"]:::aiGemini + E["발표자료 생성\n예정"]:::planned + F["사용자 등록 유형\n확장 가능"]:::planned + + T["📋 템플늿 슀타음 ì°žì¡°\ntemplates_store/\n(v6 신규)"]:::newModule + + G["Ꞁ벗 표쀀 HTML\nA4·Navy·Noto Sans KR"]:::startEnd + + H{"펞집 방식"}:::decision + I["웹 펞집Ʞ\n수Ʞ 펞집"]:::editStyle + J["AI 펞집\n전첎·부분 수정\n⚡ Claude API"]:::aiClaude + + K{"출력 형식"}:::decision + L["HTML / PDF"]:::exportStyle + M["HWP 변환 (하읎람늬드)\npyhwpx→슀타음죌입→표죌입"]:::exportStyle + N["PPT 변환\n예정"]:::planned + O(["✅ 최종 산출묌"]):::startEnd + + A --> B + B -->|"Ʞ획서"| C --> G + B -->|"볎고서"| D --> G + B -->|"발표자료"| E -.-> G + B -->|"확장"| F -.-> G + + T -.->|"슀타음 ì°žì¡°"| G + + G --> H + H -->|"수Ʞ"| I --> K + H -->|"AI"| J --> K + K -->|"웹/읞쇄"| L --> O + K -->|"HWP"| M --> O + K -->|"PPT"| N -.-> O +``` + +#### 템플늿 분석 (v6 신규) + +```mermaid +flowchart LR + classDef process fill:#e8f4fd,stroke:#1a365d,stroke-width:1.5px,color:#1a365d + classDef newModule fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 + 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["압축 핎제\nheader.xml\nsection*.xml"]:::process + C["XML 파싱\n폰튞·표·테두늬·색상\n페읎지 섀정"]:::newModule + D["CSS 자동 생성\n슀타음 요앜"]:::newModule + E[("📋 templates_store/\nmeta.json\n+ 분석 결곌")]:::dataStore + + A --> B --> C --> D --> E +``` + +--- + +## 🔄 v5 → v6 변겜사항 + +| 영역 | v5 | v6 | +|------|------|------| +| 템플늿 ꎀ늬 | 없음 | **handlers/template/ 팚킀지 신규** | +| HWPX 분석 | 없음 | header.xml·section.xml 파싱 (폰튞·표·테두늬·배겜·페읎지) | +| CSS 자동 생성 | 없음 | 분석된 슀타음 → CSS 변환 | +| 신규 API | — | `GET /templates` · `POST /analyze-template` · `DELETE /delete-template/` | +| 저장소 | — | `templates_store/` (meta.json + 원볞 + 분석 결곌) | +| AI 프롬프튞 | — | `analyze_template.txt` — 구조 분석 볎조 | + +--- + +## 🗺 상태 및 로드맵 (Status & Roadmap) + +- **Phase 1**: RAG 파읎프띌읞 — 9닚계 파읎프띌읞, 도메읞 분석, 분량 자동 판당 (🔧 Ʞ볞 구현) +- **Phase 2**: 묞서 생성 — Ʞ획서·볎고서 AI 생성 + Ꞁ벗 표쀀 HTML 양식 (🔧 Ʞ볞 구현) +- **Phase 3**: 출력 — HTML/PDF 닀욎로드, HWP 변환 (🔧 Ʞ볞 구현) +- **Phase 4**: HWP/HWPX/HTML 맀핑 — 슀타음 분석·HWPX 생성·슀타음 죌입·표 죌입 (🔧 Ʞ볞 구현) +- **Phase 5**: 묞서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) +- **Phase 6**: HWPX 템플늿 ꎀ늬 — 파싱·슀타음 추출·CSS 생성·저장·조회·삭제 (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 7**: UI 고도화 — 프론튞 몚듈화, 데몚 몚드, AI 펞집 개선, 도메읞 선택Ʞ (예정) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v6.git +cd geulbeot-v6 + +# 가상환겜 +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_6th/ +├── app.py # Flask 웹 서버 — API 띌우팅 +├── api_config.py # .env 환겜변수 로더 +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출, JSON/HTML 추출 +│ ├── briefing/ # Ʞ획서 처늬 (구조추출 → 배치 → HTML) +│ ├── report/ # 볎고서 처늬 (RAG 파읎프띌읞 연동) +│ └── template/ # ★ v6 신규 — 템플늿 ꎀ늬 +│ ├── processor.py # HWPX 파싱·분석·CSS 생성·CRUD +│ └── prompts/ +│ └── analyze_template.txt # AI 구조 분석 프롬프튞 +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ │ ├── router.py # 분량 판당 (5,000자 Ʞ쀀) +│ │ └── step1 ~ step9 # 변환→추출→분석→청킹→임베딩→윔퍌슀→읞덱싱→윘텐잠→HTML +│ ├── style_analyzer.py # HTML 요소 역할 분류 +│ ├── hwpx_generator.py # HWPX 파음 직접 생성 +│ ├── hwp_style_mapping.py # 역할 → HWP 슀타음 맀핑 +│ ├── hwpx_style_injector.py # HWPX 컀슀텀 슀타음 죌입 +│ ├── hwpx_table_injector.py # HWPX 표 ì—Ž 너비 정밀 수정 +│ ├── html_to_hwp.py # 볎고서 → HWP 변환 (하읎람늬드 워크플로우) +│ └── html_to_hwp_briefing.py # Ʞ획서 → HWP 변환 +│ +├── templates_store/ # ★ v6 신규 — 등록된 템플늿 저장소 +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 펞집Ʞ +│ └── css/editor.css # 펞집Ʞ 슀타음 +├── templates/ +│ ├── index.html # 메읞 UI +│ └── hwp_guide.html # HWP 변환 가읎드 +│ +├── .env / .env.sample # API í‚€ ꎀ늬 +├── .gitignore +├── requirements.txt +├── Procfile # 배포 섀정 (Gunicorn) +└── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 묞서 유형: Ʞ획서·볎고서만 구현, 발표자료·사용자 등록 유형 믞구현 +- 템플늿 → 묞서 생성 연동: 아직 믞연결 (분석·저장만 가능, 생성 시 자동 적용은 예정) +- 레거시 잔졎: prompts/ 디렉토늬 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 11,406 (+624) | +| 프론튞엔드 (JS + CSS + HTML) | 3,859 | +| **합계** | **~15,300** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| v4 | 윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ | +| v5 | HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환 | +| **v6** | **HWPX 템플늿 분석·저장·ꎀ늬** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_6th/api_config.py b/03. Code/geulbeot_6th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_6th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_6th/app.py b/03. Code/geulbeot_6th/app.py new file mode 100644 index 0000000..178a54d --- /dev/null +++ b/03. Code/geulbeot_6th/app.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +Flask 띌우팅 + 공통 Ʞ능 +""" + +import os +import io +import tempfile +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response, session, send_file +from handlers.template import TemplateProcessor + + +# 묞서 유형별 프로섞서 +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor + +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') + +# processors 딕셔너늬에 추가 +processors = { + 'briefing': BriefingProcessor(), + 'report': ReportProcessor(), + 'template': TemplateProcessor() # 추가 +} + + + +# ============== 메읞 페읎지 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +# ============== 생성 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', '') + + options = { + 'page_option': request.form.get('page_option', '1'), + 'department': request.form.get('department', '쎝ꎄꞰ획싀'), + 'instruction': request.form.get('instruction', '') + } + + result = processors['briefing'].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/') +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 + + +@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: + result = processors['template'].get_list() + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/analyze-template', methods=['POST']) +def analyze_template(): + """템플늿 분석 및 저장""" + 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 + + result = processors['template'].analyze(file, name) + + if 'error' in result: + return jsonify(result), 400 + + return jsonify(result) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +@app.route('/delete-template/', methods=['DELETE']) +def delete_template(template_id): + """템플늿 삭제""" + try: + result = processors['template'].delete(template_id) + + if 'error' in result: + return jsonify(result), 400 + + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/__init__.py b/03. Code/geulbeot_6th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_6th/converters/html_to_hwp.py b/03. Code/geulbeot_6th/converters/html_to_hwp.py new file mode 100644 index 0000000..d0a9afa --- /dev/null +++ b/03. Code/geulbeot_6th/converters/html_to_hwp.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 컀슀텀 슀타음 죌입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 슀타음 죌입 완료") + + except Exception as e: + print(f" [겜고] 슀타음 죌입 싀팚: {e}") + import traceback + traceback.print_exc() + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_6th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/hwp_style_mapping.py b/03. Code/geulbeot_6th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/hwpx_generator.py b/03. Code/geulbeot_6th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_6th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
                    +

                    걎섀·토목 잡량 DX 싀묎지칚

                    +

                    드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

                    +

                    2024년 1월

                    +
                    + +

                    1. 개요

                    +

                    볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

                    + +

                    1.1 배겜

                    +

                    최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

                    + +

                    1.1.1 Ʞ술 동향

                    +

                    1) 드론 잡량의 발전

                    +

                    드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

                    + +

                    (1) RTK 드론

                    +

                    싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

                    + +
                      +
                    • 고정밀 GPS 수신Ʞ 낎장
                    • +
                    • 섌티믞터 닚위 정확도
                    • +
                    + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/hwpx_style_injector.py b/03. Code/geulbeot_6th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/hwpx_table_injector.py b/03. Code/geulbeot_6th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_6th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/__init__.py b/03. Code/geulbeot_6th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_6th/converters/pipeline/router.py b/03. Code/geulbeot_6th/converters/pipeline/router.py new file mode 100644 index 0000000..9a396cc --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/router.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 읎믞지 겜로륌 서버 겜로로 변환 + - assets/xxx.png → /assets/xxx.png (Flask 서빙용) + - 절대 겜로나 URL은 귞대로 유지 + """ + + def replace_src(match): + original_path = match.group(1) + + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')): + return match.group(0) + + # assets/로 시작하멎 /assets/로 변환 (Flask 서빙) + if original_path.startswith('assets/'): + return f'src="/{original_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + +def inject_template_css(html_content: str, template_css: str) -> str: + """ + HTML에 템플늿 CSS 죌입 + - 태귞 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태귞 뒀에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없윌멎 ë§š 앞에 추가 + else: + return f'\n{html_content}' + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + # ⭐ 템플늿 CSS 죌입 + template_css = options.get('template_css') + if template_css and result.get('success') and result.get('html'): + result['html'] = inject_template_css(result['html'], template_css) + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_6th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..a3b57b6 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\테슀튞 쀑(잡량)\잡량_GIS_드론 ꎀ렚 자료듀" + OUTPUT_DIR = r"D:\for python\테슀튞 쀑(잡량)\추출" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_6th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..be4d6d6 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_6th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..e01a87a --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\extract") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_6th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_6th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..9680692 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_6th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..30ef48e --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_6th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_6th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..d3e33d0 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step7_index.py b/03. Code/geulbeot_6th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..3180719 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
                    구분번혞제목킀워드
                    대목찚{num}{title}
                    쀑목찚{num}{title}
                    소목찚{num}{title}{kw}
                    + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
                    + + +
                    +

                    {html_escape(report_title)}

                    +
                    +
                    + +
                    +
                    +
                    확정 목찚 표 형태 정늬볞
                    +
                    +
                    목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
                    + +
                    +
                    목찚
                    + {table_html} +
                    +
                    + +
                    + + + +
                    +
                    + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_6th/converters/pipeline/step8_content.py b/03. Code/geulbeot_6th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..5f66190 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/pipeline/step9_html.py b/03. Code/geulbeot_6th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..3ee7365 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
                    변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
                      '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
                      ') + lines.append(f'
                    • {item.number}. {item.title}
                    • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
                    • {item.number} {item.title}
                    • ') + elif item.level == 3: + lines.append(f'
                    • {item.number} {item.title}
                    • ') + + if current_l1 is not None: + lines.append('
                      ') # 마지막 귞룹 ë‹«êž° + + lines.append('
                    ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
                    처늬 + cell = cell.replace('
                    ', '
                    ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
                    {cell}
                    {cell}
                    ') + + # 캡션 추가 + if caption: + html_lines.append(f'
                    {caption}
                    ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
                    변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
                    + {caption} +
                    {caption_text}
                    +
                    ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
                      ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                    • {content}
                    • ') + elif ol_match: + if not in_list: + html_lines.append('
                        ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                      1. {content}
                      2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                        변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                        {text}

                        ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                        {title}

                        ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                        {title}

                        ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                        {h3_match.group(1)}

                        ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                        {h4_match.group(1)}

                        ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                        +
                        {box_cover}
                        +
                        {box_toc}
                        +
                        {box_summary}
                        +
                        {box_content}
                        +
                        + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                        {main_title}

                        +

                        {sub_title}

                        +

                        {cover_date}

                        + {f'

                        {cover_author}

                        ' if cover_author else ''} + {f'

                        {cover_dept}

                        ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                        요앜

                        \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_6th/converters/style_analyzer.py b/03. Code/geulbeot_6th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_6th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                        + +
                        +

                        1 DX 개요와 Ʞ볞 개념

                        +

                        1.1 잡량 DX 프레임

                        +

                        1.1.1 잡량 DX 발전 닚계

                        +

                        1) Digitization 정의

                        +

                        볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                        +

                        (1) 닚계별 정의 및 진화

                        +

                        잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                        +
                        + +
                        + +
                        + +
                        +

                        ① 첫 번짞 항목

                        + + + + +
                        표 1. 데읎터 비교
                        구분낎용
                        항목1섀명1
                        +
                        + +
                        + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/__init__.py b/03. Code/geulbeot_6th/handlers/__init__.py new file mode 100644 index 0000000..7c7e687 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +handlers 팚킀지 +묞서 유형별 처늬 로직을 분늬하여 ꎀ늬 +""" \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/briefing/__init__.py b/03. Code/geulbeot_6th/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 몚듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/briefing/processor.py b/03. Code/geulbeot_6th/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 로직 +- 1~2페읎지 압축형 볎고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """Ʞ획서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1닚계: 구조 추출 프롬프튞""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5닚계: 배치 계획 프롬프튞""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + + def _get_step2_prompt(self) -> str: + """2닚계: HTML 생성 프롬프튞""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """Ʞ획서 생성""" + try: + if not content.strip(): + return {'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '쎝ꎄꞰ획싀') + additional_prompt = options.get('instruction', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞, 2페읎지는 [첚부]입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부] 형태로 분할합니닀.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝 + if self._content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_5_plan.txt b/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_extract.txt b/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_6th/handlers/briefing/prompts/step2_generate.txt b/03. Code/geulbeot_6th/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                        + +
                        +

                        {{title}}

                        +
                        +
                        +
                        +
                        +
                        {{lead.text}} - 킀워드 강조
                        +
                        + +
                        +
                        {{conclusion.label}}
                        +
                        {{conclusion.text}}
                        +
                        +
                        +
                        - 1 -
                        +
                        + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                        +
                        {{section.title}}
                        +
                          +
                        • {{item.keyword}}: {{item.text}} {{highlight}}
                        • +
                        +
                        +``` + +### table → data-table +```html +
                        +
                        {{section.title}}
                        + + + + + + + + + + + + + +
                        {{col1}}{{col2}}
                        {{text}}{{text}}
                        +
                        +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{item.title}}
                        +

                        {{item.text}} {{highlight}}

                        +
                        +
                        +
                        +``` + +### two-column → two-col +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{item.title}}
                        +

                        {{item.text}} {{highlight}}

                        +
                        +
                        +
                        +``` + +### process → process-container +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{step.number}}
                        +
                        {{step.title}}: {{step.text}}
                        +
                        +
                        ▌
                        + +
                        +
                        +``` + +### qa → qa-grid +```html +
                        +
                        {{section.title}}
                        +
                        +
                        + Q. {{question}}
                        + A. {{answer}} +
                        +
                        +
                        +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                        [첚부] 핎당 낎용에 맞는 제목

                        ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                        [첚부] 핎당 페읎지 낎용에 맞는 제목

                        ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/common.py b/03. Code/geulbeot_6th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/report/__init__.py b/03. Code/geulbeot_6th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/report/processor.py b/03. Code/geulbeot_6th/handlers/report/processor.py new file mode 100644 index 0000000..19def30 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/report/processor.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # ⭐ 템플늿 슀타음 로드 + template_id = options.get('template_id') + if template_id: + from handlers.template import TemplateProcessor + template_processor = TemplateProcessor() + style = template_processor.get_style(template_id) + if style and style.get('css'): + options['template_css'] = style['css'] + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_6th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/template/__init__.py b/03. Code/geulbeot_6th/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/template/processor.py b/03. Code/geulbeot_6th/handlers/template/processor.py new file mode 100644 index 0000000..f8cb6d1 --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/template/processor.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +""" +템플늿 처늬 로직 (v3 - 싀제 구조 정확 분석) +- HWPX 파음의 싀제 표 구조, 읎믞지 배겜, 테두늬 정확히 추출 +- ARGB 8자늬 색상 정규화 +- NONE 테두늬 색상 제왞 +""" + +import os +import json +import uuid +import shutil +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from collections import Counter, defaultdict + +# 템플늿 저장 겜로 +TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store' +TEMPLATES_DIR.mkdir(exist_ok=True) + +# HWP 명섞서 êž°ë°˜ 상수 +LINE_TYPES = { + 'NONE': '없음', + 'SOLID': '싀선', + 'DASH': 'ꞎ 점선', + 'DOT': '점선', + 'DASH_DOT': '-.-.-.-.', + 'DASH_DOT_DOT': '-..-..-..', + 'DOUBLE_SLIM': '2쀑선', + 'SLIM_THICK': '가는선+굵은선', + 'THICK_SLIM': '굵은선+가는선', + 'SLIM_THICK_SLIM': '가는선+굵은선+가는선', + 'WAVE': '묌결', + 'DOUBLE_WAVE': '묌결 2쀑선', +} + + +class TemplateProcessor: + """템플늿 처늬 큎래슀 (v3)""" + + NS = { + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'hs': 'http://www.hancom.co.kr/hwpml/2011/section', + } + + def __init__(self): + self.templates_dir = TEMPLATES_DIR + self.templates_dir.mkdir(exist_ok=True) + + # ========================================================================= + # 공개 API + # ========================================================================= + + def get_list(self) -> Dict[str, Any]: + """저장된 템플늿 목록""" + templates = [] + for item in self.templates_dir.iterdir(): + if item.is_dir(): + meta_path = item / 'meta.json' + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + templates.append({ + 'id': meta.get('id', item.name), + 'name': meta.get('name', item.name), + 'features': meta.get('features', []), + 'created_at': meta.get('created_at', '') + }) + except: + pass + templates.sort(key=lambda x: x.get('created_at', ''), reverse=True) + return {'templates': templates} + + def analyze(self, file, name: str) -> Dict[str, Any]: + """템플늿 파음 분석 및 저장""" + filename = file.filename + ext = Path(filename).suffix.lower() + + if ext not in ['.hwpx', '.hwp', '.pdf']: + return {'error': f'지원하지 않는 파음 형식: {ext}'} + + template_id = str(uuid.uuid4())[:8] + template_dir = self.templates_dir / template_id + template_dir.mkdir(exist_ok=True) + + try: + original_path = template_dir / f'original{ext}' + file.save(str(original_path)) + + if ext == '.hwpx': + style_data = self._analyze_hwpx(original_path, template_dir) + else: + style_data = self._analyze_fallback(ext) + + if 'error' in style_data: + shutil.rmtree(template_dir) + return style_data + + # 특징 추출 + features = self._extract_features(style_data) + + # 메타 저장 + meta = { + 'id': template_id, + 'name': name, + 'original_file': filename, + 'file_type': ext, + 'features': features, + 'created_at': datetime.now().isoformat() + } + (template_dir / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # 슀타음 저장 + (template_dir / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # CSS 저장 + css = style_data.get('css', '') + css_dir = template_dir / 'css' + css_dir.mkdir(exist_ok=True) + (css_dir / 'template.css').write_text(css, encoding='utf-8') + + return { + 'success': True, + 'template': { + 'id': template_id, + 'name': name, + 'features': features, + 'created_at': meta['created_at'] + } + } + except Exception as e: + if template_dir.exists(): + shutil.rmtree(template_dir) + raise e + + def delete(self, template_id: str) -> Dict[str, Any]: + """템플늿 삭제""" + template_dir = self.templates_dir / template_id + if not template_dir.exists(): + return {'error': '템플늿을 찟을 수 없습니닀'} + shutil.rmtree(template_dir) + return {'success': True, 'deleted': template_id} + + def get_style(self, template_id: str) -> Optional[Dict[str, Any]]: + """템플늿 슀타음 반환""" + style_path = self.templates_dir / template_id / 'style.json' + if not style_path.exists(): + return None + return json.loads(style_path.read_text(encoding='utf-8')) + + # ========================================================================= + # HWPX 분석 (핵심) + # ========================================================================= + + def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]: + """HWPX 분석 - 싀제 구조 정확히 추출""" + extract_dir = template_dir / 'extracted' + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + zf.extractall(extract_dir) + + result = { + 'version': 'v3', + 'fonts': {}, + 'colors': { + 'background': [], + 'border': [], + 'text': [] + }, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': {}, + 'css': '' + } + + # 1. header.xml 분석 + header_path = extract_dir / 'Contents' / 'header.xml' + if header_path.exists(): + self._parse_header(header_path, result) + + # 2. section0.xml 분석 + section_path = extract_dir / 'Contents' / 'section0.xml' + if section_path.exists(): + self._parse_section(section_path, result) + + # 3. 슀타음 요앜 생성 + result['style_summary'] = self._create_style_summary(result) + + # 4. CSS 생성 + result['css'] = self._generate_css(result) + + return result + + finally: + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + def _parse_header(self, header_path: Path, result: Dict): + """header.xml 파싱 - 폰튾, borderFill""" + tree = ET.parse(header_path) + root = tree.getroot() + + # 폰튾 + for fontface in root.findall('.//hh:fontface', self.NS): + if fontface.get('lang') == 'HANGUL': + for font in fontface.findall('hh:font', self.NS): + result['fonts'][font.get('id')] = font.get('face') + + # borderFill + for bf in root.findall('.//hh:borderFill', self.NS): + bf_id = bf.get('id') + bf_data = self._parse_border_fill(bf, result) + result['border_fills'][bf_id] = bf_data + + def _parse_border_fill(self, bf, result: Dict) -> Dict: + """개별 borderFill 파싱""" + bf_id = bf.get('id') + data = { + 'id': bf_id, + 'type': 'empty', + 'background': None, + 'image': None, + 'borders': {} + } + + # 읎믞지 배겜 + img_brush = bf.find('.//hc:imgBrush', self.NS) + if img_brush is not None: + img = img_brush.find('hc:img', self.NS) + if img is not None: + data['type'] = 'image' + data['image'] = { + 'ref': img.get('binaryItemIDRef'), + 'effect': img.get('effect') + } + + # 닚색 배겜 + win_brush = bf.find('.//hc:winBrush', self.NS) + if win_brush is not None: + face_color = self._normalize_color(win_brush.get('faceColor')) + if face_color and face_color != 'none': + if data['type'] == 'empty': + data['type'] = 'solid' + data['background'] = face_color + if face_color not in result['colors']['background']: + result['colors']['background'].append(face_color) + + # 4방향 테두늬 + for side in ['top', 'bottom', 'left', 'right']: + border = bf.find(f'hh:{side}Border', self.NS) + if border is not None: + border_type = border.get('type', 'NONE') + width = border.get('width', '0.1 mm') + color = self._normalize_color(border.get('color', '#000000')) + + data['borders'][side] = { + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'width_mm': self._parse_width(width), + 'color': color + } + + # 볎읎는 테두늬만 색상 수집 + if border_type != 'NONE': + if data['type'] == 'empty': + data['type'] = 'border_only' + if color and color not in result['colors']['border']: + result['colors']['border'].append(color) + + # 특수 테두늬 수집 + if border_type not in ['SOLID', 'NONE']: + result['special_borders'].append({ + 'bf_id': bf_id, + 'side': side, + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'color': color + }) + + return data + + def _parse_section(self, section_path: Path, result: Dict): + """section0.xml 파싱 - 표 구조""" + tree = ET.parse(section_path) + root = tree.getroot() + + border_fills = result['border_fills'] + + for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'): + table_data = { + 'rows': int(tbl.get('rowCnt', 0)), + 'cols': int(tbl.get('colCnt', 0)), + 'cells': [], + 'structure': { + 'header_row_style': None, + 'first_col_style': None, + 'body_style': None, + 'has_image_cells': False + } + } + + # 셀별 분석 + cell_by_position = {} + for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'): + cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr') + if cell_addr is None: + continue + + row = int(cell_addr.get('rowAddr', 0)) + col = int(cell_addr.get('colAddr', 0)) + bf_id = tc.get('borderFillIDRef') + bf_info = border_fills.get(bf_id, {}) + + # 텍슀튞 추출 + text = '' + for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'): + if t.text: + text += t.text + + cell_data = { + 'row': row, + 'col': col, + 'bf_id': bf_id, + 'bf_type': bf_info.get('type'), + 'background': bf_info.get('background'), + 'image': bf_info.get('image'), + 'text_preview': text[:30] if text else '' + } + + table_data['cells'].append(cell_data) + cell_by_position[(row, col)] = cell_data + + if bf_info.get('type') == 'image': + table_data['structure']['has_image_cells'] = True + + # 구조 분석: 헀더행, 첫엎 슀타음 + self._analyze_table_structure(table_data, cell_by_position, border_fills) + + result['tables'].append(table_data) + + def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict): + """표 구조 분석 - 헀더행/첫엎 슀타음 파악""" + rows = table_data['rows'] + cols = table_data['cols'] + + if rows == 0 or cols == 0: + return + + # 첫 행 (헀더) 분석 + header_styles = [] + for c in range(cols): + cell = cells.get((0, c)) + if cell: + header_styles.append(cell.get('bf_id')) + + if header_styles: + # 가장 많읎 쓰읞 슀타음 + most_common = Counter(header_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['header_row_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background'), + 'borders': bf.get('borders', {}) + } + + # 첫 ì—Ž 분석 (행 1부터) + first_col_styles = [] + for r in range(1, rows): + cell = cells.get((r, 0)) + if cell: + first_col_styles.append(cell.get('bf_id')) + + if first_col_styles: + most_common = Counter(first_col_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['first_col_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') + } + + # 볞묞 셀 슀타음 (첫엎 제왞) + body_styles = [] + for r in range(1, rows): + for c in range(1, cols): + cell = cells.get((r, c)) + if cell: + body_styles.append(cell.get('bf_id')) + + if body_styles: + most_common = Counter(body_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + table_data['structure']['body_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') if bf else None + } + + def _create_style_summary(self, result: Dict) -> Dict: + """AI 프롬프튞용 슀타음 요앜""" + summary = { + '폰튾': list(result['fonts'].values())[:3], + '색상': { + '배겜색': result['colors']['background'], + '테두늬색': result['colors']['border'] + }, + '표_슀타음': [], + '특수_테두늬': [] + } + + # 표별 슀타음 요앜 + for i, tbl in enumerate(result['tables']): + tbl_summary = { + '표번혞': i + 1, + '크Ʞ': f"{tbl['rows']}행 × {tbl['cols']}ì—Ž", + '읎믞지셀': tbl['structure']['has_image_cells'] + } + + header = tbl['structure'].get('header_row_style') + if header: + tbl_summary['헀더행'] = f"배겜={header.get('background')}" + + first_col = tbl['structure'].get('first_col_style') + if first_col: + tbl_summary['첫엎'] = f"배겜={first_col.get('background')}" + + body = tbl['structure'].get('body_style') + if body: + tbl_summary['볞묞'] = f"배겜={body.get('background') or '없음'}" + + summary['표_슀타음'].append(tbl_summary) + + # 특수 테두늬 요앜 + seen = set() + for sb in result['special_borders']: + key = f"{sb['type_name']} {sb['width']} {sb['color']}" + if key not in seen: + seen.add(key) + summary['특수_테두늬'].append(key) + + return summary + + def _generate_css(self, result: Dict) -> str: + """CSS 생성 - 싀제 구조 반영""" + fonts = list(result['fonts'].values())[:2] + font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'" + + bg_colors = result['colors']['background'] + header_bg = bg_colors[0] if bg_colors else '#D6D6D6' + + # 특수 테두늬에서 2쀑선 ì°Ÿêž° + double_border = None + for sb in result['special_borders']: + if 'DOUBLE' in sb['type']: + double_border = sb + break + + css = f"""/* 템플늿 슀타음 v3 - HWPX 구조 êž°ë°˜ */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +:root {{ + --font-primary: 'Noto Sans KR', {font_family}, sans-serif; + --color-header-bg: {header_bg}; + --color-border: #000000; +}} + +body {{ + font-family: var(--font-primary); + font-size: 10pt; + line-height: 1.6; + color: #000000; +}} + +.sheet {{ + width: 210mm; + min-height: 297mm; + padding: 20mm; + margin: 10px auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +}} + +@media print {{ + .sheet {{ margin: 0; box-shadow: none; page-break-after: always; }} +}} + +/* 표 Ʞ볞 */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 9pt; +}} + +th, td {{ + border: 0.12mm solid var(--color-border); + padding: 6px 8px; + vertical-align: middle; +}} + +/* 헀더 행 */ +thead th, tr:first-child th, tr:first-child td {{ + background-color: var(--color-header-bg); + font-weight: bold; + text-align: center; +}} + +/* 첫 ì—Ž (구분 ì—Ž) - 배겜색 */ +td:first-child {{ + background-color: var(--color-header-bg); + text-align: center; + font-weight: 500; +}} + +/* 볞묞 셀 - 배겜 없음 */ +td:not(:first-child) {{ + background-color: transparent; +}} + +/* 2쀑선 테두늬 (헀더 하당) */ +thead tr:last-child th, +thead tr:last-child td, +tr:first-child th, +tr:first-child td {{ + border-bottom: 0.5mm double var(--color-border); +}} +""" + return css + + # ========================================================================= + # 유틞늬티 + # ========================================================================= + + def _normalize_color(self, color: str) -> str: + """ARGB 8자늬 → RGB 6자늬""" + if not color or color == 'none': + return color + color = color.strip() + # #AARRGGBB → #RRGGBB + if color.startswith('#') and len(color) == 9: + return '#' + color[3:] + return color + + def _parse_width(self, width_str: str) -> float: + """너비 묞자엎 → mm""" + if not width_str: + return 0.1 + try: + return float(width_str.split()[0]) + except: + return 0.1 + + def _extract_features(self, data: Dict) -> List[str]: + """특징 목록""" + features = [] + + fonts = list(data.get('fonts', {}).values()) + if fonts: + features.append(f"폰튾: {', '.join(fonts[:2])}") + + bg_colors = data.get('colors', {}).get('background', []) + if bg_colors: + features.append(f"배겜색: {', '.join(bg_colors[:2])}") + + tables = data.get('tables', []) + if tables: + has_img = any(t['structure']['has_image_cells'] for t in tables) + if has_img: + features.append("읎믞지 배겜 셀") + + special = data.get('special_borders', []) + if special: + types = set(s['type_name'] for s in special) + features.append(f"특수 테두늬: {', '.join(list(types)[:2])}") + + return features if features else ['Ʞ볞 템플늿'] + + def _analyze_fallback(self, ext: str) -> Dict: + """HWP, PDF Ʞ볞 분석""" + return { + 'version': 'v3', + 'fonts': {'0': '맑은 고딕'}, + 'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']}, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': { + '폰튾': ['맑은 고딕'], + '색상': {'배겜색': [], '테두늬색': ['#000000']}, + '표_슀타음': [], + '특수_테두늬': [] + }, + 'css': self._get_default_css(), + 'note': f'{ext} 파음은 Ʞ볞 분석만 지원. HWPX 권장.' + } + + def _get_default_css(self) -> str: + return """/* Ʞ볞 슀타음 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; } +.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; } +table { width: 100%; border-collapse: collapse; } +th, td { border: 0.5pt solid #000; padding: 8px; } +th { background: #D6D6D6; } +""" \ No newline at end of file diff --git a/03. Code/geulbeot_6th/handlers/template/prompts/analyze_template.txt b/03. Code/geulbeot_6th/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/03. Code/geulbeot_6th/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 묞서 템플늿 분석 전묞가입니닀. + +죌얎진 HWPX/HWP/PDF 템플늿의 구조륌 분석하여 닀음 정볎륌 추출핎죌섞요: + +1. 제목 슀타음 (H1~H6) + - 폰튞명, 크Ʞ(pt), 굵Ʞ, 색상 + - 정렬 방식 + - 번혞 첎계 (제1장, 1.1, 가. 등) + +2. 볞묞 슀타음 + - Ʞ볞 폰튾, 크Ʞ, 쀄간격 + - 듀여쓰Ʞ + +3. 표 슀타음 + - 헀더 배겜색 + - 테두늬 슀타음 (선 두께, 색상) + - 읎쀑선 사용 여부 + +4. 귞늌/캡션 슀타음 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페읎지 구성 + - 표지 유묎 + - 목찚 유묎 + - 뚞늬말/ꌬ늬말 + +분석 결곌륌 JSON 형식윌로 출력핎죌섞요. \ No newline at end of file diff --git a/03. Code/geulbeot_6th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_6th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_6th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_6th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_6th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_6th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_6th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_6th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_6th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_6th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_6th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_6th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_6th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_6th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_6th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_6th/prompts/step1_5_plan.txt b/03. Code/geulbeot_6th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_6th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_6th/prompts/step1_extract.txt b/03. Code/geulbeot_6th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_6th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_6th/prompts/step2_generate.txt b/03. Code/geulbeot_6th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_6th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                        + +
                        +

                        {{title}}

                        +
                        +
                        +
                        +
                        +
                        {{lead.text}} - 킀워드 강조
                        +
                        + +
                        +
                        {{conclusion.label}}
                        +
                        {{conclusion.text}}
                        +
                        +
                        +
                        - 1 -
                        +
                        + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                        +
                        {{section.title}}
                        +
                          +
                        • {{item.keyword}}: {{item.text}} {{highlight}}
                        • +
                        +
                        +``` + +### table → data-table +```html +
                        +
                        {{section.title}}
                        + + + + + + + + + + + + + +
                        {{col1}}{{col2}}
                        {{text}}{{text}}
                        +
                        +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{item.title}}
                        +

                        {{item.text}} {{highlight}}

                        +
                        +
                        +
                        +``` + +### two-column → two-col +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{item.title}}
                        +

                        {{item.text}} {{highlight}}

                        +
                        +
                        +
                        +``` + +### process → process-container +```html +
                        +
                        {{section.title}}
                        +
                        +
                        +
                        {{step.number}}
                        +
                        {{step.title}}: {{step.text}}
                        +
                        +
                        ▌
                        + +
                        +
                        +``` + +### qa → qa-grid +```html +
                        +
                        {{section.title}}
                        +
                        +
                        + Q. {{question}}
                        + A. {{answer}} +
                        +
                        +
                        +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                        [첚부] 핎당 낎용에 맞는 제목

                        ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                        [첚부] 핎당 페읎지 낎용에 맞는 제목

                        ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_6th/requirements.txt b/03. Code/geulbeot_6th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_6th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_6th/static/css/editor.css b/03. Code/geulbeot_6th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_6th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_6th/static/js/editor.js b/03. Code/geulbeot_6th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_6th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                        + + + +
                        + + + + +
                        + + +
                        +
                        + A + +
                        +
                        + A + +
                        + + +
                        + + + +
                        + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                        +
                        +
                        ▩ 표 삜입
                        +
                        + + +
                        +
                        + + +
                        +
                        + + +
                        +
                        + + +
                        +
                        +
                        + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                        헀더낎용
                        '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                        + +
                        귞늌 섀명
                        +
                        `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                        '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                        + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                        + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_6th/templates/hwp_guide.html b/03. Code/geulbeot_6th/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_6th/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
                        +
                        +
                        +
                        + ← 메읞윌로 +

                        HWP 변환 가읎드

                        +
                        +
                        +
                        +
                        + +
                        + +
                        +

                        ⚠ HWP 변환 요구사항

                        +
                          +
                        • • Windows 욎영첎제
                        • +
                        • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
                        • +
                        • • Python 3.8 읎상
                        • +
                        +
                        + + +
                        +

                        1. 필요 띌읎람러늬 섀치

                        +
                        pip install pyhwpx beautifulsoup4
                        +
                        + + +
                        +

                        2. 사용 방법

                        +
                          +
                        1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
                        2. +
                        3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
                        4. +
                        5. 슀크늜튞 낮 겜로륌 수정합니닀.
                        6. +
                        7. 슀크늜튞륌 싀행합니닀.
                        8. +
                        +
                        + + +
                        +
                        +

                        3. HWP 변환 슀크늜튞

                        + +
                        +
                        # -*- coding: utf-8 -*-
                        +"""
                        +Ꞁ벗 Light - HTML → HWP 변환Ʞ
                        +Windows + 한Ꞁ 프로귞랚 필요
                        +"""
                        +
                        +from pyhwpx import Hwp
                        +from bs4 import BeautifulSoup
                        +import os
                        +
                        +
                        +class HtmlToHwpConverter:
                        +    def __init__(self, visible=True):
                        +        self.hwp = Hwp(visible=visible)
                        +        self.colors = {}
                        +    
                        +    def _init_colors(self):
                        +        self.colors = {
                        +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
                        +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
                        +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
                        +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
                        +            'bg-light': self.hwp.RGBColor(247, 250, 252),
                        +            'white': self.hwp.RGBColor(255, 255, 255),
                        +            'black': self.hwp.RGBColor(0, 0, 0),
                        +        }
                        +    
                        +    def _mm(self, mm):
                        +        return self.hwp.MiliToHwpUnit(mm)
                        +    
                        +    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 _align(self, align):
                        +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
                        +        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 _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)
                        +    
                        +    def _create_header(self, left_text, right_text):
                        +        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._font(9, 'medium-gray')
                        +            self.hwp.insert_text(left_text)
                        +            self.hwp.insert_text("\t" * 12)
                        +            self.hwp.insert_text(right_text)
                        +            self.hwp.HAction.Run("CloseEx")
                        +        except Exception as e:
                        +            print(f"뚞늬말 생성 싀팚: {e}")
                        +    
                        +    def _create_footer(self, text):
                        +        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", 1)
                        +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
                        +            self._align('center')
                        +            self._font(8.5, 'medium-gray')
                        +            self.hwp.insert_text(text)
                        +            self.hwp.HAction.Run("CloseEx")
                        +        except Exception as e:
                        +            print(f"ꌬ늬말 생성 싀팚: {e}")
                        +    
                        +    def _convert_lead_box(self, elem):
                        +        content = elem.find("div")
                        +        if not content:
                        +            return
                        +        text = ' '.join(content.get_text().split())
                        +        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_bottom_box(self, elem):
                        +        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)
                        +        
                        +        self.hwp.create_table(1, 2, treat_as_char=True)
                        +        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):
                        +        title = section.find(class_="section-title")
                        +        if title:
                        +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
                        +        
                        +        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')
                        +        self._para()
                        +    
                        +    def _convert_sheet(self, sheet, is_first_page=False):
                        +        if is_first_page:
                        +            header = sheet.find(class_="page-header")
                        +            if header:
                        +                left = header.find(class_="header-left")
                        +                right = header.find(class_="header-right")
                        +                left_text = left.get_text(strip=True) if left else ""
                        +                right_text = right.get_text(strip=True) if right else ""
                        +                if left_text or right_text:
                        +                    self._create_header(left_text, right_text)
                        +            
                        +            footer = sheet.find(class_="page-footer")
                        +            if footer:
                        +                self._create_footer(footer.get_text(strip=True))
                        +        
                        +        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')
                        +            else:
                        +                self._para(title_text, 23, 'primary-navy', True, 'center')
                        +            self._font(10, 'secondary-navy')
                        +            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):
                        +        print(f"[입력] {html_path}")
                        +        
                        +        with open(html_path, 'r', encoding='utf-8') as f:
                        +            soup = BeautifulSoup(f.read(), 'html.parser')
                        +        
                        +        self.hwp.FileNew()
                        +        self._init_colors()
                        +        
                        +        # 페읎지 섀정
                        +        try:
                        +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
                        +            sec = self.hwp.HParameterSet.HSecDef
                        +            sec.PageDef.LeftMargin = self._mm(20)
                        +            sec.PageDef.RightMargin = self._mm(20)
                        +            sec.PageDef.TopMargin = self._mm(20)
                        +            sec.PageDef.BottomMargin = self._mm(20)
                        +            sec.PageDef.HeaderLen = self._mm(10)
                        +            sec.PageDef.FooterLen = self._mm(10)
                        +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
                        +        except Exception as e:
                        +            print(f"페읎지 섀정 싀팚: {e}")
                        +        
                        +        sheets = soup.find_all(class_="sheet")
                        +        total = len(sheets)
                        +        print(f"[변환] 쎝 {total} 페읎지")
                        +        
                        +        for i, sheet in enumerate(sheets, 1):
                        +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
                        +            self._convert_sheet(sheet, is_first_page=(i == 1))
                        +            if i < total:
                        +                self.hwp.HAction.Run("BreakPage")
                        +        
                        +        self.hwp.SaveAs(output_path)
                        +        print(f"✅ 저장 완료: {output_path}")
                        +    
                        +    def close(self):
                        +        try:
                        +            self.hwp.Quit()
                        +        except:
                        +            pass
                        +
                        +
                        +def main():
                        +    # ====================================
                        +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
                        +    # ====================================
                        +    html_path = r"C:\Users\User\Downloads\report.html"
                        +    output_path = r"C:\Users\User\Downloads\report.hwp"
                        +    
                        +    print("=" * 50)
                        +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
                        +    print("=" * 50)
                        +    
                        +    try:
                        +        converter = HtmlToHwpConverter(visible=True)
                        +        converter.convert(html_path, output_path)
                        +        print("\n✅ 변환 완료!")
                        +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
                        +        converter.close()
                        +    except FileNotFoundError:
                        +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
                        +    except Exception as e:
                        +        print(f"\n[에러] {e}")
                        +        import traceback
                        +        traceback.print_exc()
                        +
                        +
                        +if __name__ == "__main__":
                        +    main()
                        +
                        + + +
                        +

                        4. 겜로 수정

                        +

                        슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

                        +
                        html_path = r"C:\닀욎로드겜로\report.html"
                        +output_path = r"C:\저장겜로\report.hwp"
                        +
                        +
                        + + + + diff --git a/03. Code/geulbeot_6th/templates/index.html b/03. Code/geulbeot_6th/templates/index.html new file mode 100644 index 0000000..6a61fba --- /dev/null +++ b/03. Code/geulbeot_6th/templates/index.html @@ -0,0 +1,2975 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + + + +
                        + + +
                        + + + +
                        + + + +
                        + + + + + +
                        + + +
                        + + + + +
                        + +
                        +
                        +
                        + +
                        +
                        📄
                        +
                        HTML을 입력하고 생성하섞요
                        +
                        좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                        +
                        +
                        +
                        +
                        + + + +
                        + + +
                        +
                        + 묞서 섀정 +
                        + +
                        + +
                        +
                        묞서 유형
                        +
                        + +
                        + + 📋 Ʞ획서 + + +
                        +
                        +
                        + +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        + +
                        [첚부]
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        Ʞ획서 (볎고자료)
                        +
                        임원볎고용 정형화된 1~2페읎지 묞서
                        +
                        +
                        📄 1p 볞묞만 / 1p+1p첚부 / 1p+np첚부
                        +
                        🎚 Navy 양식 (A4 읞쇄 최적화)
                        +
                        ✍ 개조식 자동 변환
                        +
                        +
                        +
                        + + +
                        + + 📄 볎고서 + +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        볎고서 (HWP)
                        +
                        RAG êž°ë°˜ 장묞 볎고서 → HWPX 출력
                        +
                        +
                        🏷 AI 슀타음 자동 태깅
                        +
                        📝 대제목/쀑제목/소제목/볞묞
                        +
                        ✹ 한Ꞁ에서 슀타음 음ꎄ 변겜
                        +
                        +
                        +
                        + + +
                        + + 📊 발표자료 + 쀀비쀑 + +
                        +
                        +
                        +
                        제목
                        +
                        +
                        +
                        +
                        +
                        볞묞
                        +
                        +
                        +
                        +
                        +
                        +
                        ê²°ë¡ 
                        +
                        +
                        +
                        +
                        발표자료 (PPT)
                        +
                        프레젠테읎션 형식 슬띌읎드
                        +
                        +
                        📊 슬띌읎드 자동 구성
                        +
                        🎯 핵심 낎용 추출
                        +
                        🖌 도식화 자동 생성
                        +
                        +
                        +
                        +
                        + + + + + +
                        + + +
                        + +
                        +
                        페읎지 구성
                        +
                        +
                        + + +
                        +
                        + + +
                        +
                        + + +
                        +
                        +
                        + + +
                        +
                        요청사항
                        + +
                        +
                        + + + + + + + + + + + +
                        +
                        +
                        + + +
                        +
                        + + 쀀비됚 +
                        +
                        Ꞁ벗 Light v2.0
                        +
                        + + + + + + + + + + + + +
                        + +
                        🀖 AI로 수정하Ʞ
                        +
                        선택된 텍슀튞:
                        +
                        + + +
                        + + + +
                        +
                        +
                        +
                        📁 템플늿 추가
                        + +
                        + +
                        + + +
                        + +
                        + +
                        +
                        📄
                        +
                        파음을 드래귞하거나 큎늭하여 선택
                        +
                        HWPX, HWP, PDF 지원
                        +
                        + +
                        + + ✕ +
                        +
                        + + +
                        +
                        + + + \ No newline at end of file diff --git a/03. Code/geulbeot_7th/.env.sample b/03. Code/geulbeot_7th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_7th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_7th/.gitignore b/03. Code/geulbeot_7th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_7th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_7th/7th.zip b/03. Code/geulbeot_7th/7th.zip new file mode 100644 index 0000000..ef8cd9f Binary files /dev/null and b/03. Code/geulbeot_7th/7th.zip differ diff --git a/03. Code/geulbeot_7th/Procfile b/03. Code/geulbeot_7th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_7th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_7th/README.md b/03. Code/geulbeot_7th/README.md new file mode 100644 index 0000000..69a8688 --- /dev/null +++ b/03. Code/geulbeot_7th/README.md @@ -0,0 +1,291 @@ +# Ꞁ벗 (Geulbeot) v7.0 + +**UI 고도화 — 템플늿 ꎀ늬·작성 방식·묞서 유형 선택 UI** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +v7에서는 프론튞엔드 UI륌 고도화했습니닀. +v6에서 백엔드로만 졎재하던 템플늿 ꎀ늬륌 화멎에서 직접 조작할 수 있게 되었고, +자료 활용 방식(형식 변겜·재구성·신규 작성)곌 묞서 유형을 시각적윌로 선택하는 UI가 추가되었습니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +작성 방식 선택 ─── 형식만 변겜 / 낎용 재구성 / 신규 작성 (v7 신규) + │ + â–Œ +RAG 파읎프띌읞 (9닚계) ─── 공통 처늬 + │ + â–Œ +묞서 유형 선택 ─── UI 늬슀튞 (v7 신규) + ├─ Ʞ획서 (Ʞ볞) + ├─ 볎고서 (Ʞ볞) + ├─ 발표자료 (Ʞ볞) + └─ 사용자 등록 (확장 가능) + │ + â–Œ +Ꞁ벗 표쀀 HTML 생성 ◀── 템플늿 슀타음 ì°žì¡° + 요소 선택 (v7 신규) + │ + â–Œ +웹 펞집Ʞ (수Ʞ 펞집 / 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 3닚계), 볎고서 (Gemini 2닚계) + - AI 펞집: 전첎 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - HWPX 템플늿 분석·저장·ꎀ늬 + - HWP 변환: 하읎람늬드 방식 — pyhwpx → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 람띌우저에서 생성된 묞서 직접 수정 + - 페읎지 넘김·듀여쓰Ʞ·정렬 등 서식 도구 + - HTML / PDF / HWP 닀욎로드 + - **작성 방식 선택 탭 (v7 신규)**: 📄 형식만 변겜 / 🔄 낎용의 재구성 / ✹ 묞서 ì°žê³  신규 작성 + - **묞서 유형 선택 UI (v7 신규)**: Ʞ획서·볎고서 띌디였 늬슀튞 + 배지 슀타음 + - **템플늿 ꎀ늬 UI (v7 신규)**: 사읎드바에서 템플늿 업로드·선택·삭제, 적용할 요소 첎크박슀 + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 — ꞎ 묞서는 전첎 파읎프띌읞, 짧은 묞서는 축앜 파읎프띌읞 +- **HWP 변환 (하읎람늬드 방식)**: HTML 분석 → pyhwpx 변환 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + +### 4. 템플늿 ꎀ늬 + +- **HWPX 파싱**: 업로드된 HWPX륌 압축 핎제하여 header.xml + section*.xml 구조 분석 +- **자동 추출**: 폰튞·묞닚·표·배겜·테두늬·페읎지 섀정 +- **CSS 자동 생성**: 분석된 슀타음 → CSS 변환 +- **저장소**: `templates_store/` — meta.json + 원볞 + 분석 결곌 +- **UI 연동 (v7 신규)**: 사읎드바에서 목록 조회·선택·삭제, 요소별 적용 첎크박슀 + +### 5. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: 텍슀튞 또는 파음을 입력하멎, RAG 분석 후 Claude API가 구조 추출 → 페읎지 배치 계획 → Ꞁ벗 표쀀 HTML Ʞ획서륌 생성. 1~N페읎지 옵션 지원 +2. **볎고서 생성**: 폮더 겜로의 자료듀을 RAG 파읎프띌읞윌로 분석하고, Gemini API가 섹션별 윘텐잠 쎈안 → 표지·목찚·간지·별첚읎 포핚된 닀페읎지 HTML 볎고서륌 생성 +3. **작성 방식 선택 (v7 신규)**: 업로드 자료륌 얎떻게 활용할지 3가지 몚드 쀑 선택 + - 📄 **형식만 변겜** — 원볞 낎용 유지, Ꞁ벗 양식윌로만 변환 + - 🔄 **낎용의 재구성** — 원볞 Ʞ반윌로 구조와 낎용을 재구성 (Ʞ볞값) + - ✹ **묞서 ì°žê³  신규 작성** — 원볞을 ì°žê³  자료로만 활용, 새로 작성 +4. **템플늿 적용**: 등록된 HWPX 템플늿을 선택하고, 적용할 요소(제목 슀타음·표 슀타음·색상 등)륌 첎크박슀로 선택 +5. **HWP 낎볎낎Ʞ**: pyhwpx 변환 후 HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 수정 + +### 프로섞슀 플로우 + +#### 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 +``` + +--- + +## 🔄 v6 → v7 변겜사항 + +| 영역 | v6 | v7 | +|------|------|------| +| 작성 방식 | 없음 (묎조걎 재구성) | **3가지 몚드 UI**: 형식 변겜 / 재구성 / 신규 작성 | +| 묞서 유형 선택 | Ʞ획서·볎고서 구분 없읎 탭 | **묞서 유형 띌디였 늬슀튞** + 배지 슀타음 | +| 템플늿 ꎀ늬 UI | API만 졎재 (화멎 없음) | **사읎드바 UI**: 목록·선택·삭제 + 요소별 첎크박슀 | +| 템플늿 업로드 | API 직접 혞출 | **몚달 UI**: 파음 선택 + 읎늄 입력 + 업로드 | +| index.html | 2,974쀄 | 3,400쀄 (+426) | +| Python | 변겜 없음 | 변겜 없음 | + +--- + +## 🗺 상태 및 로드맵 (Status & Roadmap) + +- **Phase 1**: RAG 파읎프띌읞 — 9닚계 파읎프띌읞, 도메읞 분석, 분량 자동 판당 (🔧 Ʞ볞 구현) +- **Phase 2**: 묞서 생성 — Ʞ획서·볎고서 AI 생성 + Ꞁ벗 표쀀 HTML 양식 (🔧 Ʞ볞 구현) +- **Phase 3**: 출력 — HTML/PDF 닀욎로드, HWP 변환 (🔧 Ʞ볞 구현) +- **Phase 4**: HWP/HWPX/HTML 맀핑 — 슀타음 분석·HWPX 생성·슀타음 죌입·표 죌입 (🔧 Ʞ볞 구현) +- **Phase 5**: 묞서 유형 분석·등록 — HWPX 업로드 → AI 구조 분석 → 유형 CRUD + 확장 (예정) +- **Phase 6**: HWPX 템플늿 ꎀ늬 — 파싱·슀타음 추출·CSS 생성·저장·조회·삭제 (🔧 Ʞ볞 구현) +- **Phase 7**: UI 고도화 — 작성 방식 선택, 묞서 유형 UI, 템플늿 ꎀ늬 UI (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v7.git +cd geulbeot-v7 + +# 가상환겜 +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_7th/ +├── app.py # Flask 웹 서버 — API 띌우팅 +├── api_config.py # .env 환겜변수 로더 +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출, JSON/HTML 추출 +│ ├── briefing/ # Ʞ획서 처늬 (구조추출 → 배치 → HTML) +│ ├── report/ # 볎고서 처늬 (RAG 파읎프띌읞 연동) +│ └── template/ # 템플늿 ꎀ늬 (HWPX 파싱·분석·CRUD) +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ ├── style_analyzer.py # HTML 요소 역할 분류 +│ ├── hwpx_generator.py # HWPX 파음 직접 생성 +│ ├── hwp_style_mapping.py # 역할 → HWP 슀타음 맀핑 +│ ├── hwpx_style_injector.py # HWPX 컀슀텀 슀타음 죌입 +│ ├── hwpx_table_injector.py # HWPX 표 ì—Ž 너비 정밀 수정 +│ ├── html_to_hwp.py # 볎고서 → HWP 변환 +│ └── html_to_hwp_briefing.py # Ʞ획서 → HWP 변환 +│ +├── templates_store/ # 등록된 템플늿 저장소 +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 펞집Ʞ +│ └── css/editor.css # 펞집Ʞ 슀타음 +├── templates/ +│ ├── index.html # ★ v7 고도화 — 작성 방식·묞서 유형·템플늿 UI +│ └── hwp_guide.html # HWP 변환 가읎드 +│ +├── .env / .env.sample # API í‚€ ꎀ늬 +├── .gitignore +├── requirements.txt +├── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 묞서 유형: Ʞ획서·볎고서만 구현, 발표자료·사용자 등록 유형 믞구현 +- 작성 방식: UI만 구현, 백엔드 로직 믞연동 (몚드별 프롬프튞 ë¶„êž° 예정) +- 템플늿 → 묞서 생성 연동: 아직 믞연결 (선택·첎크는 가능, 생성 시 자동 적용은 예정) +- 레거시 잔졎: prompts/ 디렉토늬 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 11,500 | +| 프론튞엔드 (JS + CSS + HTML) | 4,904 (+1,045) | +| **합계** | **~16,400** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| v4 | 윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ | +| v5 | HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환 | +| v6 | HWPX 템플늿 분석·저장·ꎀ늬 | +| **v7** | **UI 고도화 — 작성 방식·묞서 유형·템플늿 ꎀ늬 UI** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_7th/api_config.py b/03. Code/geulbeot_7th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_7th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_7th/app.py b/03. Code/geulbeot_7th/app.py new file mode 100644 index 0000000..178a54d --- /dev/null +++ b/03. Code/geulbeot_7th/app.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +""" +Ꞁ벗 Light v2.0 +Flask 띌우팅 + 공통 Ʞ능 +""" + +import os +import io +import tempfile +from datetime import datetime +from flask import Flask, render_template, request, jsonify, Response, session, send_file +from handlers.template import TemplateProcessor + + +# 묞서 유형별 프로섞서 +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor + +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') + +# processors 딕셔너늬에 추가 +processors = { + 'briefing': BriefingProcessor(), + 'report': ReportProcessor(), + 'template': TemplateProcessor() # 추가 +} + + + +# ============== 메읞 페읎지 ============== + +@app.route('/') +def index(): + """메읞 페읎지""" + return render_template('index.html') + + +# ============== 생성 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', '') + + options = { + 'page_option': request.form.get('page_option', '1'), + 'department': request.form.get('department', '쎝ꎄꞰ획싀'), + 'instruction': request.form.get('instruction', '') + } + + result = processors['briefing'].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/') +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 + + +@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: + result = processors['template'].get_list() + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/analyze-template', methods=['POST']) +def analyze_template(): + """템플늿 분석 및 저장""" + 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 + + result = processors['template'].analyze(file, name) + + if 'error' in result: + return jsonify(result), 400 + + return jsonify(result) + + except Exception as e: + import traceback + return jsonify({'error': str(e), 'trace': traceback.format_exc()}), 500 + + +@app.route('/delete-template/', methods=['DELETE']) +def delete_template(template_id): + """템플늿 삭제""" + try: + result = processors['template'].delete(template_id) + + if 'error' in result: + return jsonify(result), 400 + + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/__init__.py b/03. Code/geulbeot_7th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_7th/converters/html_to_hwp.py b/03. Code/geulbeot_7th/converters/html_to_hwp.py new file mode 100644 index 0000000..d0a9afa --- /dev/null +++ b/03. Code/geulbeot_7th/converters/html_to_hwp.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 컀슀텀 슀타음 죌입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 슀타음 죌입 완료") + + except Exception as e: + print(f" [겜고] 슀타음 죌입 싀팚: {e}") + import traceback + traceback.print_exc() + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_7th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/hwp_style_mapping.py b/03. Code/geulbeot_7th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/hwpx_generator.py b/03. Code/geulbeot_7th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_7th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
                        +

                        걎섀·토목 잡량 DX 싀묎지칚

                        +

                        드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

                        +

                        2024년 1월

                        +
                        + +

                        1. 개요

                        +

                        볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

                        + +

                        1.1 배겜

                        +

                        최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

                        + +

                        1.1.1 Ʞ술 동향

                        +

                        1) 드론 잡량의 발전

                        +

                        드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

                        + +

                        (1) RTK 드론

                        +

                        싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

                        + +
                          +
                        • 고정밀 GPS 수신Ʞ 낎장
                        • +
                        • 섌티믞터 닚위 정확도
                        • +
                        + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/hwpx_style_injector.py b/03. Code/geulbeot_7th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/hwpx_table_injector.py b/03. Code/geulbeot_7th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_7th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/__init__.py b/03. Code/geulbeot_7th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_7th/converters/pipeline/router.py b/03. Code/geulbeot_7th/converters/pipeline/router.py new file mode 100644 index 0000000..9a396cc --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/router.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 읎믞지 겜로륌 서버 겜로로 변환 + - assets/xxx.png → /assets/xxx.png (Flask 서빙용) + - 절대 겜로나 URL은 귞대로 유지 + """ + + def replace_src(match): + original_path = match.group(1) + + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')): + return match.group(0) + + # assets/로 시작하멎 /assets/로 변환 (Flask 서빙) + if original_path.startswith('assets/'): + return f'src="/{original_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + +def inject_template_css(html_content: str, template_css: str) -> str: + """ + HTML에 템플늿 CSS 죌입 + - 태귞 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태귞 뒀에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없윌멎 ë§š 앞에 추가 + else: + return f'\n{html_content}' + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + # ⭐ 템플늿 CSS 죌입 + template_css = options.get('template_css') + if template_css and result.get('success') and result.get('html'): + result['html'] = inject_template_css(result['html'], template_css) + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_7th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..a3b57b6 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\테슀튞 쀑(잡량)\잡량_GIS_드론 ꎀ렚 자료듀" + OUTPUT_DIR = r"D:\for python\테슀튞 쀑(잡량)\추출" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_7th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..be4d6d6 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\survey_test\extract") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\survey_test\process") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_7th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..e01a87a --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\extract") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_7th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_7th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..9680692 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_7th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..30ef48e --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_7th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_7th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..d3e33d0 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step7_index.py b/03. Code/geulbeot_7th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..3180719 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
                        구분번혞제목킀워드
                        대목찚{num}{title}
                        쀑목찚{num}{title}
                        소목찚{num}{title}{kw}
                        + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
                        + + +
                        +

                        {html_escape(report_title)}

                        +
                        +
                        + +
                        +
                        +
                        확정 목찚 표 형태 정늬볞
                        +
                        +
                        목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
                        + +
                        +
                        목찚
                        + {table_html} +
                        +
                        + +
                        + + + +
                        +
                        + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_7th/converters/pipeline/step8_content.py b/03. Code/geulbeot_7th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..5f66190 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\survey_test\process") +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/pipeline/step9_html.py b/03. Code/geulbeot_7th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..3ee7365 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
                        변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\survey_test\output") +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
                          '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
                          ') + lines.append(f'
                        • {item.number}. {item.title}
                        • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
                        • {item.number} {item.title}
                        • ') + elif item.level == 3: + lines.append(f'
                        • {item.number} {item.title}
                        • ') + + if current_l1 is not None: + lines.append('
                          ') # 마지막 귞룹 ë‹«êž° + + lines.append('
                        ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
                        처늬 + cell = cell.replace('
                        ', '
                        ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
                        {cell}
                        {cell}
                        ') + + # 캡션 추가 + if caption: + html_lines.append(f'
                        {caption}
                        ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
                        변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
                        + {caption} +
                        {caption_text}
                        +
                        ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
                          ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                        • {content}
                        • ') + elif ol_match: + if not in_list: + html_lines.append('
                            ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                          1. {content}
                          2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                            변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                            {text}

                            ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                            {title}

                            ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                            {title}

                            ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                            {h3_match.group(1)}

                            ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                            {h4_match.group(1)}

                            ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                            +
                            {box_cover}
                            +
                            {box_toc}
                            +
                            {box_summary}
                            +
                            {box_content}
                            +
                            + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                            {main_title}

                            +

                            {sub_title}

                            +

                            {cover_date}

                            + {f'

                            {cover_author}

                            ' if cover_author else ''} + {f'

                            {cover_dept}

                            ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                            요앜

                            \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_7th/converters/style_analyzer.py b/03. Code/geulbeot_7th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_7th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                            + +
                            +

                            1 DX 개요와 Ʞ볞 개념

                            +

                            1.1 잡량 DX 프레임

                            +

                            1.1.1 잡량 DX 발전 닚계

                            +

                            1) Digitization 정의

                            +

                            볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                            +

                            (1) 닚계별 정의 및 진화

                            +

                            잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                            +
                            + +
                            + +
                            + +
                            +

                            ① 첫 번짞 항목

                            + + + + +
                            표 1. 데읎터 비교
                            구분낎용
                            항목1섀명1
                            +
                            + +
                            + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/__init__.py b/03. Code/geulbeot_7th/handlers/__init__.py new file mode 100644 index 0000000..7c7e687 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +handlers 팚킀지 +묞서 유형별 처늬 로직을 분늬하여 ꎀ늬 +""" \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/briefing/__init__.py b/03. Code/geulbeot_7th/handlers/briefing/__init__.py new file mode 100644 index 0000000..f0545ff --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/briefing/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 몚듈 +""" +from .processor import BriefingProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/briefing/processor.py b/03. Code/geulbeot_7th/handlers/briefing/processor.py new file mode 100644 index 0000000..e8825a3 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/briefing/processor.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +Ʞ획서(briefing) 처늬 로직 +- 1~2페읎지 압축형 볎고서 +- Navy 양식 +""" + +import os +import json +from pathlib import Path +from flask import jsonify, session + +from handlers.common import call_claude, extract_json, extract_html, load_prompt, client + + +class BriefingProcessor: + """Ʞ획서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def _get_step1_prompt(self) -> str: + """1닚계: 구조 추출 프롬프튞""" + prompt = self._load_prompt('step1_extract.txt') + if prompt: + return prompt + return """HTML 묞서륌 분석하여 JSON 구조로 추출하섞요. +원볞 텍슀튞륌 귞대로 볎졎하고, 구조만 정확히 파악하섞요.""" + + def _get_step1_5_prompt(self) -> str: + """1.5닚계: 배치 계획 프롬프튞""" + prompt = self._load_prompt('step1_5_plan.txt') + if prompt: + return prompt + return """JSON 구조륌 분석하여 페읎지 배치 계획을 수늜하섞요.""" + + def _get_step2_prompt(self) -> str: + """2닚계: HTML 생성 프롬프튞""" + prompt = self._load_prompt('step2_generate.txt') + if prompt: + return prompt + return """JSON 구조륌 각읞된 양식의 HTML로 변환하섞요. +Navy 색상 테마, A4 크Ʞ, Noto Sans KR 폰튞륌 사용하섞요.""" + + def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool: + """페읎지당 윘텐잠 양 첎크""" + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html.parser') + + sheets = soup.find_all('div', class_='sheet') + for sheet in sheets: + sections = sheet.find_all('div', class_='section') + if len(sections) > max_sections_per_page: + return True + + all_li = sheet.find_all('li') + if len(all_li) > 12: + return True + + steps = sheet.find_all('div', class_='process-step') + if len(steps) > 6: + return True + + return False + + def generate(self, content: str, options: dict) -> dict: + """Ʞ획서 생성""" + try: + if not content.strip(): + return {'error': '낎용을 입력하거나 파음을 업로드핎죌섞요.'} + + page_option = options.get('page_option', '1') + department = options.get('department', '쎝ꎄꞰ획싀') + additional_prompt = options.get('instruction', '') + + # ============== 1닚계: 구조 추출 ============== + step1_prompt = self._get_step1_prompt() + step1_message = f"""닀음 HTML 묞서의 구조륌 분석하여 JSON윌로 추출핎죌섞요. + +## 원볞 HTML +{content} + +--- +위 묞서륌 분석하여 JSON 구조로 출력하섞요. 섀명 없읎 JSON만 출력.""" + + step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000) + structure_json = extract_json(step1_response) + + if not structure_json: + structure_json = {"raw_content": content, "parse_failed": True} + + # ============== 1.5닚계: 배치 계획 ============== + step1_5_prompt = self._get_step1_5_prompt() + step1_5_message = f"""닀음 JSON 구조륌 분석하여 페읎지 배치 계획을 수늜핎죌섞요. + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 수 +{page_option}페읎지 + +--- +배치 계획 JSON만 출력하섞요. 섀명 없읎 JSON만.""" + + step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000) + page_plan = extract_json(step1_5_response) + + if not page_plan: + page_plan = {"page_plan": {}, "parse_failed": True} + + # ============== 2닚계: HTML 생성 ============== + page_instructions = { + '1': '1페읎지로 핵심 낎용만 압축하여 작성하섞요.', + '2': '2페읎지로 작성하섞요. 1페읎지는 볞묞, 2페읎지는 [첚부]입니닀.', + 'n': '여러 페읎지로 작성하섞요. 1페읎지는 볞묞, 나뚞지는 [첚부] 형태로 분할합니닀.' + } + + step2_prompt = self._get_step2_prompt() + step2_message = f"""닀음 배치 계획곌 묞서 구조륌 Ʞ반윌로 각읞된 양식의 HTML 볎고서륌 생성핎죌섞요. + +## 배치 계획 +{json.dumps(page_plan, ensure_ascii=False, indent=2)} + +## 묞서 구조 (JSON) +{json.dumps(structure_json, ensure_ascii=False, indent=2)} + +## 페읎지 옵션 +{page_instructions.get(page_option, page_instructions['1'])} + +## 부서명 +{department} + +## 추가 요청사항 +{additional_prompt if additional_prompt else '없음'} + +--- +위 JSON을 바탕윌로 완전한 HTML 묞서륌 생성하섞요. +윔드 랔록(```) 없읎 부터 까지 순수 HTML만 출력.""" + + step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000) + html_content = extract_html(step2_response) + + # 후처늬 검슝 + if self._content_too_long(html_content): + compress_message = f"""닀음 HTML읎 페읎지당 윘텐잠가 너묎 많습니닀. +각 페읎지당 섹션 3~4개, 늬슀튞 항목 8개 읎하로 압축핎죌섞요. + +{html_content} + +윔드 랔록 없읎 압축된 완전한 HTML만 출력하섞요.""" + + compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000) + html_content = extract_html(compress_response) + + # 섞션에 저장 + session['original_html'] = content + session['current_html'] = html_content + session['structure_json'] = json.dumps(structure_json, ensure_ascii=False) + session['conversation'] = [] + + return { + 'success': True, + 'html': html_content, + 'structure': structure_json + } + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. 나뚞지 구조와 슀타음은 귞대로 유지 +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 +5. 원볞 묞서의 텍슀튞륌 찞조하여 누띜된 낎용 복구 가능 + +## 원볞 HTML (ì°žê³ ìš©) +{original_html[:3000] if original_html else '없음'}... + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가, 레읎아웃 변겜 등) + +2. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용) + +3. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎) +4. STRUCTURE읞 겜우: 완전한 HTML 요소 출력 (Ʞ졎 큎래슀명 유지) +5. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_5_plan.txt b/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_extract.txt b/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/briefing/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_7th/handlers/briefing/prompts/step2_generate.txt b/03. Code/geulbeot_7th/handlers/briefing/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/briefing/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                            + +
                            +

                            {{title}}

                            +
                            +
                            +
                            +
                            +
                            {{lead.text}} - 킀워드 강조
                            +
                            + +
                            +
                            {{conclusion.label}}
                            +
                            {{conclusion.text}}
                            +
                            +
                            +
                            - 1 -
                            +
                            + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                            +
                            {{section.title}}
                            +
                              +
                            • {{item.keyword}}: {{item.text}} {{highlight}}
                            • +
                            +
                            +``` + +### table → data-table +```html +
                            +
                            {{section.title}}
                            + + + + + + + + + + + + + +
                            {{col1}}{{col2}}
                            {{text}}{{text}}
                            +
                            +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{item.title}}
                            +

                            {{item.text}} {{highlight}}

                            +
                            +
                            +
                            +``` + +### two-column → two-col +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{item.title}}
                            +

                            {{item.text}} {{highlight}}

                            +
                            +
                            +
                            +``` + +### process → process-container +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{step.number}}
                            +
                            {{step.title}}: {{step.text}}
                            +
                            +
                            ▌
                            + +
                            +
                            +``` + +### qa → qa-grid +```html +
                            +
                            {{section.title}}
                            +
                            +
                            + Q. {{question}}
                            + A. {{answer}} +
                            +
                            +
                            +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                            [첚부] 핎당 낎용에 맞는 제목

                            ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                            [첚부] 핎당 페읎지 낎용에 맞는 제목

                            ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/common.py b/03. Code/geulbeot_7th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/report/__init__.py b/03. Code/geulbeot_7th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/report/processor.py b/03. Code/geulbeot_7th/handlers/report/processor.py new file mode 100644 index 0000000..19def30 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/report/processor.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # ⭐ 템플늿 슀타음 로드 + template_id = options.get('template_id') + if template_id: + from handlers.template import TemplateProcessor + template_processor = TemplateProcessor() + style = template_processor.get_style(template_id) + if style and style.get('css'): + options['template_css'] = style['css'] + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_7th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/template/__init__.py b/03. Code/geulbeot_7th/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/template/processor.py b/03. Code/geulbeot_7th/handlers/template/processor.py new file mode 100644 index 0000000..f8cb6d1 --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/template/processor.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +""" +템플늿 처늬 로직 (v3 - 싀제 구조 정확 분석) +- HWPX 파음의 싀제 표 구조, 읎믞지 배겜, 테두늬 정확히 추출 +- ARGB 8자늬 색상 정규화 +- NONE 테두늬 색상 제왞 +""" + +import os +import json +import uuid +import shutil +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from collections import Counter, defaultdict + +# 템플늿 저장 겜로 +TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store' +TEMPLATES_DIR.mkdir(exist_ok=True) + +# HWP 명섞서 êž°ë°˜ 상수 +LINE_TYPES = { + 'NONE': '없음', + 'SOLID': '싀선', + 'DASH': 'ꞎ 점선', + 'DOT': '점선', + 'DASH_DOT': '-.-.-.-.', + 'DASH_DOT_DOT': '-..-..-..', + 'DOUBLE_SLIM': '2쀑선', + 'SLIM_THICK': '가는선+굵은선', + 'THICK_SLIM': '굵은선+가는선', + 'SLIM_THICK_SLIM': '가는선+굵은선+가는선', + 'WAVE': '묌결', + 'DOUBLE_WAVE': '묌결 2쀑선', +} + + +class TemplateProcessor: + """템플늿 처늬 큎래슀 (v3)""" + + NS = { + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'hs': 'http://www.hancom.co.kr/hwpml/2011/section', + } + + def __init__(self): + self.templates_dir = TEMPLATES_DIR + self.templates_dir.mkdir(exist_ok=True) + + # ========================================================================= + # 공개 API + # ========================================================================= + + def get_list(self) -> Dict[str, Any]: + """저장된 템플늿 목록""" + templates = [] + for item in self.templates_dir.iterdir(): + if item.is_dir(): + meta_path = item / 'meta.json' + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + templates.append({ + 'id': meta.get('id', item.name), + 'name': meta.get('name', item.name), + 'features': meta.get('features', []), + 'created_at': meta.get('created_at', '') + }) + except: + pass + templates.sort(key=lambda x: x.get('created_at', ''), reverse=True) + return {'templates': templates} + + def analyze(self, file, name: str) -> Dict[str, Any]: + """템플늿 파음 분석 및 저장""" + filename = file.filename + ext = Path(filename).suffix.lower() + + if ext not in ['.hwpx', '.hwp', '.pdf']: + return {'error': f'지원하지 않는 파음 형식: {ext}'} + + template_id = str(uuid.uuid4())[:8] + template_dir = self.templates_dir / template_id + template_dir.mkdir(exist_ok=True) + + try: + original_path = template_dir / f'original{ext}' + file.save(str(original_path)) + + if ext == '.hwpx': + style_data = self._analyze_hwpx(original_path, template_dir) + else: + style_data = self._analyze_fallback(ext) + + if 'error' in style_data: + shutil.rmtree(template_dir) + return style_data + + # 특징 추출 + features = self._extract_features(style_data) + + # 메타 저장 + meta = { + 'id': template_id, + 'name': name, + 'original_file': filename, + 'file_type': ext, + 'features': features, + 'created_at': datetime.now().isoformat() + } + (template_dir / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # 슀타음 저장 + (template_dir / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # CSS 저장 + css = style_data.get('css', '') + css_dir = template_dir / 'css' + css_dir.mkdir(exist_ok=True) + (css_dir / 'template.css').write_text(css, encoding='utf-8') + + return { + 'success': True, + 'template': { + 'id': template_id, + 'name': name, + 'features': features, + 'created_at': meta['created_at'] + } + } + except Exception as e: + if template_dir.exists(): + shutil.rmtree(template_dir) + raise e + + def delete(self, template_id: str) -> Dict[str, Any]: + """템플늿 삭제""" + template_dir = self.templates_dir / template_id + if not template_dir.exists(): + return {'error': '템플늿을 찟을 수 없습니닀'} + shutil.rmtree(template_dir) + return {'success': True, 'deleted': template_id} + + def get_style(self, template_id: str) -> Optional[Dict[str, Any]]: + """템플늿 슀타음 반환""" + style_path = self.templates_dir / template_id / 'style.json' + if not style_path.exists(): + return None + return json.loads(style_path.read_text(encoding='utf-8')) + + # ========================================================================= + # HWPX 분석 (핵심) + # ========================================================================= + + def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]: + """HWPX 분석 - 싀제 구조 정확히 추출""" + extract_dir = template_dir / 'extracted' + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + zf.extractall(extract_dir) + + result = { + 'version': 'v3', + 'fonts': {}, + 'colors': { + 'background': [], + 'border': [], + 'text': [] + }, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': {}, + 'css': '' + } + + # 1. header.xml 분석 + header_path = extract_dir / 'Contents' / 'header.xml' + if header_path.exists(): + self._parse_header(header_path, result) + + # 2. section0.xml 분석 + section_path = extract_dir / 'Contents' / 'section0.xml' + if section_path.exists(): + self._parse_section(section_path, result) + + # 3. 슀타음 요앜 생성 + result['style_summary'] = self._create_style_summary(result) + + # 4. CSS 생성 + result['css'] = self._generate_css(result) + + return result + + finally: + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + def _parse_header(self, header_path: Path, result: Dict): + """header.xml 파싱 - 폰튾, borderFill""" + tree = ET.parse(header_path) + root = tree.getroot() + + # 폰튾 + for fontface in root.findall('.//hh:fontface', self.NS): + if fontface.get('lang') == 'HANGUL': + for font in fontface.findall('hh:font', self.NS): + result['fonts'][font.get('id')] = font.get('face') + + # borderFill + for bf in root.findall('.//hh:borderFill', self.NS): + bf_id = bf.get('id') + bf_data = self._parse_border_fill(bf, result) + result['border_fills'][bf_id] = bf_data + + def _parse_border_fill(self, bf, result: Dict) -> Dict: + """개별 borderFill 파싱""" + bf_id = bf.get('id') + data = { + 'id': bf_id, + 'type': 'empty', + 'background': None, + 'image': None, + 'borders': {} + } + + # 읎믞지 배겜 + img_brush = bf.find('.//hc:imgBrush', self.NS) + if img_brush is not None: + img = img_brush.find('hc:img', self.NS) + if img is not None: + data['type'] = 'image' + data['image'] = { + 'ref': img.get('binaryItemIDRef'), + 'effect': img.get('effect') + } + + # 닚색 배겜 + win_brush = bf.find('.//hc:winBrush', self.NS) + if win_brush is not None: + face_color = self._normalize_color(win_brush.get('faceColor')) + if face_color and face_color != 'none': + if data['type'] == 'empty': + data['type'] = 'solid' + data['background'] = face_color + if face_color not in result['colors']['background']: + result['colors']['background'].append(face_color) + + # 4방향 테두늬 + for side in ['top', 'bottom', 'left', 'right']: + border = bf.find(f'hh:{side}Border', self.NS) + if border is not None: + border_type = border.get('type', 'NONE') + width = border.get('width', '0.1 mm') + color = self._normalize_color(border.get('color', '#000000')) + + data['borders'][side] = { + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'width_mm': self._parse_width(width), + 'color': color + } + + # 볎읎는 테두늬만 색상 수집 + if border_type != 'NONE': + if data['type'] == 'empty': + data['type'] = 'border_only' + if color and color not in result['colors']['border']: + result['colors']['border'].append(color) + + # 특수 테두늬 수집 + if border_type not in ['SOLID', 'NONE']: + result['special_borders'].append({ + 'bf_id': bf_id, + 'side': side, + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'color': color + }) + + return data + + def _parse_section(self, section_path: Path, result: Dict): + """section0.xml 파싱 - 표 구조""" + tree = ET.parse(section_path) + root = tree.getroot() + + border_fills = result['border_fills'] + + for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'): + table_data = { + 'rows': int(tbl.get('rowCnt', 0)), + 'cols': int(tbl.get('colCnt', 0)), + 'cells': [], + 'structure': { + 'header_row_style': None, + 'first_col_style': None, + 'body_style': None, + 'has_image_cells': False + } + } + + # 셀별 분석 + cell_by_position = {} + for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'): + cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr') + if cell_addr is None: + continue + + row = int(cell_addr.get('rowAddr', 0)) + col = int(cell_addr.get('colAddr', 0)) + bf_id = tc.get('borderFillIDRef') + bf_info = border_fills.get(bf_id, {}) + + # 텍슀튞 추출 + text = '' + for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'): + if t.text: + text += t.text + + cell_data = { + 'row': row, + 'col': col, + 'bf_id': bf_id, + 'bf_type': bf_info.get('type'), + 'background': bf_info.get('background'), + 'image': bf_info.get('image'), + 'text_preview': text[:30] if text else '' + } + + table_data['cells'].append(cell_data) + cell_by_position[(row, col)] = cell_data + + if bf_info.get('type') == 'image': + table_data['structure']['has_image_cells'] = True + + # 구조 분석: 헀더행, 첫엎 슀타음 + self._analyze_table_structure(table_data, cell_by_position, border_fills) + + result['tables'].append(table_data) + + def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict): + """표 구조 분석 - 헀더행/첫엎 슀타음 파악""" + rows = table_data['rows'] + cols = table_data['cols'] + + if rows == 0 or cols == 0: + return + + # 첫 행 (헀더) 분석 + header_styles = [] + for c in range(cols): + cell = cells.get((0, c)) + if cell: + header_styles.append(cell.get('bf_id')) + + if header_styles: + # 가장 많읎 쓰읞 슀타음 + most_common = Counter(header_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['header_row_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background'), + 'borders': bf.get('borders', {}) + } + + # 첫 ì—Ž 분석 (행 1부터) + first_col_styles = [] + for r in range(1, rows): + cell = cells.get((r, 0)) + if cell: + first_col_styles.append(cell.get('bf_id')) + + if first_col_styles: + most_common = Counter(first_col_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['first_col_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') + } + + # 볞묞 셀 슀타음 (첫엎 제왞) + body_styles = [] + for r in range(1, rows): + for c in range(1, cols): + cell = cells.get((r, c)) + if cell: + body_styles.append(cell.get('bf_id')) + + if body_styles: + most_common = Counter(body_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + table_data['structure']['body_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') if bf else None + } + + def _create_style_summary(self, result: Dict) -> Dict: + """AI 프롬프튞용 슀타음 요앜""" + summary = { + '폰튾': list(result['fonts'].values())[:3], + '색상': { + '배겜색': result['colors']['background'], + '테두늬색': result['colors']['border'] + }, + '표_슀타음': [], + '특수_테두늬': [] + } + + # 표별 슀타음 요앜 + for i, tbl in enumerate(result['tables']): + tbl_summary = { + '표번혞': i + 1, + '크Ʞ': f"{tbl['rows']}행 × {tbl['cols']}ì—Ž", + '읎믞지셀': tbl['structure']['has_image_cells'] + } + + header = tbl['structure'].get('header_row_style') + if header: + tbl_summary['헀더행'] = f"배겜={header.get('background')}" + + first_col = tbl['structure'].get('first_col_style') + if first_col: + tbl_summary['첫엎'] = f"배겜={first_col.get('background')}" + + body = tbl['structure'].get('body_style') + if body: + tbl_summary['볞묞'] = f"배겜={body.get('background') or '없음'}" + + summary['표_슀타음'].append(tbl_summary) + + # 특수 테두늬 요앜 + seen = set() + for sb in result['special_borders']: + key = f"{sb['type_name']} {sb['width']} {sb['color']}" + if key not in seen: + seen.add(key) + summary['특수_테두늬'].append(key) + + return summary + + def _generate_css(self, result: Dict) -> str: + """CSS 생성 - 싀제 구조 반영""" + fonts = list(result['fonts'].values())[:2] + font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'" + + bg_colors = result['colors']['background'] + header_bg = bg_colors[0] if bg_colors else '#D6D6D6' + + # 특수 테두늬에서 2쀑선 ì°Ÿêž° + double_border = None + for sb in result['special_borders']: + if 'DOUBLE' in sb['type']: + double_border = sb + break + + css = f"""/* 템플늿 슀타음 v3 - HWPX 구조 êž°ë°˜ */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +:root {{ + --font-primary: 'Noto Sans KR', {font_family}, sans-serif; + --color-header-bg: {header_bg}; + --color-border: #000000; +}} + +body {{ + font-family: var(--font-primary); + font-size: 10pt; + line-height: 1.6; + color: #000000; +}} + +.sheet {{ + width: 210mm; + min-height: 297mm; + padding: 20mm; + margin: 10px auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +}} + +@media print {{ + .sheet {{ margin: 0; box-shadow: none; page-break-after: always; }} +}} + +/* 표 Ʞ볞 */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 9pt; +}} + +th, td {{ + border: 0.12mm solid var(--color-border); + padding: 6px 8px; + vertical-align: middle; +}} + +/* 헀더 행 */ +thead th, tr:first-child th, tr:first-child td {{ + background-color: var(--color-header-bg); + font-weight: bold; + text-align: center; +}} + +/* 첫 ì—Ž (구분 ì—Ž) - 배겜색 */ +td:first-child {{ + background-color: var(--color-header-bg); + text-align: center; + font-weight: 500; +}} + +/* 볞묞 셀 - 배겜 없음 */ +td:not(:first-child) {{ + background-color: transparent; +}} + +/* 2쀑선 테두늬 (헀더 하당) */ +thead tr:last-child th, +thead tr:last-child td, +tr:first-child th, +tr:first-child td {{ + border-bottom: 0.5mm double var(--color-border); +}} +""" + return css + + # ========================================================================= + # 유틞늬티 + # ========================================================================= + + def _normalize_color(self, color: str) -> str: + """ARGB 8자늬 → RGB 6자늬""" + if not color or color == 'none': + return color + color = color.strip() + # #AARRGGBB → #RRGGBB + if color.startswith('#') and len(color) == 9: + return '#' + color[3:] + return color + + def _parse_width(self, width_str: str) -> float: + """너비 묞자엎 → mm""" + if not width_str: + return 0.1 + try: + return float(width_str.split()[0]) + except: + return 0.1 + + def _extract_features(self, data: Dict) -> List[str]: + """특징 목록""" + features = [] + + fonts = list(data.get('fonts', {}).values()) + if fonts: + features.append(f"폰튾: {', '.join(fonts[:2])}") + + bg_colors = data.get('colors', {}).get('background', []) + if bg_colors: + features.append(f"배겜색: {', '.join(bg_colors[:2])}") + + tables = data.get('tables', []) + if tables: + has_img = any(t['structure']['has_image_cells'] for t in tables) + if has_img: + features.append("읎믞지 배겜 셀") + + special = data.get('special_borders', []) + if special: + types = set(s['type_name'] for s in special) + features.append(f"특수 테두늬: {', '.join(list(types)[:2])}") + + return features if features else ['Ʞ볞 템플늿'] + + def _analyze_fallback(self, ext: str) -> Dict: + """HWP, PDF Ʞ볞 분석""" + return { + 'version': 'v3', + 'fonts': {'0': '맑은 고딕'}, + 'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']}, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': { + '폰튾': ['맑은 고딕'], + '색상': {'배겜색': [], '테두늬색': ['#000000']}, + '표_슀타음': [], + '특수_테두늬': [] + }, + 'css': self._get_default_css(), + 'note': f'{ext} 파음은 Ʞ볞 분석만 지원. HWPX 권장.' + } + + def _get_default_css(self) -> str: + return """/* Ʞ볞 슀타음 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; } +.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; } +table { width: 100%; border-collapse: collapse; } +th, td { border: 0.5pt solid #000; padding: 8px; } +th { background: #D6D6D6; } +""" \ No newline at end of file diff --git a/03. Code/geulbeot_7th/handlers/template/prompts/analyze_template.txt b/03. Code/geulbeot_7th/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/03. Code/geulbeot_7th/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 묞서 템플늿 분석 전묞가입니닀. + +죌얎진 HWPX/HWP/PDF 템플늿의 구조륌 분석하여 닀음 정볎륌 추출핎죌섞요: + +1. 제목 슀타음 (H1~H6) + - 폰튞명, 크Ʞ(pt), 굵Ʞ, 색상 + - 정렬 방식 + - 번혞 첎계 (제1장, 1.1, 가. 등) + +2. 볞묞 슀타음 + - Ʞ볞 폰튾, 크Ʞ, 쀄간격 + - 듀여쓰Ʞ + +3. 표 슀타음 + - 헀더 배겜색 + - 테두늬 슀타음 (선 두께, 색상) + - 읎쀑선 사용 여부 + +4. 귞늌/캡션 슀타음 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페읎지 구성 + - 표지 유묎 + - 목찚 유묎 + - 뚞늬말/ꌬ늬말 + +분석 결곌륌 JSON 형식윌로 출력핎죌섞요. \ No newline at end of file diff --git a/03. Code/geulbeot_7th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_7th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_7th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_7th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_7th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_7th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_7th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_7th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_7th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_7th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_7th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_7th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_7th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_7th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_7th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_7th/prompts/step1_5_plan.txt b/03. Code/geulbeot_7th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_7th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_7th/prompts/step1_extract.txt b/03. Code/geulbeot_7th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_7th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_7th/prompts/step2_generate.txt b/03. Code/geulbeot_7th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_7th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                            + +
                            +

                            {{title}}

                            +
                            +
                            +
                            +
                            +
                            {{lead.text}} - 킀워드 강조
                            +
                            + +
                            +
                            {{conclusion.label}}
                            +
                            {{conclusion.text}}
                            +
                            +
                            +
                            - 1 -
                            +
                            + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                            +
                            {{section.title}}
                            +
                              +
                            • {{item.keyword}}: {{item.text}} {{highlight}}
                            • +
                            +
                            +``` + +### table → data-table +```html +
                            +
                            {{section.title}}
                            + + + + + + + + + + + + + +
                            {{col1}}{{col2}}
                            {{text}}{{text}}
                            +
                            +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{item.title}}
                            +

                            {{item.text}} {{highlight}}

                            +
                            +
                            +
                            +``` + +### two-column → two-col +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{item.title}}
                            +

                            {{item.text}} {{highlight}}

                            +
                            +
                            +
                            +``` + +### process → process-container +```html +
                            +
                            {{section.title}}
                            +
                            +
                            +
                            {{step.number}}
                            +
                            {{step.title}}: {{step.text}}
                            +
                            +
                            ▌
                            + +
                            +
                            +``` + +### qa → qa-grid +```html +
                            +
                            {{section.title}}
                            +
                            +
                            + Q. {{question}}
                            + A. {{answer}} +
                            +
                            +
                            +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                            [첚부] 핎당 낎용에 맞는 제목

                            ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                            [첚부] 핎당 페읎지 낎용에 맞는 제목

                            ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_7th/requirements.txt b/03. Code/geulbeot_7th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_7th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_7th/static/css/editor.css b/03. Code/geulbeot_7th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_7th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_7th/static/js/editor.js b/03. Code/geulbeot_7th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_7th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                            + + + +
                            + + + + +
                            + + +
                            +
                            + A + +
                            +
                            + A + +
                            + + +
                            + + + +
                            + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                            +
                            +
                            ▩ 표 삜입
                            +
                            + + +
                            +
                            + + +
                            +
                            + + +
                            +
                            + + +
                            +
                            +
                            + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                            헀더낎용
                            '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                            + +
                            귞늌 섀명
                            +
                            `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                            '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                            + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                            + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_7th/templates/hwp_guide.html b/03. Code/geulbeot_7th/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/03. Code/geulbeot_7th/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가읎드 - Ꞁ벗 Light + + + + + + +
                            +
                            +
                            +
                            + ← 메읞윌로 +

                            HWP 변환 가읎드

                            +
                            +
                            +
                            +
                            + +
                            + +
                            +

                            ⚠ HWP 변환 요구사항

                            +
                              +
                            • • Windows 욎영첎제
                            • +
                            • • 한Ꞁ 프로귞랚 (한컎였플슀) 섀치
                            • +
                            • • Python 3.8 읎상
                            • +
                            +
                            + + +
                            +

                            1. 필요 띌읎람러늬 섀치

                            +
                            pip install pyhwpx beautifulsoup4
                            +
                            + + +
                            +

                            2. 사용 방법

                            +
                              +
                            1. Ꞁ벗 Light에서 HTML 파음을 닀욎로드합니닀.
                            2. +
                            3. 아래 Python 슀크늜튞륌 닀욎로드합니닀.
                            4. +
                            5. 슀크늜튞 낮 겜로륌 수정합니닀.
                            6. +
                            7. 슀크늜튞륌 싀행합니닀.
                            8. +
                            +
                            + + +
                            +
                            +

                            3. HWP 변환 슀크늜튞

                            + +
                            +
                            # -*- coding: utf-8 -*-
                            +"""
                            +Ꞁ벗 Light - HTML → HWP 변환Ʞ
                            +Windows + 한Ꞁ 프로귞랚 필요
                            +"""
                            +
                            +from pyhwpx import Hwp
                            +from bs4 import BeautifulSoup
                            +import os
                            +
                            +
                            +class HtmlToHwpConverter:
                            +    def __init__(self, visible=True):
                            +        self.hwp = Hwp(visible=visible)
                            +        self.colors = {}
                            +    
                            +    def _init_colors(self):
                            +        self.colors = {
                            +            'primary-navy': self.hwp.RGBColor(26, 54, 93),
                            +            'secondary-navy': self.hwp.RGBColor(44, 82, 130),
                            +            'dark-gray': self.hwp.RGBColor(45, 55, 72),
                            +            'medium-gray': self.hwp.RGBColor(74, 85, 104),
                            +            'bg-light': self.hwp.RGBColor(247, 250, 252),
                            +            'white': self.hwp.RGBColor(255, 255, 255),
                            +            'black': self.hwp.RGBColor(0, 0, 0),
                            +        }
                            +    
                            +    def _mm(self, mm):
                            +        return self.hwp.MiliToHwpUnit(mm)
                            +    
                            +    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 _align(self, align):
                            +        actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
                            +        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 _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)
                            +    
                            +    def _create_header(self, left_text, right_text):
                            +        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._font(9, 'medium-gray')
                            +            self.hwp.insert_text(left_text)
                            +            self.hwp.insert_text("\t" * 12)
                            +            self.hwp.insert_text(right_text)
                            +            self.hwp.HAction.Run("CloseEx")
                            +        except Exception as e:
                            +            print(f"뚞늬말 생성 싀팚: {e}")
                            +    
                            +    def _create_footer(self, text):
                            +        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", 1)
                            +            self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
                            +            self._align('center')
                            +            self._font(8.5, 'medium-gray')
                            +            self.hwp.insert_text(text)
                            +            self.hwp.HAction.Run("CloseEx")
                            +        except Exception as e:
                            +            print(f"ꌬ늬말 생성 싀팚: {e}")
                            +    
                            +    def _convert_lead_box(self, elem):
                            +        content = elem.find("div")
                            +        if not content:
                            +            return
                            +        text = ' '.join(content.get_text().split())
                            +        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_bottom_box(self, elem):
                            +        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)
                            +        
                            +        self.hwp.create_table(1, 2, treat_as_char=True)
                            +        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):
                            +        title = section.find(class_="section-title")
                            +        if title:
                            +            self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
                            +        
                            +        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')
                            +        self._para()
                            +    
                            +    def _convert_sheet(self, sheet, is_first_page=False):
                            +        if is_first_page:
                            +            header = sheet.find(class_="page-header")
                            +            if header:
                            +                left = header.find(class_="header-left")
                            +                right = header.find(class_="header-right")
                            +                left_text = left.get_text(strip=True) if left else ""
                            +                right_text = right.get_text(strip=True) if right else ""
                            +                if left_text or right_text:
                            +                    self._create_header(left_text, right_text)
                            +            
                            +            footer = sheet.find(class_="page-footer")
                            +            if footer:
                            +                self._create_footer(footer.get_text(strip=True))
                            +        
                            +        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')
                            +            else:
                            +                self._para(title_text, 23, 'primary-navy', True, 'center')
                            +            self._font(10, 'secondary-navy')
                            +            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):
                            +        print(f"[입력] {html_path}")
                            +        
                            +        with open(html_path, 'r', encoding='utf-8') as f:
                            +            soup = BeautifulSoup(f.read(), 'html.parser')
                            +        
                            +        self.hwp.FileNew()
                            +        self._init_colors()
                            +        
                            +        # 페읎지 섀정
                            +        try:
                            +            self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
                            +            sec = self.hwp.HParameterSet.HSecDef
                            +            sec.PageDef.LeftMargin = self._mm(20)
                            +            sec.PageDef.RightMargin = self._mm(20)
                            +            sec.PageDef.TopMargin = self._mm(20)
                            +            sec.PageDef.BottomMargin = self._mm(20)
                            +            sec.PageDef.HeaderLen = self._mm(10)
                            +            sec.PageDef.FooterLen = self._mm(10)
                            +            self.hwp.HAction.Execute("PageSetup", sec.HSet)
                            +        except Exception as e:
                            +            print(f"페읎지 섀정 싀팚: {e}")
                            +        
                            +        sheets = soup.find_all(class_="sheet")
                            +        total = len(sheets)
                            +        print(f"[변환] 쎝 {total} 페읎지")
                            +        
                            +        for i, sheet in enumerate(sheets, 1):
                            +            print(f"[{i}/{total}] 페읎지 처늬 쀑...")
                            +            self._convert_sheet(sheet, is_first_page=(i == 1))
                            +            if i < total:
                            +                self.hwp.HAction.Run("BreakPage")
                            +        
                            +        self.hwp.SaveAs(output_path)
                            +        print(f"✅ 저장 완료: {output_path}")
                            +    
                            +    def close(self):
                            +        try:
                            +            self.hwp.Quit()
                            +        except:
                            +            pass
                            +
                            +
                            +def main():
                            +    # ====================================
                            +    # 겜로 섀정 (볞읞 환겜에 맞게 수정)
                            +    # ====================================
                            +    html_path = r"C:\Users\User\Downloads\report.html"
                            +    output_path = r"C:\Users\User\Downloads\report.hwp"
                            +    
                            +    print("=" * 50)
                            +    print("Ꞁ벗 Light - HTML → HWP 변환Ʞ")
                            +    print("=" * 50)
                            +    
                            +    try:
                            +        converter = HtmlToHwpConverter(visible=True)
                            +        converter.convert(html_path, output_path)
                            +        print("\n✅ 변환 완료!")
                            +        input("Enter륌 누륎멎 HWP가 닫힙니닀...")
                            +        converter.close()
                            +    except FileNotFoundError:
                            +        print(f"\n[에러] 파음을 찟을 수 없습니닀: {html_path}")
                            +    except Exception as e:
                            +        print(f"\n[에러] {e}")
                            +        import traceback
                            +        traceback.print_exc()
                            +
                            +
                            +if __name__ == "__main__":
                            +    main()
                            +
                            + + +
                            +

                            4. 겜로 수정

                            +

                            슀크늜튞 하닚의 main() 핚수에서 겜로륌 수정하섞요:

                            +
                            html_path = r"C:\닀욎로드겜로\report.html"
                            +output_path = r"C:\저장겜로\report.hwp"
                            +
                            +
                            + + + + diff --git a/03. Code/geulbeot_7th/templates/index.html b/03. Code/geulbeot_7th/templates/index.html new file mode 100644 index 0000000..150d13a --- /dev/null +++ b/03. Code/geulbeot_7th/templates/index.html @@ -0,0 +1,3401 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + + + +
                            + + +
                            + + + +
                            + + + +
                            + + + + + +
                            + + +
                            + + + + +
                            + +
                            +
                            +
                            + +
                            +
                            📄
                            +
                            HTML을 입력하고 생성하섞요
                            +
                            좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                            +
                            +
                            +
                            +
                            + + +
                            + + +
                            + + + +
                            + + +
                            +
                            + 묞서 섀정 +
                            +
                            + +
                            +
                            묞서 유형
                            +
                            + +
                            + + 📋 Ʞ획서 + +
                            + + +
                            + + 📄 볎고서 +
                            + + +
                            + + 📊 발표자료 + 쀀비쀑 +
                            + + +
                            +
                            + + + +
                            + + +
                            +
                            +
                            페읎지 구성
                            +
                            +
                            + + +
                            +
                            + + +
                            +
                            + + +
                            +
                            +
                            +
                            + + + + + + + +
                            +
                            템플늿
                            +
                            + +
                            + + 📄 Ʞ볞 템플늿 +
                            + + +
                            +
                            + + + + + + +
                            + + +
                            +
                            요청사항
                            + +
                            + + + +
                            +
                            +
                            + + +
                            +
                            + + 쀀비됚 +
                            +
                            Ꞁ벗 Light v2.0
                            +
                            + + + + + + + + + + + + +
                            + +
                            🀖 AI로 수정하Ʞ
                            +
                            선택된 텍슀튞:
                            +
                            + + +
                            + + + +
                            +
                            +
                            +
                            📁 템플늿 추가
                            + +
                            + +
                            + + +
                            + +
                            + +
                            +
                            📄
                            +
                            파음을 드래귞하거나 큎늭하여 선택
                            +
                            HWPX, HWP, PDF 지원
                            +
                            + +
                            + + ✕ +
                            +
                            + + +
                            +
                            + + + + + +
                            +
                            +
                            +
                            📁 묞서 유형 추가
                            + +
                            + +
                            + + +
                            + +
                            + +
                            +
                            📄
                            +
                            샘플 묞서륌 업로드하멎 AI가 구조륌 분석합니닀
                            +
                            HWPX, HWP, PDF, DOCX 지원
                            +
                            +
                            + + +
                            +
                            + + \ No newline at end of file diff --git a/03. Code/geulbeot_8th/.env.sample b/03. Code/geulbeot_8th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_8th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_8th/.gitignore b/03. Code/geulbeot_8th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_8th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_8th/Procfile b/03. Code/geulbeot_8th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_8th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_8th/README.md b/03. Code/geulbeot_8th/README.md new file mode 100644 index 0000000..6dca87e --- /dev/null +++ b/03. Code/geulbeot_8th/README.md @@ -0,0 +1,446 @@ +# Ꞁ벗 (Geulbeot) v8.0 + +**묞서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플늿 고도화** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +v8에서는 **묞서 유형 분석·등록 시슀템**을 구축했습니닀. +HWPX 파음을 업로드하멎 12종의 추출 도구가 XML을 윔드 Ʞ반윌로 파싱하고, +시맚틱 맀퍌가 요소 의믞륌 판별한 ë’€, 슀타음 생성Ʞ가 CSS륌 산출하여 +사용자 정의 묞서 유형윌로 등록합니닀. 등록된 유형은 Ʞ획서·볎고서와 동음하게 묞서 생성에 사용됩니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +작성 방식 선택 ─── 형식만 변겜 / 낎용 재구성 / 신규 작성 + │ + â–Œ +RAG 파읎프띌읞 (9닚계) ─── 공통 처늬 + │ + â–Œ +묞서 유형 선택 + ├─ Ʞ획서 (Ʞ볞) + ├─ 볎고서 (Ʞ볞) + ├─ 발표자료 (Ʞ볞) + └─ 사용자 등록 (v8 — 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), 사용자 정의 유형 (v8 신규) + - AI 펞집: 전첎 수정 (`/refine`), 부분 수정 (`/refine-selection`) + - 묞서 유형 분석·등록 (v8 신규): HWPX 업로드 → 12종 도구 추출 → 시맚틱 맀핑 → 슀타음 생성 → 유형 CRUD + - HWPX 템플늿 ꎀ늬: 추출·저장·교첎·삭제 + - HWP 변환: 하읎람늬드 방식 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 생성된 묞서 직접 수정 + - 작성 방식 선택 탭: 형식만 변겜 / 낎용 재구성 / 신규 작성 + - 묞서 유형 선택 UI: Ʞ볞 3종 + 사용자 등록 유형 동적 표시 + - 템플늿 ꎀ늬 UI: 사읎드바 목록·선택·삭제, 요소별 첎크박슀 + - HTML / PDF / HWP 닀욎로드 + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 +- **HWP 변환 (하읎람늬드)**: HTML 분석 → pyhwpx 변환 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + +### 4. HWPX 추출 도구 12종 (v8 신규) + +HWPX XML에서 특정 항목을 **윔드 êž°ë°˜**윌로 추출하는 몚듈 팚킀지 (`handlers/tools/`): + +| 도구 | 대상 | 추출 낎용 | +|------|------|----------| +| page_setup | §7 용지/여백 | pagePr, margin, 용지 크Ʞ | +| font | §3 Ꞁꌎ | fontface → 폰튞명·유형 맀핑 | +| char_style | §4 Ꞁ자 몚양 | charPr 28개 속성 전첎 | +| para_style | §5 묞닚 몚양 | paraPr 23개 속성 전첎 | +| border_fill | §2 테두늬/배겜 | borderFill, 색상·선 종류 | +| table | §6 표 | tbl, tc, 병합·너비·셀 구조 | +| header_footer | §8 뚞늬말/ꌬ늬말 | headerFooter 영역 | +| section | §9 구역 정의 | secPr, 닀닚, 페읎지 속성 | +| style_def | 슀타음 정의 | styles 목록 (charPr + paraPr 조합) | +| numbering | 번혞맀ꞰꞰ | Ꞁ뚞늬표·번혞 첎계 | +| image | 읎믞지 | 귞늬Ʞ 객첎, 크Ʞ·위치 | +| content_order | 볞묞 순서 | section*.xml 묞닚·표·읎믞지 순서 | + +- 추출 싀팚 시 `None` 반환 (디폎튞값 절대 생성 안 핹) +- 몚든 닚위 변환은 `hwpx_utils` 사용 (hwpunit→mm, charsize→pt) +- `hwpx_domain_guide.md` Ʞ쀀 쀀수 + +### 5. 묞서 유형 분석·등록 (v8 신규) + +HWPX 업로드 → 자동 분석 → 사용자 정의 묞서 유형 등록: + +1. **DocTemplateAnalyzer**: HWPX 압축 핎제 → 12종 도구로 윔드 êž°ë°˜ 추출 +2. **SemanticMapper**: 추출 결곌에서 요소 의믞 판별 (헀더표/푾터표/제목랔록/데읎터표/섹션) +3. **StyleGenerator**: 추출값 → CSS 생성 (charPr→큎래슀, paraPr→큎래슀, 폰튾 맀핑, 쀄간격) +4. **ContentAnalyzer**: template_info + semantic_map → content_prompt.json (placeholder 의믞·유형·작성 팹턮) +5. **DocTypeAnalyzer**: AI로 맥띜(목적/묞서유형)곌 구조 가읎드(섹션별 작성법) 분석 — 레읎아웃은 윔드 추출 +6. **TemplateManager**: 템플늿 CRUD (생성·조회·삭제·교첎), template.html 조늜 +7. **CustomDocType**: 등록된 유형윌로 싀제 묞서 생성 — template.html에 사용자 윘텐잠 채움 + +### 6. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: RAG 분석 후 Claude API가 구조 추출 → 배치 → Ꞁ벗 표쀀 HTML 생성 +2. **볎고서 생성**: RAG 파읎프띌읞 → Gemini API가 닀페읎지 HTML 볎고서 생성 +3. **사용자 정의 묞서 생성 (v8 신규)**: 등록된 유형의 template.html + content_prompt.json을 Ʞ반윌로, 사용자 입력 낎용을 정늬·재구성하여 묞서 생성 +4. **묞서 유형 등록 (v8 신규)**: HWPX 업로드 → 12종 도구 추출 → 시맚틱 맀핑 → CSS 생성 → config.json + template.html + semantic_map.json + style.json 자동 저장 +5. **AI 펞집**: 웹 펞집Ʞ에서 전첎·부분 수정 +6. **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 +``` + +#### 전첎 워크플로우 (v8 시점) + +```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 + + A(["📂 자료 입력"]):::startEnd + + 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 êž°ë°˜\n(v8 신규)"]:::newModule + + T["📋 템플늿 + 시맚틱 ë§µ\nstyle.json\nsemantic_map.json\ncontent_prompt.json"]:::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 --> W + W --> W1 & W2 & W3 + W1 & W2 & W3 --> 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 +``` + +#### 묞서 유형 등록 (v8 신규) + +```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 +``` + +--- + +## 🔄 v7 → v8 변겜사항 + +| 영역 | v7 | v8 | +|------|------|------| +| 묞서 유형 등록 | 없음 | **HWPX → 자동 분석 → 유형 CRUD** (doc_type_analyzer + custom_doc_type) | +| HWPX 추출 | template/processor.py 닚음 | **handlers/tools/ 12종 몚듈** (폰튞·묞닚·표·테두늬·읎믞지 등) | +| 시맚틱 맀핑 | 없음 | **semantic_mapper** — 요소 의믞 판별 (헀더/푾터/제목/데읎터표) | +| 슀타음 생성 | CSS 자동 생성 (Ʞ쎈) | **style_generator v2.1** — charPr 28개·paraPr 23개 전첎 CSS 큎래슀 | +| 윘텐잠 분석 | 없음 | **content_analyzer** — placeholder 의믞·유형·작성 팹턮 추출 | +| 템플늿 ꎀ늬 | 분석·저장·CRUD | **template_manager v5.2** — content_order êž°ë°˜ 볞묞 조늜, 독늜 저장 구조 | +| 도메읞 지식 | 없음 | **domain/hwpx/** — hwpx_utils + hwpx_domain_guide.md | +| Ʞ볞 묞서유형 | 하드윔딩 | **config.json 3종** (briefing·report·presentation) | +| 사용자 유형 | 없음 | **templates/user/** 디렉토늬 (doc_types + templates 분늬) | +| 신규 API | — | `/api/doc-types` CRUD, `/api/templates` CRUD, `/api/doc-types/analyze-stream` | +| app.py | 354쀄 | 682쀄 (+328) | +| Python 전첎 | 11,500쀄 | 18,917쀄 (+7,417) | + +--- + +## 🗺 상태 및 로드맵 (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 v5.2, content_order êž°ë°˜ 조늜, 독늜 저장 (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 7**: UI 고도화 — 작성 방식·묞서 유형·템플늿 ꎀ늬 UI (🔧 Ʞ볞 구현) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v8.git +cd geulbeot-v8 + +# 가상환겜 +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_8th/ +├── app.py # Flask 웹 서버 — API 띌우팅 (682쀄) +├── api_config.py # .env 환겜변수 로더 +│ +├── domain/ # ★ v8 신규 — 도메읞 지식 +│ └── hwpx/ +│ ├── hwpx_domain_guide.md # HWPX 명섞서 (§1~§11) +│ └── hwpx_utils.py # 닚위 변환 (hwpunit→mm, charsize→pt) +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출, JSON/HTML 추출 +│ ├── briefing/ # Ʞ획서 처늬 +│ ├── report/ # 볎고서 처늬 +│ ├── template/ # 템플늿 Ʞ볞 ꎀ늬 +│ │ +│ ├── doc_type_analyzer.py # ★ v8 — 묞서 유형 AI 분석 (맥띜·구조) +│ ├── doc_template_analyzer.py # ★ v8 — HWPX → 12종 도구 추출 였쌀슀튞레읎터 +│ ├── semantic_mapper.py # ★ v8 — 요소 의믞 판별 (헀더/푾터/제목/데읎터표) +│ ├── style_generator.py # ★ v8 — 추출값 → CSS 큎래슀 생성 +│ ├── content_analyzer.py # ★ v8 — placeholder 의믞·유형·작성 팹턮 +│ ├── template_manager.py # ★ v8 — 템플늿 CRUD + template.html 조늜 +│ ├── custom_doc_type.py # ★ v8 — 사용자 정의 유형 묞서 생성 +│ │ +│ └── tools/ # ★ v8 — HWPX 추출 도구 12종 +│ ├── page_setup.py # §7 용지/여백 +│ ├── font.py # §3 Ꞁꌎ +│ ├── char_style.py # §4 Ꞁ자 몚양 (charPr 28개) +│ ├── para_style.py # §5 묞닚 몚양 (paraPr 23개) +│ ├── border_fill.py # §2 테두늬/배겜 +│ ├── table.py # §6 표 (병합·너비·셀) +│ ├── header_footer.py # §8 뚞늬말/ꌬ늬말 +│ ├── section.py # §9 구역 정의 +│ ├── style_def.py # 슀타음 정의 +│ ├── numbering.py # 번혞맀ꞰꞰ/Ꞁ뚞늬표 +│ ├── image.py # 읎믞지/귞늬Ʞ 객첎 +│ └── content_order.py # 볞묞 윘텐잠 순서 +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ ├── style_analyzer.py # HTML 요소 역할 분류 +│ ├── hwpx_generator.py # HWPX 파음 직접 생성 +│ ├── hwp_style_mapping.py # 역할 → HWP 슀타음 맀핑 +│ ├── hwpx_style_injector.py # HWPX 컀슀텀 슀타음 죌입 +│ ├── hwpx_table_injector.py # HWPX 표 ì—Ž 너비 정밀 수정 +│ ├── html_to_hwp.py # 볎고서 → HWP 변환 +│ └── html_to_hwp_briefing.py # Ʞ획서 → HWP 변환 +│ +├── templates/ # 묞서 유형 + UI +│ ├── default/doc_types/ # Ʞ볞 유형 섀정 +│ │ ├── briefing/config.json # Ʞ획서 +│ │ ├── report/config.json # 볎고서 +│ │ └── presentation/config.json # 발표자료 +│ ├── user/ # ★ v8 — 사용자 등록 데읎터 +│ │ ├── doc_types/{type_id}/ # config.json + content_prompt.json +│ │ └── templates/{tpl_id}/ # meta.json + style.json + semantic_map.json + template.html +│ ├── hwp_guide.md +│ ├── hwp_html_defaults.json +│ └── index.html # 메읞 UI +│ +├── static/ +│ ├── js/editor.js # 웹 WYSIWYG 펞집Ʞ +│ └── css/editor.css # 펞집Ʞ 슀타음 +│ +├── .env / .env.sample +├── .gitignore +├── requirements.txt +├── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 발표자료: config.json만 졎재, 싀제 생성 믞구현 +- 사용자 유형 생성: template.html êž°ë°˜ 채움만 (AI 찜작 아닌 정늬·재구성) +- 레거시 잔졎: prompts/ 디렉토늬, templates/hwp_guide.html → .md 전환 쀑 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 18,917 (+7,417) | +| 프론튞엔드 (JS + CSS + HTML) | 5,269 (+365) | +| **합계** | **~24,200** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| v4 | 윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ | +| v5 | HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환 | +| v6 | HWPX 템플늿 분석·저장·ꎀ늬 | +| v7 | UI 고도화 — 작성 방식·묞서 유형·템플늿 ꎀ늬 UI | +| **v8** | **묞서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플늿 고도화** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_8th/api_config.py b/03. Code/geulbeot_8th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_8th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_8th/app.py b/03. Code/geulbeot_8th/app.py new file mode 100644 index 0000000..0ff38e4 --- /dev/null +++ b/03. Code/geulbeot_8th/app.py @@ -0,0 +1,683 @@ +# -*- 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_manager import TemplateManager +from pathlib import Path + +# 묞서 유형별 프로섞서 +from handlers.template import TemplateProcessor +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor +from handlers.custom_doc_type import CustomDocTypeProcessor +from handlers.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') + +# 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/', 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/') +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_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/', 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/', 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/', 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//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//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) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/__init__.py b/03. Code/geulbeot_8th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_8th/converters/html_to_hwp.py b/03. Code/geulbeot_8th/converters/html_to_hwp.py new file mode 100644 index 0000000..d0a9afa --- /dev/null +++ b/03. Code/geulbeot_8th/converters/html_to_hwp.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 컀슀텀 슀타음 죌입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 슀타음 죌입 완료") + + except Exception as e: + print(f" [겜고] 슀타음 죌입 싀팚: {e}") + import traceback + traceback.print_exc() + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_8th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/hwp_style_mapping.py b/03. Code/geulbeot_8th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/hwpx_generator.py b/03. Code/geulbeot_8th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_8th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
                            +

                            걎섀·토목 잡량 DX 싀묎지칚

                            +

                            드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

                            +

                            2024년 1월

                            +
                            + +

                            1. 개요

                            +

                            볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

                            + +

                            1.1 배겜

                            +

                            최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

                            + +

                            1.1.1 Ʞ술 동향

                            +

                            1) 드론 잡량의 발전

                            +

                            드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

                            + +

                            (1) RTK 드론

                            +

                            싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

                            + +
                              +
                            • 고정밀 GPS 수신Ʞ 낎장
                            • +
                            • 섌티믞터 닚위 정확도
                            • +
                            + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/hwpx_style_injector.py b/03. Code/geulbeot_8th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/hwpx_table_injector.py b/03. Code/geulbeot_8th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_8th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/__init__.py b/03. Code/geulbeot_8th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_8th/converters/pipeline/router.py b/03. Code/geulbeot_8th/converters/pipeline/router.py new file mode 100644 index 0000000..9a396cc --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/router.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 읎믞지 겜로륌 서버 겜로로 변환 + - assets/xxx.png → /assets/xxx.png (Flask 서빙용) + - 절대 겜로나 URL은 귞대로 유지 + """ + + def replace_src(match): + original_path = match.group(1) + + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')): + return match.group(0) + + # assets/로 시작하멎 /assets/로 변환 (Flask 서빙) + if original_path.startswith('assets/'): + return f'src="/{original_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + +def inject_template_css(html_content: str, template_css: str) -> str: + """ + HTML에 템플늿 CSS 죌입 + - 태귞 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태귞 뒀에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없윌멎 ë§š 앞에 추가 + else: + return f'\n{html_content}' + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + # ⭐ 템플늿 CSS 죌입 + template_css = options.get('template_css') + if template_css and result.get('success') and result.get('html'): + result['html'] = inject_template_css(result['html'], template_css) + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_8th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..d15f2dc --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\in" + OUTPUT_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_8th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..9e9554f --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_8th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..29a5547 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_8th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_8th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..b1309cf --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_8th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..0525082 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_8th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_8th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..4a3cb3e --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step7_index.py b/03. Code/geulbeot_8th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..4f40baf --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
                            구분번혞제목킀워드
                            대목찚{num}{title}
                            쀑목찚{num}{title}
                            소목찚{num}{title}{kw}
                            + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
                            + + +
                            +

                            {html_escape(report_title)}

                            +
                            +
                            + +
                            +
                            +
                            확정 목찚 표 형태 정늬볞
                            +
                            +
                            목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
                            + +
                            +
                            목찚
                            + {table_html} +
                            +
                            + +
                            + + + +
                            +
                            + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_8th/converters/pipeline/step8_content.py b/03. Code/geulbeot_8th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..4330251 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/pipeline/step9_html.py b/03. Code/geulbeot_8th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..9e20780 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
                            변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
                              '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
                              ') + lines.append(f'
                            • {item.number}. {item.title}
                            • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
                            • {item.number} {item.title}
                            • ') + elif item.level == 3: + lines.append(f'
                            • {item.number} {item.title}
                            • ') + + if current_l1 is not None: + lines.append('
                              ') # 마지막 귞룹 ë‹«êž° + + lines.append('
                            ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
                            처늬 + cell = cell.replace('
                            ', '
                            ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
                            {cell}
                            {cell}
                            ') + + # 캡션 추가 + if caption: + html_lines.append(f'
                            {caption}
                            ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
                            변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
                            + {caption} +
                            {caption_text}
                            +
                            ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
                              ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                            • {content}
                            • ') + elif ol_match: + if not in_list: + html_lines.append('
                                ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                              1. {content}
                              2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                                변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                                {text}

                                ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                                {title}

                                ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                                {title}

                                ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                                {h3_match.group(1)}

                                ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                                {h4_match.group(1)}

                                ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                                +
                                {box_cover}
                                +
                                {box_toc}
                                +
                                {box_summary}
                                +
                                {box_content}
                                +
                                + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                                {main_title}

                                +

                                {sub_title}

                                +

                                {cover_date}

                                + {f'

                                {cover_author}

                                ' if cover_author else ''} + {f'

                                {cover_dept}

                                ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                                요앜

                                \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_8th/converters/style_analyzer.py b/03. Code/geulbeot_8th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_8th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                                + +
                                +

                                1 DX 개요와 Ʞ볞 개념

                                +

                                1.1 잡량 DX 프레임

                                +

                                1.1.1 잡량 DX 발전 닚계

                                +

                                1) Digitization 정의

                                +

                                볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                                +

                                (1) 닚계별 정의 및 진화

                                +

                                잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                                +
                                + +
                                + +
                                + +
                                +

                                ① 첫 번짞 항목

                                + + + + +
                                표 1. 데읎터 비교
                                구분낎용
                                항목1섀명1
                                +
                                + +
                                + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_8th/domain/__init__.py b/03. Code/geulbeot_8th/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_8th/domain/hwpx/__init__.py b/03. Code/geulbeot_8th/domain/hwpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_8th/domain/hwpx/hwpx_domain_guide.md b/03. Code/geulbeot_8th/domain/hwpx/hwpx_domain_guide.md new file mode 100644 index 0000000..da48039 --- /dev/null +++ b/03. Code/geulbeot_8th/domain/hwpx/hwpx_domain_guide.md @@ -0,0 +1,769 @@ +# HWP/HWPX ↔ HTML/CSS 도메읞 가읎드 + +> **목적**: HWPX에서 묞서 유형·슀타음·템플늿을 추출하거나, HTML → HWPX → HWP 변환 시 +> 하드윔딩 없읎 읎 가읎드륌 찞조하여 정확한 맀핑을 수행한닀. +> **출처**: 한Ꞁ곌컎퓚터 공식 "Ꞁ 묞서 파음 구조 5.0" (revision 1.3, 2018-11-08) +> **범위**: HWP 5.0 바읎너늬 슀펙의 개념 첎계 + HWPX XML 태귞 + HTML/CSS 맀핑 + +--- + +## 0. 묞서 형식 ꎀ계 + +``` +HWP (바읎너늬) HWPX (XML) HTML/CSS +───────────────── ───────────────────── ───────────────── +Compound File ZIP Archive 닚음 HTML 파음 +├─ FileHeader ├─ META-INF/ ├─ +├─ DocInfo │ └─ manifest.xml │ ├─ +│ (Ꞁꌎ, 슀타음, ├─ Contents/ │ └─ + + +
                                + +
                                +

                                {{title}}

                                +
                                +
                                +
                                +
                                +
                                {{lead.text}} - 킀워드 강조
                                +
                                + +
                                +
                                {{conclusion.label}}
                                +
                                {{conclusion.text}}
                                +
                                +
                                +
                                - 1 -
                                +
                                + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                                +
                                {{section.title}}
                                +
                                  +
                                • {{item.keyword}}: {{item.text}} {{highlight}}
                                • +
                                +
                                +``` + +### table → data-table +```html +
                                +
                                {{section.title}}
                                + + + + + + + + + + + + + +
                                {{col1}}{{col2}}
                                {{text}}{{text}}
                                +
                                +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                                +
                                {{section.title}}
                                +
                                +
                                +
                                {{item.title}}
                                +

                                {{item.text}} {{highlight}}

                                +
                                +
                                +
                                +``` + +### two-column → two-col +```html +
                                +
                                {{section.title}}
                                +
                                +
                                +
                                {{item.title}}
                                +

                                {{item.text}} {{highlight}}

                                +
                                +
                                +
                                +``` + +### process → process-container +```html +
                                +
                                {{section.title}}
                                +
                                +
                                +
                                {{step.number}}
                                +
                                {{step.title}}: {{step.text}}
                                +
                                +
                                ▌
                                + +
                                +
                                +``` + +### qa → qa-grid +```html +
                                +
                                {{section.title}}
                                +
                                +
                                + Q. {{question}}
                                + A. {{answer}} +
                                +
                                +
                                +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                                [첚부] 핎당 낎용에 맞는 제목

                                ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                                [첚부] 핎당 페읎지 낎용에 맞는 제목

                                ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/common.py b/03. Code/geulbeot_8th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/content_analyzer.py b/03. Code/geulbeot_8th/handlers/content_analyzer.py new file mode 100644 index 0000000..47ae5ed --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/content_analyzer.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +""" +Content Analyzer (Phase 3 — Layer A) +- template_info + semantic_map → content_prompt.json +- 각 placeholder의 의믞/유형/예시값/작성 팹턮 추출 +- Phase 5에서 AI가 새 묞서 생성 시 "레시플"로 ì°žì¡° + +★ 원칙: 몚든 분류는 윔드 100% (AI 없음) + purpose_hint / audience_hint / tone_hint는 빈 묞자엎로 낚김 + → 추후 AI enrichment 닚계에서 채욞 수 있도록 섀계 +""" + +import re + + +def generate(template_info: dict, semantic_map: dict, + parsed: dict = None) -> dict: + """ + content_prompt.json 생성 + + Args: + template_info: doc_template_analyzer 추출 결곌 + semantic_map: semantic_mapper 분류 결곌 + parsed: HWPX 파싱 원볞 (선택) + + Returns: + content_prompt.json 구조 + """ + placeholders = {} + table_guide = {} + + # ① 묞서 Ʞ볞 정볎 + document = _analyze_document(template_info) + + # ② 헀더 placeholders + _analyze_header(template_info, placeholders) + + # ③ 푾터 placeholders + _analyze_footer(template_info, placeholders) + + # ④ 제목 placeholder + _analyze_title(template_info, semantic_map, placeholders) + + # â‘€ 섹션 placeholders + _analyze_sections(semantic_map, placeholders, template_info) + + # â‘€-b content_order êž°ë°˜ 묞닚/읎믞지 placeholders + _analyze_content_order(template_info, semantic_map, placeholders) + + # ⑥ 표 가읎드 + placeholders + _analyze_tables(template_info, semantic_map, + placeholders, table_guide) + + # ⑩ 작성 팹턮 + writing_guide = _analyze_writing_patterns(template_info, semantic_map) + + return { + "version": "1.0", + "document": document, + "placeholders": placeholders, + "table_guide": table_guide, + "writing_guide": writing_guide + } + + +# ================================================================ +# 묞서 Ʞ볞 정볎 +# ================================================================ + +def _analyze_document(template_info: dict) -> dict: + """묞서 레벚 정볎 추출""" + page = template_info.get("page", {}) + paper = page.get("paper", {}) + + return { + "paper": paper.get("name", "A4"), + "layout": "landscape" if paper.get("landscape") else "portrait", + "margins": page.get("margins", {}), + "purpose_hint": "", # AI enrichment 예앜 + "audience_hint": "", # AI enrichment 예앜 + "tone_hint": "" # AI enrichment 예앜 + } + + +# ================================================================ +# 텍슀튞 유형 분류 (윔드 100%, AI 없음) +# ================================================================ + +def _classify_text(text: str) -> dict: + """텍슀튞 팚턎윌로 윘텐잠 유형 분류""" + text = text.strip() + if not text: + return {"type": "empty", "pattern": "빈 셀"} + + # 날짜: "2025. 1. 30(ꞈ)", "2025-01-30", "2025.01.30" + if re.match(r'\d{4}[\.\-/]\s*\d{1,2}[\.\-/]\s*\d{1,2}', text): + return {"type": "date", "pattern": "날짜 (YYYY. M. D)"} + + # ★ 직꞉+읎늄 (부서볎닀 뚌저!) + positions = [ + '사원', '대늬', '곌장', '찚장', '부장', '읎사', '상묎', '전묎', + '연구원', '선임연구원', '책임연구원', '수석연구원', + '죌임', '계장', '팀장', '싀장', '부서장', '섌터장' + ] + for pos in positions: + if pos in text: + return {"type": "author", "pattern": f"읎늄 + 직꞉({pos})"} + + # 부서 (직꞉ 아닌 것만 여Ʞ로) + if re.search(r'(ì‹€|부|êµ­|곌|원|처|섌터|볞부)$', text) and len(text) <= 12: + return {"type": "department", "pattern": "조직명"} + + # 팀 + if re.search(r'팀$', text) and len(text) <= 10: + return {"type": "team", "pattern": "팀명"} + + # 페읎지 ì°žì¡°: "1p", "2p" + if re.match(r'\d+p$', text): + return {"type": "page_ref", "pattern": "페읎지 ì°žì¡°"} + + # 묞서 제목: ~계획(안), ~볎고서, ~제안서 등 + if re.search(r'(계획|볎고서|제안서|Ʞ획서|결곌|방안|현황|분석)' + r'\s*(\(안\))?\s*$', text): + return {"type": "doc_title", "pattern": "묞서 제목"} + + # 슬로걎/비전 (êžžê³  추상적 킀워드 포핚) + if len(text) > 10 and any(k in text for k in + ['핚께', '섞상', '믞래', '가치', '만듀얎']): + return {"type": "slogan", "pattern": "회사 슬로걎/비전"} + + # Ʞ볞 + return {"type": "text", "pattern": "자유 텍슀튞"} + + +# ================================================================ +# 헀더 분석 +# ================================================================ + +def _analyze_header(template_info: dict, placeholders: dict): + """헀더 영역 placeholder 분석""" + header = template_info.get("header", {}) + if not header or not header.get("exists"): + return + + if header.get("type") == "table" and header.get("table"): + _analyze_table_area(header["table"], "HEADER", "header", + placeholders) + else: + texts = header.get("texts", []) + for i in range(max(len(texts), 1)): + ph = f"HEADER_TEXT_{i+1}" + example = texts[i] if i < len(texts) else "" + info = _classify_text(example) + info["example"] = example.strip() + info["location"] = "header" + placeholders[ph] = info + + +# ================================================================ +# 푾터 분석 +# ================================================================ + +def _analyze_footer(template_info: dict, placeholders: dict): + """푾터 영역 placeholder 분석""" + footer = template_info.get("footer", {}) + if not footer or not footer.get("exists"): + return + + if footer.get("type") == "table" and footer.get("table"): + _analyze_table_area(footer["table"], "FOOTER", "footer", + placeholders) + else: + placeholders["PAGE_NUMBER"] = { + "type": "page_number", + "pattern": "페읎지 번혞", + "example": "1", + "location": "footer" + } + + +# ================================================================ +# 헀더/푾터 공통: 표 형태 영역 분석 +# ================================================================ + +def _analyze_table_area(tbl: dict, prefix: str, location: str, + placeholders: dict): + """표 형태의 헀더/푾터 → placeholder 맀핑 + + Args: + tbl: header["table"] 또는 footer["table"] + prefix: "HEADER" 또는 "FOOTER" + location: "header" 또는 "footer" + placeholders: 결곌 dict (in-place 수정) + """ + rows = tbl.get("rows", []) + + for r_idx, row in enumerate(rows): + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + + if len(lines) > 1: + for l_idx, line_text in enumerate(lines): + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}_LINE_{l_idx+1}" + info = _classify_text(line_text) + info["example"] = line_text.strip() + info["location"] = location + placeholders[ph] = info + elif lines: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(lines[0]) + info["example"] = lines[0].strip() + info["location"] = location + placeholders[ph] = info + else: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + placeholders[ph] = { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": location + } + + +# ================================================================ +# 제목 분석 +# ================================================================ + +def _analyze_title(template_info: dict, semantic_map: dict, + placeholders: dict): + """제목 랔록 placeholder 분석 + + ★ v1.1: template_manager._build_title_block_html()곌 동음한 + TITLE_R{r}_C{c} 명명 규칙 사용 (범용 맀핑) + """ + title_idx = semantic_map.get("title_table") + if title_idx is None: + return + + tables = template_info.get("tables", []) + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if not title_tbl: + return + + # 각 셀별로 placeholder 생성 (template곌 동음한 읎늄) + for r_idx, row in enumerate(title_tbl.get("rows", [])): + for c_idx, cell in enumerate(row): + cell_text = cell.get("text", "").strip() + if not cell_text: + continue # 빈 셀은 template에서도 placeholder 없음 + + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(cell_text) + if "title" not in info["type"] and "doc_title" not in info["type"]: + # 제목표 안의 텍슀튞가 doc_title읎 아닐 수도 있음 (부제 등) + # 가장 ꞎ 텍슀튞만 doc_title로 분류 + pass + info["example"] = cell_text + info["location"] = "title_block" + placeholders[ph_name] = info + + # 가장 ꞎ 텍슀튞륌 가진 셀을 doc_title로 마킹 + longest_ph = None + longest_len = 0 + for ph_key in list(placeholders.keys()): + if ph_key.startswith("TITLE_R"): + ex = placeholders[ph_key].get("example", "") + if len(ex) > longest_len: + longest_len = len(ex) + longest_ph = ph_key + if longest_ph: + placeholders[longest_ph]["type"] = "doc_title" + placeholders[longest_ph]["pattern"] = "묞서 제목" + + +# ================================================================ +# 섹션 분석 +# ================================================================ + +def _analyze_sections(semantic_map: dict, placeholders: dict, + template_info: dict = None): + """섹션 placeholder 분석. + + content_order에 묞닚읎 있윌멎 SECTION_n_CONTENT는 생략 + (개별 PARA_n읎 볞묞 역할을 대신핚). + """ + sections = semantic_map.get("sections", []) + + # content_order에 묞닚읎 있윌멎 개별 PARA_n읎 볞묞 닎당 → CONTENT 불필요 + has_co_paragraphs = False + if template_info: + co = template_info.get("content_order", []) + has_co_paragraphs = any(c['type'] == 'paragraph' for c in co) if co else False + + if not sections: + placeholders["SECTION_1_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": "", "location": "body" + } + if not has_co_paragraphs: + placeholders["SECTION_1_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + return + + for i, sec in enumerate(sections): + s_num = i + 1 + title_text = sec if isinstance(sec, str) else sec.get("title", "") + + placeholders[f"SECTION_{s_num}_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": title_text, "location": "body" + } + if not has_co_paragraphs: + placeholders[f"SECTION_{s_num}_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + +# ================================================================ +# content_order êž°ë°˜ 묞닚/읎믞지 분석 (v5.2+) +# ================================================================ + +def _analyze_content_order(template_info: dict, semantic_map: dict, + placeholders: dict): + """content_order의 paragraph/image → PARA_n, IMAGE_n placeholder 생성. + + content_order가 없거나 묞닚읎 없윌멎 아묎것도 안 핹 (legacy 혾환). + """ + content_order = template_info.get("content_order") + if not content_order: + return + if not any(c['type'] == 'paragraph' for c in content_order): + return + + # 섹션 제목 팹턮 (template_manager와 동음) + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + para_num = 0 + img_num = 0 + section_num = 0 + + for item in content_order: + itype = item['type'] + + if itype == 'empty': + continue + + # ── 표: _analyze_tables에서 처늬 → 걎너뛰Ʞ ── + if itype == 'table': + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + placeholders[f"IMAGE_{img_num}"] = { + "type": "image", + "pattern": "읎믞지", + "example_ref": item.get("binaryItemIDRef", ""), + "location": "body" + } + caption = item.get("text", "") + if caption: + placeholders[f"IMAGE_{img_num}_CAPTION"] = { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": caption, + "location": "body" + } + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + + # 섹션 제목 → SECTION_n_TITLE (읎믞 _analyze_sections에서 등록됐을 수 있음) + if any(p.match(text) for p in sec_patterns): + section_num += 1 + ph = f"SECTION_{section_num}_TITLE" + if ph not in placeholders: + placeholders[ph] = { + "type": "section_title", + "pattern": "섹션 제목", + "example": text, + "location": "body" + } + continue + + # 음반 묞닚 + para_num += 1 + runs = item.get('runs', []) + + if len(runs) > 1: + # 닀쀑 run → 각 run별 placeholder + for r_idx, run in enumerate(runs): + ph = f"PARA_{para_num}_RUN_{r_idx+1}" + run_text = run.get("text", "") + info = _classify_text(run_text) + info["example"] = run_text[:100] if len(run_text) > 100 else run_text + info["location"] = "body" + info["run_index"] = r_idx + 1 + placeholders[ph] = info + else: + ph = f"PARA_{para_num}" + info = _classify_text(text) + info["example"] = text[:100] if len(text) > 100 else text + info["location"] = "body" + placeholders[ph] = info + + +# ================================================================ +# 표 분석 → placeholder + 표 가읎드 +# ================================================================ + +def _analyze_tables(template_info: dict, semantic_map: dict, + placeholders: dict, table_guide: dict): + """볞묞 데읎터 표 → placeholder + table_guide""" + tables = template_info.get("tables", []) + body_indices = semantic_map.get("body_tables", []) + table_roles = semantic_map.get("table_roles", {}) + + for tbl_num_0, tbl_idx in enumerate(body_indices): + tbl_num = tbl_num_0 + 1 + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + role_info = table_roles.get(tbl_idx, table_roles.get(str(tbl_idx), {})) + col_headers = role_info.get("col_headers", []) + col_cnt = len(col_headers) if col_headers else tbl.get("colCnt", 0) + + # ── 헀더 placeholder ── + for c_idx, h_text in enumerate(col_headers): + ph = f"TABLE_{tbl_num}_H_C{c_idx+1}" + placeholders[ph] = { + "type": "table_header", "pattern": "표 ì—Ž 제목", + "example": h_text, "location": f"table_{tbl_num}" + } + + # ── BODY placeholder ── + placeholders[f"TABLE_{tbl_num}_BODY"] = { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML 반복)", + "example": "", + "location": f"table_{tbl_num}" + } + + # ── 표 가읎드 ── + table_guide[str(tbl_num)] = { + "col_headers": col_headers, + "col_count": col_cnt, + "row_count": tbl.get("rowCnt", 0), + "merge_pattern": _detect_merge_pattern(tbl), + "bullet_chars": _detect_bullet_chars(tbl), + "example_rows": _extract_example_rows(tbl, role_info), + "col_types": _classify_columns(col_headers), + "row_bf_pattern": _extract_row_bf_pattern(tbl, role_info), + } + + +def _detect_merge_pattern(tbl: dict) -> dict: + """셀 병합 팹턮 감지""" + pattern = {} + for row in tbl.get("rows", []): + for cell in row: + col = cell.get("colAddr", 0) + if cell.get("rowSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "row_group") + if cell.get("colSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "col_span") + return pattern + + +def _detect_bullet_chars(tbl: dict) -> list: + """표 셀 텍슀튞에서 불늿 묞자 감지""" + bullets = set() + pats = [ + (r'^-\s', '- '), (r'^·\s', '· '), (r'^•\s', '• '), + (r'^▾\s', '▾ '), (r'^▶\s', '▶ '), (r'^※\s', '※ '), + (r'^◈\s', '◈ '), (r'^○\s', '○ '), (r'^●\s', '● '), + ] + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + for pat, char in pats: + if re.match(pat, line.strip()): + bullets.add(char) + return sorted(bullets) + + +def _extract_example_rows(tbl: dict, role_info: dict) -> list: + """데읎터 행에서 예시 최대 3행 추출""" + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + examples = [] + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + row_data = [] + for cell in row: + text = cell.get("text", "").strip() + if len(text) > 80: + text = text[:77] + "..." + row_data.append(text) + examples.append(row_data) + if len(examples) >= 3: + break + return examples + + +def _classify_columns(col_headers: list) -> list: + """ì—Ž 헀더 킀워드로 용도 추론""" + type_map = { + "category": ['구분', '분류', '항목', '칎테고늬'], + "content": ['낎용', '섀명', '상섞', '섞부낎용'], + "note": ['비고', 'ì°žê³ ', 'Ʞ타', '메몚'], + "date": ['날짜', '음자', '음시', 'êž°ê°„'], + "person": ['닎당', '닎당자', '작성자', '책임'], + "number": ['수량', 'ꞈ액', '닚가', '합계'], + } + result = [] + for c_idx, header in enumerate(col_headers): + h = header.strip() + col_type = "text" + for t, keywords in type_map.items(): + if h in keywords: + col_type = t + break + result.append({"col": c_idx, "type": col_type, "header": h}) + return result + +def _extract_row_bf_pattern(tbl: dict, role_info: dict) -> list: + """첫 데읎터행의 셀별 borderFillIDRef → 엎별 bf class 팹턮. + + AI가 TABLE_BODY 생성 시 class="bf-{id}" 적용하도록 안낎. + 예: [{"col": 0, "bf_class": "bf-12"}, {"col": 1, "bf_class": "bf-8"}, ...] + """ + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + # 첫 데읎터행 ì°Ÿêž° + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + pattern = [] + for cell in row: + bf_id = cell.get("borderFillIDRef") + pattern.append({ + "col": cell.get("colAddr", len(pattern)), + "bf_class": f"bf-{bf_id}" if bf_id else "", + "colSpan": cell.get("colSpan", 1), + "rowSpan": cell.get("rowSpan", 1), + }) + return pattern + + return [] +# ================================================================ +# 작성 팹턮 분석 +# ================================================================ + +def _analyze_writing_patterns(template_info: dict, + semantic_map: dict) -> dict: + """묞서 전첎의 작성 팹턮 분석""" + result = { + "bullet_styles": [], + "numbering_patterns": [], + "avg_line_length": 0, + "font_primary": "", + "font_size_body": "" + } + + # ── 불늿 수집 (몚든 표 텍슀튞) ── + all_bullets = set() + tables = template_info.get("tables", []) + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if re.match(r'^[-·•▞▶※◈○●]\s', line.strip()): + all_bullets.add(line.strip()[0] + " ") + + # ── numbering tools 데읎터 ── + numbering = template_info.get("numbering", {}) + for num in numbering.get("numberings", []): + levels = num.get("levels", []) + patterns = [lv.get("pattern", "") for lv in levels[:3]] + if patterns: + result["numbering_patterns"].append(patterns) + + for b in numbering.get("bullets", []): + char = b.get("char", "") + if char: + all_bullets.add(char + " ") + + result["bullet_styles"] = sorted(all_bullets) + +# ── 평균 띌읞 Ꞟ읎 ── + lengths = [] + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if line.strip(): + lengths.append(len(line.strip())) + + # content_order 묞닚 텍슀튞도 포핚 + content_order = template_info.get("content_order", []) + for item in content_order: + if item['type'] == 'paragraph': + text = item.get('text', '').strip() + if text: + lengths.append(len(text)) + # 불늿 감지도 추가 + if re.match(r'^[-·•▞▶※◈○●]\s', text): + all_bullets.add(text[0] + " ") + + if lengths: + result["avg_line_length"] = round(sum(lengths) / len(lengths)) + + # ── 죌요 폰튾 ── + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + result["font_primary"] = hangul[0].get("face", "") + + # ── 볞묞 Ꞁ자 크Ʞ (char_styles id=0 Ʞ볞) ── + char_styles = template_info.get("char_styles", []) + if char_styles: + result["font_size_body"] = f"{char_styles[0].get('height_pt', 10)}pt" + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/custom_doc_type.py b/03. Code/geulbeot_8th/handlers/custom_doc_type.py new file mode 100644 index 0000000..c07daf1 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/custom_doc_type.py @@ -0,0 +1,555 @@ +# -*- coding: utf-8 -*- +""" +사용자 정의 묞서 유형 프로섞서 (v2.1 - 템플늿 êž°ë°˜) +- template.html 로드 +- config.json의 구조/가읎드 활용 +- 사용자 입력 낎용을 템플늿에 정늬하여 채움 +- 찜작 X, 정늬/재구성 O + +★ v2.1 변겜사항: +- 한Ꞁ 포핚 placeholder 지원 (TABLE_1_H_구분 등) +- TABLE_*_BODY / TABLE_*_H_* placeholder 구분 처늬 +- 개조식 항목
                                  래핑 +- 페읎지 분량 제한 프롬프튞 강화 +- 헀더/푾터 닀쀑행 placeholder 섀명 추가 +""" + +import json +import re +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from .template_manager import TemplateManager +from pathlib import Path +from .common import call_claude, extract_html + + +# ★ 한Ꞁ 포핚 placeholder 정규식 (영묞 + 숫자 + 얞더슀윔얎 + 한Ꞁ) +PH_PATTERN = re.compile(r'\{\{([A-Za-z0-9_\uAC00-\uD7AF]+)\}\}') + + +class CustomDocTypeProcessor: + """사용자 정의 묞서 유형 처늬Ʞ (템플늿 êž°ë°˜)""" + + def __init__(self): + self.doc_types_user = Path('templates/user/doc_types') + self.template_manager = TemplateManager() + + def load_config(self, doc_type_id: str) -> dict: + """config.json 로드""" + config_path = self.doc_types_user / doc_type_id / 'config.json' + if not config_path.exists(): + raise FileNotFoundError(f"묞서 유형을 찟을 수 없습니닀: {doc_type_id}") + + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + + def load_content_prompt(self, doc_type_id: str, template_id: str = None) -> dict: + """content_prompt.json 로드 (doc_type 우선 → template fallback)""" + # ① doc_type 폮더 + path = self.doc_types_user / doc_type_id / 'content_prompt.json' + if path.exists(): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + # ② template 폮더 fallback + if template_id: + tpl_path = Path('templates/user/templates') / template_id / 'content_prompt.json' + if tpl_path.exists(): + with open(tpl_path, 'r', encoding='utf-8') as f: + return json.load(f) + + return {} + + def load_template(self, doc_type_id: str) -> str: + """template.html 로드 — template_manager 겜유 (분늬 구조)""" + # ① config에서 template_id 확읞 + config = self.load_config(doc_type_id) + tpl_id = config.get("template_id") + + if tpl_id: + # ★ 새 구조: template_manager에서 로드 + tpl_data = self.template_manager.load_template(tpl_id) + if "html" in tpl_data: + return tpl_data["html"] + + # ★ 하위 혾환: 레거시 방식 (같은 폎더의 template.html) + template_path = self.doc_types_user / doc_type_id / 'template.html' + if template_path.exists(): + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + + return None + + def generate(self, content: str, doc_type_id: str, options: dict = None, + image_data: dict = None) -> dict: + """묞서 생성 - 템플늿 + 사용자 입력 + + Args: + content: 사용자 입력 텍슀튞 + doc_type_id: 묞서 유형 ID + options: 추가 옵션 (instruction 등) + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + None읎멎 템플늿 폎더에서 자동 로드 시도 + """ + try: + config = self.load_config(doc_type_id) + template = self.load_template(doc_type_id) + + if template: + # 읎믞지 데읎터 쀀비 + if image_data is None: + image_data = self._load_image_data(config) + result = self._generate_with_template( + content, config, template, options, image_data + ) + else: + result = self._generate_with_guide(content, config, options) + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _generate_with_template(self, content: str, config: dict, + template: str, options: dict, + image_data: dict = None) -> dict: + """템플늿 êž°ë°˜ 생성 — content_prompt.json 활용""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + instruction = options.get('instruction', '') if options else '' + + # ★ content_prompt 로드 + doc_type_id = config.get('id', '') + template_id = config.get('template_id', '') + cp = self.load_content_prompt(doc_type_id, template_id) + + placeholders_info = cp.get('placeholders', {}) + table_guide = cp.get('table_guide', {}) + writing_guide = cp.get('writing_guide', {}) + doc_info = cp.get('document', {}) + + # ★ placeholder 가읎드 생성 (type/pattern/example 포핚) + ph_guide_lines = [] + for ph_key, ph_info in placeholders_info.items(): + ph_type = ph_info.get('type', 'text') + pattern = ph_info.get('pattern', '') + example = ph_info.get('example', '') + location = ph_info.get('location', '') + + line = f" {ph_key}:" + line += f"\n type: {ph_type}" + line += f"\n pattern: {pattern}" + if example: + line += f"\n example: \"{example}\"" + line += f"\n location: {location}" + ph_guide_lines.append(line) + + ph_guide = "\n".join(ph_guide_lines) if ph_guide_lines else "(no guide available)" + + # ★ 표 가읎드 생성 + tbl_guide_lines = [] + for tbl_num, tbl_info in table_guide.items(): + headers = tbl_info.get('col_headers', []) + col_types = tbl_info.get('col_types', []) + merge = tbl_info.get('merge_pattern', {}) + bullets = tbl_info.get('bullet_chars', []) + examples = tbl_info.get('example_rows', []) + + tbl_guide_lines.append(f"\n### Table {tbl_num}:") + tbl_guide_lines.append(f" Columns: {json.dumps(headers, ensure_ascii=False)}") + if col_types: + for ct in col_types: + tbl_guide_lines.append( + f" Col {ct['col']} '{ct['header']}': {ct['type']}") + if merge: + tbl_guide_lines.append(f" Merge: {json.dumps(merge, ensure_ascii=False)}") + tbl_guide_lines.append( + f" → row_group means: use rowspan to group rows by that column") + if bullets: + tbl_guide_lines.append(f" Bullet chars: {bullets}") + + # ★ row_bf_pattern 추가 + bf_pattern = tbl_info.get('row_bf_pattern', []) + if bf_pattern: + tbl_guide_lines.append(f" Row cell classes (apply to each ):") + for bp in bf_pattern: + col = bp.get('col', '?') + bf_cls = bp.get('bf_class', '') + cs = bp.get('colSpan', 1) + rs = bp.get('rowSpan', 1) + span_info = "" + if cs > 1: span_info += f" colSpan={cs}" + if rs > 1: span_info += f" rowSpan={rs}" + tbl_guide_lines.append( + f' col_{col}: class="{bf_cls}"{span_info}') + + if examples: + tbl_guide_lines.append(f" Example rows:") + for ex in examples[:2]: + tbl_guide_lines.append( + f" {json.dumps(ex, ensure_ascii=False)}") + + tbl_guide = "\n".join(tbl_guide_lines) if tbl_guide_lines else "No table guide" + + # ★ 페읎지 추정 + page_estimate = structure.get('pageEstimate', 1) + + # ★ placeholder í‚€ 목록 (from template) + placeholders = PH_PATTERN.findall(template) + placeholders = list(dict.fromkeys(placeholders)) + + prompt = f"""Fill the template placeholders with reorganized content. + +## Document Definition +{context.get('documentDefinition', 'structured document')} + +## Context +- Type: {context.get('documentType', '')} +- Purpose: {context.get('purpose', '')} +- Audience: {context.get('audience', '')} +- Tone: {context.get('tone', '')} +- Layout: {doc_info.get('layout', 'portrait')} +- Page limit: {page_estimate} page(s). Be CONCISE. + +## Writing Style +- Bullet chars: {writing_guide.get('bullet_styles', ['- ', '· '])} +- Primary font: {writing_guide.get('font_primary', '')} +- Keep lines ~{writing_guide.get('avg_line_length', 25)} chars average + +## Placeholder Guide (type, pattern, example for each) +{ph_guide} + +## Table Structure Guide +{tbl_guide} + +## Input Content +{content[:6000] if content else '(empty)'} + +## Additional Instructions +{instruction if instruction else 'None'} + +## ALL Placeholders to fill (JSON keys): +{json.dumps(placeholders, ensure_ascii=False)} + +## ★ Critical Rules +1. Output ONLY valid JSON — every placeholder above as a key +2. HEADER/FOOTER: use the PATTERN and modify the EXAMPLE for new content + - department → user's department or keep example + - author → user's name or keep example + - date → today's date in same format + - slogan → keep exactly as example +3. TITLE: create title matching doc_title pattern from input content +4. TABLE_*_H_*: plain text column headers (use col_headers from guide) +5. TABLE_*_BODY: HTML rows only (no wrapper) + - Follow merge_pattern: row_group → use rowspan + - Use bullet_chars from guide inside cells + - Match example_rows structure +5b. TABLE_*_BODY \n +6. SECTION_*_CONTENT: use bullet style from writing guide +7. Empty string "" for inapplicable placeholders +8. Do NOT invent content — reorganize input only +9. PARA_*: reorganize input text for each paragraph placeholder + - Keep the meaning, improve clarity and structure + - PARA_n_RUN_m: if a paragraph has multiple runs, fill each run separately +10. IMAGE_*: output exactly "KEEP_ORIGINAL" (image is auto-inserted from source) +11. IMAGE_*_CAPTION: write a concise caption describing the image context +12. Total volume: {page_estimate} page(s) + +Output ONLY valid JSON:""" + + try: + response = call_claude( + "You fill document template placeholders with reorganized content. " + "Output valid JSON only. Respect the template structure exactly.", + prompt, + max_tokens=6000 + ) + + fill_data = self._extract_json(response) + + if not fill_data: + return {'error': 'JSON extraction failed', 'raw': response[:500]} + + html = self._fill_template(template, fill_data, image_data) + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _fill_template(self, template: str, data: dict, + image_data: dict = None) -> str: + """템플늿에 데읎터 채우Ʞ + + Args: + template: HTML 템플늿 + data: AI가 채욎 placeholder → value dict + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + """ + html = template + + # ★ content_prompt에서 IMAGE_n → binaryItemIDRef 맀핑 빌드 + image_ref_map = self._build_image_ref_map(data, image_data) + + for key, value in data.items(): + placeholder = '{{' + key + '}}' + + # ── IMAGE_n: 원볞 읎믞지 삜입 ── + if re.match(r'^IMAGE_\d+$', key): + img_tag = image_ref_map.get(key, '') + html = html.replace(placeholder, img_tag) + continue + + if isinstance(value, str) and value.strip(): + # ★ 개조식 낎용 처늬: · 또는 - 로 시작하는 항목 + lines = value.strip().split('\n') + is_bullet_list = sum( + 1 for l in lines + if l.strip().startswith('·') or l.strip().startswith('-') + ) > len(lines) * 0.5 + + if is_bullet_list and len(lines) > 1: + # ★ v2.2: inline context (

                                  안)에서는

                                    ꞈ지 + # PARA_*, SECTION_*_TITLE, HEADER_*, FOOTER_*, TITLE_*, *_RUN_* + # 읎듀은

                                    또는

                                  , 등의 Ʞ능 덕분에 표가 잘 표시되고 쉜게 슀타음링되며 액섞슀할 수 있습니닀. 불행히도 화멎에 렌더링할 때는 좋지 않습니닀(punk-bands-unstyled.html 에서 띌읎람 ì°žì¡°). + + + +Ʞ볞 람띌우저 슀타음만 사용하멎, 비좁고 읜Ʞ 얎렀우며 지룚핎 볎입니닀. 읎 묞제륌 핎결하렀멎 CSS 륌 사용핎알 합니닀. + +우늬의 표 슀타음링 +표 예제륌 핚께 슀타음링 핮 뎅시닀. + +시작하렀멎, sample markup 의 로컬 사볞을 만듀고 두 읎믞지 (녞읎슈 및 표범가죜) 륌 몚두 닀욎로드한 닀음, 섞 개의 결곌 파음을 로컬 컎퓚터의 작업 디렉토늬에 넣습니닀. + +닀음윌로, style.css 띌는 새 파음을 만듀고 닀륞 파음곌 같은 디렉토늬에 저장하십시였. + + 안에 닀음 HTML 행을 배치하여 CSS 륌 HTML 에 연결하십시였. + +html + +Copy + +간격 및 레읎아웃 +가장 뚌저 핎알할 음은 간격/레읎아웃을 정렬하는 것입니닀 — Ʞ볞 표 슀타음은 너묎 비좁습니닀! 읎렇게 하렀멎, style.css 파음에 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* 간격 */ + +table { + table-layout: fixed; + width: 100%; + border-collapse: collapse; + border: 3px solid purple; +} + +thead th:nth-child(1) { + width: 30%; +} + +thead th:nth-child(2) { + width: 20%; +} + +thead th:nth-child(3) { + width: 15%; +} + +thead th:nth-child(4) { + width: 35%; +} + +th, +td { + padding: 20px; +} +가장 쀑요한 부분은 닀음곌 같습니닀. + +fixed 의 table-layout 값은 음반적윌로 표가 Ʞ볞적윌로 좀 더 예잡 가능하게 작동하므로 표에 섀정하는 것읎 좋습니닀. 음반적윌로, 표의 엎은 낎용의 양에 따띌 크Ʞ가 정핎지는 겜향읎 있윌며, ê·ž 결곌 읎상한 결곌가 발생합니닀. table-layout: fixed 륌 사용하멎 제목의 너비에 따띌 엎의 크Ʞ륌 지정한 닀음 낎용을 적절하게 처늬할 수 있습니닀. 읎것읎 우늬가 thead th:nth-child(n) (:nth-child) 선택자 (" 요소 낎에서 륌 순서대로 선택합니닀") 요소륌 섀정하고 백분윚 너비륌 섀정했습니닀. 전첎 ì—Ž 너비는 제목 너비륌 따륎므로, 표 엎의 크Ʞ륌 정할 수 있습니닀. Chris Coyier 는 읎 Ʞ술에 대핮 고정 표 레읎아웃 에서 자섞히 섀명합니닀. + +읎륌 width 와 100% 결합했습니닀. 슉, 표에 넣은 container 륌 표에 채우고 반응성읎 뛰얎납니닀 (아직 더 많은 작업읎 필요하지만 좁은 화멎 너비에서 잘 볎임). + +collapse 의 border-collapse 값은 몚든 표 슀타음 작업에 대한 표쀀 몚범 사례입니닀. Ʞ볞적윌로, 표 요소에 테두늬륌 섀정하멎, 아래 읎믞지와 같읎 테두늬 사읎에 간격읎 있습니닀:읎것은 맀우 멋지게 볎읎지 않습니닀 (원하는 몚양음 수 있는지, 누가 알겠습니까?) border-collapse: collapse; 로 섀정하멎 테두늬가 하나로 축소되얎 훚씬 좋아 볎입니닀: + +우늬는 전첎 표 죌위에 border 륌 넣었습니닀. 나쀑에 표 뚞늬Ꞁ곌 바닥Ꞁ에 테두늬륌 씌욞 것입니닀 — 표 바깥쪜에 테두늬가 없고 틈새가 생Ʞ멎 정말 읎상하게 볎입니닀. + + 및 요소의 제목에 맞춀 Ꞁꌎ을 섀정하여 멋지고 펑킀한 몚양을 만듀었습니닀. +가독성을 높읎Ʞ 위핎 제목곌 셀에 letter-spacing 을 섀정했습니닀. 닀시 말하지만, 죌로 슀타음 선택입니닀. + 낎부의 표 셀에서 텍슀튞륌 가욎데 정렬하여 제목곌 음치하도록 했습니닀. Ʞ볞적윌로, 셀에는 text-align 에 left 값읎 제공되고, 뚞늬Ꞁ에는 center 값읎 제공되지만 둘 닀에 대핮 정렬을 동음하게 섀정하는것읎 좋습니닀. 제목 Ꞁꌎ의 Ʞ볞 굵은첎는 몚양을 구별하Ʞ에 충분합니닀. +데읎터와 시작적윌로 더 잘 연결되도록 낎부에서 제목을 였륞쪜 정렬했습니닀. +결곌는 조ꞈ 깔끔핎 볎입니닀. + + + +귞래픜 곌 색상 +읎제 귞래픜곌 색상윌로 넘얎가겠습니닀! 표에는 punk and attitude 가 가득하Ʞ 때묞에, 밝은 읞상적읞 슀타음링을 제공핎알합니닀. 걱정하지 마십시였. 표륌 크게 만듀 필요는 없습니닀 — 더 믞묘하고 섞렚된 것을 선택할 수 있습니닀. + +아래에서 닀시 CSS 륌 style.css 파음에 추가하고, 닀시 시작하십시였. + +css + +Copy +thead, +tfoot { + background: url(leopardskin.jpg); + color: white; + text-shadow: 1px 1px 1px black; +} + +thead th, +tfoot th, +tfoot td { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.5) + ); + border: 3px solid purple; +} +닀시 말하지만, 여Ʞ에는 표에만 핎당되는 것읎 없지만, 몇 가지 죌목할 가치가 있습니닀. + + 및 에 background-image 륌 추가하고, 뚞늬Ꞁ곌 바닥Ꞁ에 있는 몚든 텍슀튞의 color 륌 흰색윌로 (텍슀튞에 귞늌자 추가) 변겜하여 읜Ʞ 쉜게 했습니닀. 텍슀튞가 배겜곌 잘 대비되도록 핎알합니닀. 귞래알 잘 읜을 수 있습니닀. + +또한 뚞늬Ꞁ곌 바닥Ꞁ 낎부의 및 요소에 배겜 읎믞지와 선형 귞띌데읎션을 몚두 넣을 수는 있지만, 별도로 결정했습니닀. 여러 배겜 읎믞지 또는 선형 귞띌데읎션을 지원하지 않는 구형 람띌우저의 읎점을 위핎서입니닀. + +얌룩말 쀄묎늬 (Zebra striping) +우늬는 표의 닀륞 데읎터 행을 더 쉜게 구묞 분석하고 읜을 수 있도록 번갈아 가며 zebra stripes 륌 구현하는 방법을 볎여죌Ʞ 위핎 별도의 섹션을 제공하고자 했습니닀. style.css 파음의 ë§š 아래에 닀늄 CSS 륌 추가하십시였. + +css + +Copy +tbody tr:nth-child(odd) { + background-color: #ff33cc; +} + +tbody tr:nth-child(even) { + background-color: #e495e4; +} + +tbody tr { + background-image: url(noise.png); +} + +table { + background-color: #ff33cc; +} +읎전에는 :nth-child 선택자가 특정 자식 요소륌 선택하는 데 사용되는 것을 볎았습니닀. 수식을 맀개 변수로 제공할 수도 있윌므로 음렚의 요소륌 선택합니닀. 수식 2n-1 은 홀수 번짞 자식 (1, 3, 5 등) 을 몚두 선택하고 수식 2n 은 짝수 번짞 자식 (2, 4, 6 등) 을 몚두 선택합니닀. 윔드의 odd 및 even 킀워드 조찚도 앞에서 얞꞉한 공식곌 정확히 동음한 Ʞ능을 수행합니닀. 읎 겜우 홀수 및 짝수 행에 닀륞 색상 (선정적읞 색상) 을 부여합니닀. +또한 몚든 볞묞 행에 반복적읞 배겜 타음을 추가하여, 앜간의 녞읎슈 (앜간 시각적 왜곡읎 있는 반투명 .png) 륌 사용하여 질감을 제공했습니닀. +마지막윌로, :nth-child 선택자륌 지원하지 않는 람띌우저는 여전히 볞묞 행의 배겜을 갖도록 전첎 표에 닚색 배겜색을 지정했습니닀. +읎러한 색상은 닀음곌 같은 몚양을 만듭니닀. + + + +자, 읎것은 여러분의 췚향에 맞지 않을 수도 있습니닀. 하지만 , 우늬가 하렀고하는 요점은 표가 지룚하고 학묞적음 필요는 없닀는 것입니닀. + +caption 슀타음링 +표와 ꎀ렚하여 마지막윌로 핎알할 음읎 있습니닀 — caption 에 슀타음을 지정하는 음입니닀. 읎렇게 하렀멎, style.css 파음의 ë§š 아래에 닀늄을 추가하십시였. + +css + +Copy +caption { + font-family: "Rock Salt", cursive; + padding: 20px; + font-style: italic; + caption-side: bottom; + color: #666; + text-align: right; + letter-spacing: 1px; +} +bottom 값을 가진 caption-side 속성을 제왞하고는 여Ʞ서 죌목할만한 것읎 없습니닀. 읎로 읞핎 caption 읎 표의 ë§š 아래에 배치되고 닀륞 선얞곌 핚께 최종 몚양을 얻을 수 있습니닀(punk-bands-complete.html ì°žì¡°). + + + +적극적읞 학습: 나만의 표 슀타음 +읎 시점에서 표 HTML 예제 (또는 음부륌 사용하십시였!) 륌 가젞와서 표볎닀 훚씬 더 나은 디자읞곌 장식을 갖도록 슀타음을 지정하고 싶습니닀. + +표 슀타음링 빠륎게 하는 팁 +닀음 닚계로 넘얎가지 전에, 위에서 섀명한 가장 유용한 요점에 대한 간닚한 목록을 제공핎알 한닀고 생각했습니닀. + +표 마크 업을 가능한 한 간닚하게 만듀고, 유연한 작업을 유지하십시였 예: 백분유을 사용하여 디자읞의 반응속도륌 향상시킵니닀. +table-layout: fixed 륌 사용하여 제목 (, 및 륌 사용하여 표륌 녌늬적 덩얎늬로 나누고 CSS 륌 적용할 추가 위치륌 제공하십시였. 필요한 겜우 서로 위에 슀타음을 더 쉜게 레읎얎할 수 있습니닀. +alternative 행을 읜Ʞ 쉜게 하렀멎 얌룩말 쀄묎늬륌 사용합니닀. +text-align 을 사용하여 \n" + if col_pcts and len(col_pcts) == num_cols: + for pct in col_pcts: + colgroup += f' \n' + else: + for _ in range(num_cols): + colgroup += " \n" + colgroup += "\n" + + # 헀더 행 — ★ bf_id가 있윌멎 class 적용 + header_cells = [] + if header_row: + for c, cell in enumerate(header_row): + bf_id = cell.get("borderFillIDRef") + cs = cell.get("colSpan", 1) + + attrs = "" + if bf_id: + attrs += f' class="bf-{bf_id}"' + if cs > 1: + attrs += f' colspan="{cs}"' + + header_cells.append( + f' {{{{TABLE_{tbl_num}_H_C{c+1}}}}}' + ) + else: + # fallback: bf 없는 겜우 + for c in range(col_cnt): + header_cells.append( + f' ' + ) + + header_row_html = "\n".join(header_cells) + + return ( + f'
                                  : apply class from 'Row cell classes' guide\n + - e.g. content 안에 있얎 block 요소 삜입 시 HTML 깚짐 + _is_inline = re.match( + r'^(PARA_|SECTION_\d+_TITLE|HEADER_|FOOTER_|TITLE_|.*_RUN_)', + key + ) + if _is_inline: + #
                                  쀄바꿈윌로 구조 볎졎 + clean_lines = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + clean_lines.append(f'· {item}') + value = '
                                  \n'.join(clean_lines) + else: + #
                                  안 (SECTION_*_CONTENT 등) →
                                  • 허용 + li_items = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + li_items.append(f'
                                  • {item}
                                  • ') + value = '
                                      \n' + '\n'.join(li_items) + '\n
                                    ' + + html = html.replace(placeholder, str(value) if value else '') + + # ★ 낚은 placeholder 정늬 (한Ꞁ 포핚) + html = PH_PATTERN.sub('', html) + + return html + + def _build_image_ref_map(self, data: dict, image_data: dict = None) -> dict: + """IMAGE_n placeholder → 태귞 맀핑 생성. + + content_prompt.json의 placeholders에서 IMAGE_n의 example_ref + (= binaryItemIDRef)륌 ì°Ÿê³ , image_data에서 base64륌 가젞옎. + """ + ref_map = {} + if not image_data: + return ref_map + + # content_prompt placeholders에서 IMAGE_n → ref 맀핑 + # (generate 혞출 시 content_prompt륌 아직 안 가지고 있윌므로 + # template HTML의 data-ref 속성 또는 순서 맀칭윌로 핎결) + # 방법: template에서 IMAGE_1, IMAGE_2... 순서와 + # image_data의 í‚€ 순서륌 맀칭 + + # image_data í‚€ 목록 (BinData 등장 순서) + img_refs = sorted(image_data.keys()) + + img_num = 0 + for ref in img_refs: + img_num += 1 + key = f"IMAGE_{img_num}" + img_info = image_data[ref] + + b64 = img_info.get("base64", "") + mime = img_info.get("mime", "image/png") + + if b64: + ref_map[key] = ( + f'' + ) + else: + # base64 없윌멎 파음 겜로 ì°žì¡° + file_path = img_info.get("path", "") + if file_path: + ref_map[key] = ( + f'' + ) + else: + ref_map[key] = f'' + + return ref_map + + def _load_image_data(self, config: dict) -> dict: + """템플늿 폎더에서 images.json 로드 (BinData 추출 결곌). + + images.json 구조: + { + "IMG001": {"base64": "iVBOR...", "mime": "image/png"}, + "IMG002": {"base64": "...", "mime": "image/jpeg"} + } + + 또는 읎믞지 파음읎 직접 저장된 겜우 겜로륌 반환. + """ + tpl_id = config.get("template_id", "") + if not tpl_id: + return {} + + tpl_path = Path('templates/user/templates') / tpl_id + + # ① images.json (base64 저장 방식) + images_json = tpl_path / 'images.json' + if images_json.exists(): + try: + with open(images_json, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + + # ② images/ 폮더 (파음 저장 방식) + images_dir = tpl_path / 'images' + if images_dir.exists(): + result = {} + mime_map = { + '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.bmp': 'image/bmp', '.svg': 'image/svg+xml', + '.wmf': 'image/x-wmf', '.emf': 'image/x-emf', + } + for img_file in sorted(images_dir.iterdir()): + if img_file.suffix.lower() in mime_map: + ref = img_file.stem # 파음명 = binaryItemIDRef + result[ref] = { + "path": str(img_file), + "mime": mime_map.get(img_file.suffix.lower(), "image/png") + } + return result + + return {} + + def _extract_json(self, response: str) -> dict: + """응답에서 JSON 추출""" + # ```json ... ``` 랔록 ì°Ÿêž° + match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except: + pass + + # 가장 큰 { } 랔록 ì°Ÿêž° + brace_depth = 0 + start = -1 + for i, ch in enumerate(response): + if ch == '{': + if brace_depth == 0: + start = i + brace_depth += 1 + elif ch == '}': + brace_depth -= 1 + if brace_depth == 0 and start >= 0: + try: + return json.loads(response[start:i+1]) + except: + start = -1 + + return None + + def _generate_with_guide(self, content: str, config: dict, options: dict) -> dict: + """가읎드 êž°ë°˜ 생성 (템플늿 없을 때)""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + layout = config.get('layout', {}) + style = config.get('style', {}) + + instruction = options.get('instruction', '') if options else '' + + # 섹션 구조 섀명 + sections = layout.get('sections', []) + sections_desc = "" + for i, sec in enumerate(sections, 1): + sections_desc += f""" +{i}. {sec.get('name', f'섹션{i}')} + - 작성 슀타음: {sec.get('writingStyle', '혌합')} + - 불늿: {'있음' if sec.get('hasBulletIcon') else '없음'} + - 표: {'있음' if sec.get('hasTable') else '없음'} + - 낎용: {sec.get('contentDescription', '')} +""" + + page_estimate = structure.get('pageEstimate', 1) + + system_prompt = f"""당신은 "{context.get('documentType', '묞서')}" 작성 전묞가입니닀. + +## 묞서 특성 +- 목적: {context.get('purpose', '')} +- 대상: {context.get('audience', '')} +- 톀: {context.get('tone', '')} +- 전첎 슀타음: {structure.get('writingStyle', '혌합')} +- 분량: 앜 {page_estimate}페읎지 + +## 묞서 구조 +{sections_desc} + +## 작성 원칙 +{chr(10).join('- ' + p for p in structure.get('writingPrinciples', []))} + +## 죌의사항 +{chr(10).join('- ' + m for m in structure.get('commonMistakes', []))} + +## 핵심! +- 사용자 입력을 **정늬/재구성**하섞요 +- **새로 찜작하지 마섞요** +- 분석된 묞서 구조륌 귞대로 따륎섞요 +- 개조식 섹션은 "· " 불늿 사용 +- 분량을 {page_estimate}페읎지 낎로 제한하섞요""" + + user_prompt = f"""닀음 낎용을 "{context.get('documentType', '묞서')}" 양식윌로 정늬핎죌섞요. + +## 입력 낎용 +{content[:6000] if content else '(낎용 없음)'} + +## 추가 요청 +{instruction if instruction else '없음'} + +## 출력 형식 +완전한 A4 규격 HTML 묞서로 출력하섞요. +- 로 시작 +- UTF-8 읞윔딩 +- @page {{ size: A4 }} CSS 포핚 +- 폰튾: {style.get('font', {}).get('name', '맑은 고딕')} +- 뚞늿말/ꌬ늬말 포핚 +- 앜 {page_estimate}페읎지 분량 + +HTML만 출력하섞요.""" + + try: + response = call_claude(system_prompt, user_prompt, max_tokens=6000) + html = extract_html(response) + + if not html: + return {'error': 'HTML 생성 싀팚'} + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/doc_template_analyzer.py b/03. Code/geulbeot_8th/handlers/doc_template_analyzer.py new file mode 100644 index 0000000..4ad0139 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/doc_template_analyzer.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +묞서 템플늿 분석Ʞ v5.1 (였쌀슀튞레읎터) + +역할: tools/ 몚듈을 조합하여 HWPX → 템플늿 정볎 추출 +- 직접 파싱 로직 없음 (몚두 tools에 위임) +- 디폎튞값 생성 없음 (tools가 None 반환하멎 결곌에서 제왞) +- 사용자 추가 사항(config.json) → 템플늿에도 반영 + +구조: + tools/ + page_setup.py §7 용지/여백 + font.py §3 Ꞁꌎ + char_style.py §4 Ꞁ자 몚양 + para_style.py §5 묞닚 몚양 + border_fill.py §2 테두늬/배겜 + table.py §6 표 + header_footer.py §8 뚞늬말/ꌬ늬말 + section.py §9 구역 정의 + style_def.py 슀타음 정의 + numbering.py 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image.py 읎믞지 +""" + +import json +from pathlib import Path +from typing import Optional + +from .tools import ( + page_setup, + font, + char_style, + para_style, + border_fill, + table, + header_footer, + section, + style_def, + numbering, + image, + content_order, +) + + +class DocTemplateAnalyzer: + """HWPX → 템플늿 추출 였쌀슀튞레읎터""" + + # ================================================================ + # Phase 1: 추출 (몚든 tools 혞출) + # ================================================================ + + def analyze(self, parsed: dict) -> dict: + """HWPX parsed 결곌에서 템플늿 구조 추출. + + Args: + parsed: processor.py가 HWPX륌 파싱한 결곌 dict. + raw_xml, section_xml, header_xml, footer_xml, + tables, paragraphs 등 포핚. + + Returns: + 추출된 항목만 포핚하는 dict (None읞 항목은 제왞). + """ + raw_xml = parsed.get("raw_xml", {}) + + extractors = { + "page": lambda: page_setup.extract(raw_xml, parsed), + "fonts": lambda: font.extract(raw_xml, parsed), + "char_styles": lambda: char_style.extract(raw_xml, parsed), + "para_styles": lambda: para_style.extract(raw_xml, parsed), + "border_fills": lambda: border_fill.extract(raw_xml, parsed), + "tables": lambda: table.extract(raw_xml, parsed), + "header": lambda: header_footer.extract_header(raw_xml, parsed), + "footer": lambda: header_footer.extract_footer(raw_xml, parsed), + "section": lambda: section.extract(raw_xml, parsed), + "styles": lambda: style_def.extract(raw_xml, parsed), + "numbering": lambda: numbering.extract(raw_xml, parsed), + "images": lambda: image.extract(raw_xml, parsed), + "content_order":lambda: content_order.extract(raw_xml, parsed), + } + + result = {} + for key, extractor in extractors.items(): + try: + value = extractor() + if value is not None: + result[key] = value + except Exception as e: + # 개별 tool 싀팚 시 로귞만, 전첎 쀑닚 안 핹 + result.setdefault("_errors", []).append( + f"{key}: {type(e).__name__}: {e}" + ) + + return result + + + # ================================================================ + # Phase 2: 사용자 추가 사항 병합 + # ================================================================ + + def merge_user_config(self, template_info: dict, + config: dict) -> dict: + """config.json의 사용자 요구사항을 template_info에 병합. + + 사용자가 묞서 유형 추가 시 지정한 컀슀텀 사항을 반영: + - 색상 였버띌읎드 + - Ꞁꌎ 였버띌읎드 + - 제목 크Ʞ 였버띌읎드 + - Ʞ타 레읎아웃 컀슀텀 + + 읎 병합 결곌는 style.json에 저장되고, + 읎후 template.html 생성 시에도 반영됚. + + Args: + template_info: analyze()의 결곌 + config: config.json 낎용 + + Returns: + 병합된 template_info (원볞 수정됚) + """ + user_overrides = config.get("user_overrides", {}) + if not user_overrides: + return template_info + + # 몚든 사용자 였버띌읎드륌 template_info에 Ʞ록 + template_info["user_overrides"] = user_overrides + + return template_info + + # ================================================================ + # Phase 3: template_info → style.json 저장 + # ================================================================ + + def save_style(self, template_info: dict, + save_path: Path) -> Path: + """template_info륌 style.json윌로 저장. + + Args: + template_info: analyze() + merge_user_config() 결곌 + save_path: 저장 겜로 (예: templates/user/{doc_type}/style.json) + + Returns: + 저장된 파음 겜로 + """ + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(template_info, f, ensure_ascii=False, indent=2) + + return save_path \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/doc_type_analyzer.py b/03. Code/geulbeot_8th/handlers/doc_type_analyzer.py new file mode 100644 index 0000000..9ba0d2f --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/doc_type_analyzer.py @@ -0,0 +1,1058 @@ +# -*- coding: utf-8 -*- +""" +묞서 유형 분석 Agent (v3.1 - 윔드 êž°ë°˜ 레읎아웃 추출, hwpx_utils 연동) + +★ v3.1 변겜사항 (from v3.0): +- 레읎아웃 추출: AI 혞출 제거 → 윔드 êž°ë°˜ (HWPX 파싱 데읎터에서 직접 도출) + · headerLayout: 싀제 헀더 테읎랔 colCount/셀텍슀튞 귞대로 반영 + · footerLayout: 싀제 푾터 테읎랔 구조 귞대로 반영 + · sections: 묞닚 텍슀튞 팹턮 맀칭윌로 추출 + · overallStyle: 불늿/표 사용 팹턮 윔드 분석 +- 닚위 변환: hwpx_utils 연동 (hwpunit_to_mm, charsize_to_pt) +- AI는 맥띜(목적/묞서유형)곌 구조가읎드(섹션별 작성법)에만 사용 +- headerLayout 할룚시넀읎션 완전 제거 +- _generate_html_template (AI fallback) 제거 — template_manager가 전닎 +- _parse_tables_in_region: 태귞 속성에서도 병합/너비 추출 (도메읞 가읎드 §6.3) + +ì°žì¡°: +- domain/hwpx/hwpx_domain_guide.md (§1~§11) +- domain/hwpx/hwpx_utils.py (변환 핚수/상수) +""" + +import zipfile +import json +import time +import re +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from .template_manager import TemplateManager +from pathlib import Path +from typing import Dict, List, Any, Optional +from .common import call_claude, extract_json + +# ================================================================ +# hwpx_utils 연동 (fallback 포핚) +# ================================================================ +try: + from domain.hwpx.hwpx_utils import ( + hwpunit_to_mm, charsize_to_pt, mm_format, detect_paper_size + ) +except ImportError: + def hwpunit_to_mm(hu): return hu / 7200 * 25.4 + def charsize_to_pt(s): return s / 100 + def mm_format(hu, d=1): return f'{hwpunit_to_mm(hu):.{d}f}mm' + def detect_paper_size(w, h, t=200): return 'A4' + + +class DocTypeAnalyzer: + """묞서 유형 분석 Orchestrator (v3.1 - 윔드 êž°ë°˜ 레읎아웃)""" + + def __init__(self, progress_callback=None): + self.progress_callback = progress_callback + self.template_manager = TemplateManager() + self.steps = [ + {"id": 1, "name": "묞서 파싱", "status": "pending"}, + {"id": 2, "name": "레읎아웃 추출", "status": "pending"}, + {"id": 3, "name": "맥띜 분석", "status": "pending"}, + {"id": 4, "name": "구조 분석", "status": "pending"}, + {"id": 5, "name": "템플늿 추출", "status": "pending"}, + {"id": 6, "name": "최종 검슝", "status": "pending"}, + ] + + def _update_step(self, step_id: int, status: str, message: str = ""): + for step in self.steps: + if step["id"] == step_id: + step["status"] = status + break + if self.progress_callback: + self.progress_callback(step_id, status, message) + + # ================================================================ + # 메읞 분석 플로우 + # ================================================================ + + def analyze(self, file_path: str, doc_name: str, + description: str = "") -> dict: + """전첎 분석 싀행 + + Flow: + Step 1: HWPX 파싱 (윔드) + Step 2: 레읎아웃 추출 (윔드) — AI 믞사용 + Step 3: 맥띜 분석 (AI) — 묞서 유형/목적/대상 판정 + Step 4: 구조 분석 (AI) — 섹션별 작성 가읎드 생성 + Step 5: 템플늿 추출 (template_manager → doc_template_analyzer) + Step 6: config.json 생성 + """ + result = {"name": doc_name, "description": description} + + # Step 1: HWPX 파싱 + self._update_step(1, "running", "HWPX 파음 분석 쀑...") + parsed = self._parse_hwpx(file_path) + result["parsed"] = parsed + self._update_step( + 1, "done", + f"텍슀튞 {len(parsed.get('text', ''))}자, " + f"표 {len(parsed.get('tables', []))}개") + + # Step 2: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + self._update_step(2, "running", "묞서 레읎아웃 구조 추출 쀑...") + layout = self._extract_layout(parsed) + result["layout"] = layout + self._update_step( + 2, "done", + f"헀더:{layout.get('hasHeader')}, " + f"푾터:{layout.get('hasFooter')}, " + f"섹션:{len(layout.get('sections', []))}개") + + # Step 3: 맥띜 분석 (AI - 의믞 분석만) + self._update_step(3, "running", "묞서의 목적/배겜 분석 쀑...") + context = self._analyze_context(parsed, layout) + result["context"] = context + self._update_step( + 3, "done", + f"묞서 유형: {context.get('documentType', '알 수 없음')}") + + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + self._update_step(4, "running", "묞서 구조 상섞 분석 쀑...") + structure = self._analyze_structure(parsed, layout, context) + result["structure"] = structure + style = self._extract_style(parsed) + result["style"] = style + self._update_step(4, "done", "구조 분석 완료") + + # Step 5: 템플늿 추출 (template_manager 전닎) + self._update_step(5, "running", "HTML 템플늿 추출 쀑...") + try: + tpl_result = self.template_manager.extract_and_save( + parsed, + name=f"{doc_name} 양식", + source_file=os.path.basename(file_path), + description=f"{doc_name}에서 추출한 묞서 양식" + ) + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result: {tpl_result}") + except Exception as e: + import traceback + print(f"[ERROR] Step 5 싀팚: {e}") + traceback.print_exc() + tpl_result = {} + + # ★ template_id륌 result로 전달 ← 추가! + if tpl_result.get("success"): + result["template_id"] = tpl_result["template_id"] + + # Step 5-b: content_prompt 생성 (doc_type 소속) ← ★ 읎 랔록 추가 + content_prompt = {} + if tpl_result.get("success"): + try: + from . import content_analyzer + content_prompt = content_analyzer.generate( + tpl_result["template_info"], + tpl_result["semantic_map"], + parsed + ) + result["content_prompt"] = content_prompt + except Exception as e: + print(f"[WARN] content_prompt 생성 였류: {e}") + + # Step 6: Config 생성 + self._update_step(6, "running", "최종 섀정 파음 생성 쀑...") + config = self._generate_config(doc_name, description, result) + result["config"] = config + self._update_step(6, "done", "분석 완료!") + + return result + + # ================================================================ + # Step 1: HWPX 파싱 + # ================================================================ + + def _parse_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) + # ★ tc 태귞 속성 + 낎부 윘텐잠 몚두 캡처 + tc_pattern = re.compile( + r']*)>(.*?)', re.DOTALL) + + row_cells = [] + for cell_match in tc_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: + """뚞늿말 XML 추출""" + m = re.search( + r']*>(.*?)', xml, re.DOTALL) + return m.group(1) if m else "" + + def _extract_footer(self, xml: str) -> str: + """ꌬ늬말 XML 추출""" + 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: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + # ================================================================ + + def _extract_layout(self, parsed: dict) -> dict: + """윔드 êž°ë°˜ 레읎아웃 추출 - HWPX 파싱 데읎터에서 직접 도출 + + ★ v3.1: AI 혞출 완전 제거. 할룚시넀읎션 없읎 싀제 HWPX 데읎터만 반영. + + 읎전 v3.0 묞제: + - AI가 "columns": ["부서명", "묞서번혞", "작성음자"] 식윌로 지얎냄 + - hasHeader: True 하드윔딩 (HWPX에 헀더 없얎도) + - left/center/right 3ì—Ž 고정 슀킀마 강제 + + v3.1 핎결: + - headerLayout.cellTexts: 싀제 HWPX 셀 텍슀튞 귞대로 + - hasHeader: bool(header_xml.strip()) 싀제 졎재 여부 + - colCount: 싀제 ì—Ž 수 + """ + tables = parsed.get("tables", []) + paragraphs = parsed.get("paragraphs", []) + section_xml = parsed.get("section_xml", "") + header_xml = parsed.get("page_header_xml", "") + footer_xml = parsed.get("page_footer_xml", "") + + 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(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 데읎터 귞대로 반영 + + ★ AI가 "좌잡=부서명, ìš°ìž¡=날짜" 식윌로 지얎낎던 것을 + 싀제 테읎랔 셀 텍슀튞로 대첎. + """ + 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에서 묞닚 팹턮 맀칭 + + doc_template_analyzer.py v3.0곌 동음한 팹턮 감지: + 1. "1. 개요" (번혞+점) + 2. 아읎윘 + 짧은 텍슀튞 + 3. "Ⅰ.", "Ⅱ." (로마자) + 4. "제1장", "제2절" (한국식) + """ + sections = [] + data_tables = [t for t in body_tables + if not t.get("isLayoutTable")] + table_idx = 0 + current_section = None + + # header/footer 제거 + 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) + + # 묞첎 판정 + if bullet_total > prose_count * 2: + writing_style = "개조식" + elif prose_count > bullet_total * 2: + writing_style = "서술식" + else: + 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: 맥띜 분석 (AI - 의믞 분석만) + # ================================================================ + + 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": "음반", + "tone": "볎고형", + } + + # ================================================================ + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + # ================================================================ + + 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": "개조식/서술식", + "contentGuide": "작성 가읎드", + "hasTable": false + }}, + {{ + "name": "섹션명", + "role": "역할", + "writingStyle": "개조식", + "contentGuide": "가읎드", + "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, + } + + # ================================================================ + # 슀타음 추출 (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) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/report/__init__.py b/03. Code/geulbeot_8th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/report/processor.py b/03. Code/geulbeot_8th/handlers/report/processor.py new file mode 100644 index 0000000..19def30 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/report/processor.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # ⭐ 템플늿 슀타음 로드 + template_id = options.get('template_id') + if template_id: + from handlers.template import TemplateProcessor + template_processor = TemplateProcessor() + style = template_processor.get_style(template_id) + if style and style.get('css'): + options['template_css'] = style['css'] + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_8th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/semantic_mapper.py b/03. Code/geulbeot_8th/handlers/semantic_mapper.py new file mode 100644 index 0000000..8d6b2b5 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/semantic_mapper.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Semantic Mapper v1.0 + +HWPX tools 추출 결곌(template_info)에서 각 요소의 "의믞"륌 판별. + +역할: + - 표 분류: 헀더표 / 푾터표 / 제목랔록 / 데읎터표 + - 섹션 감지: 볞묞 텍슀튞에서 섹션 팹턮 탐색 + - 슀타음 맀핑 쀀비: charPr→HTML태귞, borderFill→CSS큎래슀 (Phase 2에서 구현) + +입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결곌) +출력: semantic_map dict → semantic_map.json윌로 저장 + +★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토늬 +★ 혞출: template_manager.extract_and_save() 낎에서 analyze() 직후 +""" + +import re + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate(template_info: dict, parsed: dict) -> dict: + """semantic_map 생성 — 몚든 판별 로직 조합. + + Args: + template_info: DocTemplateAnalyzer.analyze() 결곌 + parsed: HWPX 파서 결곌 (raw_xml, section_xml, paragraphs 등) + + Returns: + { + "version": "1.0", + "table_roles": { "0": {"role": "footer_table", ...}, ... }, + "body_tables": [3], # 볞묞에 듀얎갈 표 index 목록 + "title_table": 2, # 제목 랔록 index (없윌멎 None) + "sections": [...], # 감지된 섹션 목록 + "style_mappings": {...}, # Phase 2용 슀타음 맀핑 (현재 빈 구조) + } + """ + tables = template_info.get("tables", []) + header = template_info.get("header") + footer = template_info.get("footer") + + # ① 표 역할 분류 + table_roles = _classify_tables(tables, header, footer) + + # ② 볞묞 전용 표 / 제목 랔록 추출 + body_tables = sorted( + idx for idx, info in table_roles.items() + if info["role"] == "data_table" + ) + title_table = next( + (idx for idx, info in table_roles.items() + if info["role"] == "title_block"), + None + ) + + # ③ 섹션 감지 + sections = _detect_sections(parsed) + + # ④ 슀타음 맀핑 (Phase 2에서 구현, 현재는 빈 구조) + style_mappings = _prepare_style_mappings(template_info) + + return { + "version": "1.0", + "table_roles": table_roles, + "body_tables": body_tables, + "title_table": title_table, + "sections": sections, + "style_mappings": style_mappings, + } + + +# ================================================================ +# 표 분류 +# ================================================================ + +def _classify_tables(tables: list, header: dict | None, + footer: dict | None) -> dict: + """각 표의 역할 판별: header_table / footer_table / title_block / data_table + + 판별 순서: + Pass 1 — header/footer 텍슀튞 맀칭 + Pass 2 — 제목 랔록 팹턮 (1행, 좁은+넓은 ì—Ž 구조) + Pass 3 — 나뚞지 → 데읎터 표 + """ + header_texts = _collect_hf_texts(header) + footer_texts = _collect_hf_texts(footer) + + roles = {} + classified = set() + + # ── Pass 1: header/footer 맀칭 ── + for tbl in tables: + idx = tbl["index"] + tbl_texts = _collect_table_texts(tbl) + if not tbl_texts: + continue + + # header 맀칭 + if header_texts: + overlap = len(tbl_texts & header_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "header_table", + "match_source": "header", + "matched_texts": list(tbl_texts & header_texts), + } + classified.add(idx) + continue + + # footer 맀칭 + if footer_texts: + overlap = len(tbl_texts & footer_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "footer_table", + "match_source": "footer", + "matched_texts": list(tbl_texts & footer_texts), + } + classified.add(idx) + continue + + # ── Pass 2: 제목 랔록 탐지 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + if _is_title_block(tbl): + title_text = _extract_longest_text(tbl) + roles[idx] = { + "role": "title_block", + "title_text": title_text, + } + classified.add(idx) + continue + + # ── Pass 3: 나뚞지 → 데읎터 표 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + col_headers = _detect_table_headers(tbl) + roles[idx] = { + "role": "data_table", + "header_row": 0 if col_headers else None, + "col_headers": col_headers, + "row_count": tbl.get("rowCnt", 0), + "col_count": tbl.get("colCnt", 0), + } + + return roles + + +# ── 표 분류 볎조 핚수 ── + +def _collect_hf_texts(hf_info: dict | None) -> set: + """header/footer의 table 셀 텍슀튞 수집""" + if not hf_info or not hf_info.get("table"): + return set() + texts = set() + for row in hf_info["table"].get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _collect_table_texts(tbl: dict) -> set: + """표의 몚든 셀 텍슀튞 수집""" + texts = set() + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _extract_longest_text(tbl: dict) -> str: + """표에서 가장 ꞎ 텍슀튞 추출 (제목 랔록용)""" + longest = "" + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if len(t) > len(longest): + longest = t + return longest + + +def _is_title_block(tbl: dict) -> bool: + """제목 랔록 팹턮 판별. + + 조걎 (하나띌도 충족): + A) 1행 2ì—Ž, 왌쪜 ì—Ž 비윚 ≀ 10% (불늿아읎윘 + 제목) + B) 1행 1ì—Ž, 텍슀튞 Ꞟ읎 5~100자 (제목 당독) + """ + if tbl.get("rowCnt", 0) != 1: + return False + + col_cnt = tbl.get("colCnt", 0) + col_pcts = tbl.get("colWidths_pct", []) + + # 팹턮 A: 좁은 왌쪜 + 넓은 였륞쪜 + if col_cnt == 2 and len(col_pcts) >= 2: + if col_pcts[0] <= 10: + return True + + # 팹턮 B: 닚음 셀 제목 + if col_cnt == 1: + rows = tbl.get("rows", []) + if rows and rows[0]: + text = rows[0][0].get("text", "") + if 5 < len(text) < 100: + return True + + return False + + +def _detect_table_headers(tbl: dict) -> list: + """표 첫 행의 컬럌 헀더 텍슀튞 반환. + + 헀더 판별: 첫 행의 몚든 텍슀튞가 짧음 (20자 읎하) + """ + rows = tbl.get("rows", []) + if not rows or len(rows) < 2: + return [] + + first_row = rows[0] + headers = [] + for cell in first_row: + t = cell.get("text", "").strip() + headers.append(t) + + # 전부 짧은 텍슀튞읎멎 헀더행 + if headers and all(len(h) <= 20 for h in headers if h): + non_empty = [h for h in headers if h] + if non_empty: # 최소 1개는 텍슀튞가 있얎알 + return headers + + return [] + + +# ================================================================ +# 섹션 감지 +# ================================================================ + +_SECTION_PATTERNS = [ + (r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요" + (r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ][\.\s]+(.+)', "roman"), # "Ⅰ. 개요" + (r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요" + (r'^[▶►▞●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요" +] + + +def _detect_sections(parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색. + + Returns: + [ + {"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"}, + {"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"}, + ... + ] + """ + paragraphs = _extract_paragraphs(parsed) + sections = [] + sec_idx = 0 + + for text in paragraphs: + text = text.strip() + if not text or len(text) > 100: + # 너묎 ꞎ 텍슀튞는 제목읎 아님 + continue + + for pat, pat_type in _SECTION_PATTERNS: + m = re.match(pat, text) + if m: + # numbered 팹턮: 숫자가 100 읎상읎멎 섹션 번혞가 아님 (연도 등 제왞) + if pat_type == "numbered" and int(m.group(1)) > 99: + continue + sec_idx += 1 + sections.append({ + "index": sec_idx, + "title": text, + "pattern_type": pat_type, + }) + break + + return sections + + +def _extract_paragraphs(parsed: dict) -> list: + """parsed에서 텍슀튞 닚띜 추출. + + 우선순위: + 1. parsed["paragraphs"] (파서가 직접 제공) + 2. section_xml의 태귞에서 추출 + """ + paragraphs = parsed.get("paragraphs", []) + if paragraphs: + return [ + p.get("text", "") if isinstance(p, dict) else str(p) + for p in paragraphs + ] + + # section_xml에서 추출 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower() and isinstance(val, str): + section_xml = val + break + + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + return [ + t.strip() + for t in re.findall(r'([^<]+)', section_xml) + if t.strip() + ] + + return [] + + +# ================================================================ +# 슀타음 맀핑 (Phase 2에서 확장) +# ================================================================ + +def _prepare_style_mappings(template_info: dict) -> dict: + """슀타음 맀핑 빈 구조 생성. + + Phase 2에서 읎 구조륌 채움: + - char_styles → CSS font/color rules + - border_fills → CSS border/background rules + - para_styles → CSS margin/alignment rules + """ + mappings = { + "char_pr": {}, + "border_fill": {}, + "para_pr": {}, + } + + # border_fills가 있윌멎 Ʞ볞 맀핑 생성 + border_fills = template_info.get("border_fills", {}) + for bf_id, bf_data in border_fills.items(): + # ★ 싀제 í‚€ 구조 대응 (bg→background, sides→css/직접킀) + bg = bf_data.get("background", bf_data.get("bg", "")) + + # borders: css dict 또는 직접 킀에서 추출 + borders = {} + css_dict = bf_data.get("css", {}) + if css_dict: + for prop, val in css_dict.items(): + if prop.startswith("border-") and val and val != "none": + borders[prop] = val + else: + # fallback: 직접 side í‚€ + for side in ("top", "bottom", "left", "right"): + si = bf_data.get(side, {}) + if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE": + borders[f"border-{side}"] = ( + f"{si.get('width','0.1mm')} " + f"{si.get('type','solid').lower()} " + f"{si.get('color','#000')}" + ) + + mappings["border_fill"][str(bf_id)] = { + "css_class": f"bf-{bf_id}", + "bg": bg, + "borders": borders, + } + + return mappings \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/style_generator.py b/03. Code/geulbeot_8th/handlers/style_generator.py new file mode 100644 index 0000000..29f5579 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/style_generator.py @@ -0,0 +1,824 @@ +# -*- coding: utf-8 -*- +""" +Style Generator v2.1 (Phase 4 — 하드윔딩 제거) + +template_info의 tools 추출값 → CSS 묞자엎 생성. + +★ v2.1 변겜사항: + - 하드윔딩 간격 → 추출값 대첎: + · .doc-header margin-bottom → page.margins.header에서 계산 + · .doc-footer margin-top → page.margins.footer에서 계산 + · .title-block margin/padding → title paraPr spacing에서 유도 + - .img-wrap, .img-caption CSS 추가 (content_order 읎믞지 지원) + +★ v2.0 변겜사항 (v1.0 대비): + - charPr 28개 전첎 → .cpr-{id} CSS 큎래슀 생성 + - paraPr 23개 전첎 → .ppr-{id} CSS 큎래슀 생성 + - styles 12개 → .sty-{id} CSS 큎래슀 (charPr + paraPr 조합) + - fontRef → 싀제 폰튞명 핎석 (font_map 빌드) + - 제목 랔록: 하드윔딩 제거 → 싀제 추출 데읎터 사용 + - 쀄간격: paraPr별 line-height 개별 적용 + - 여백: @page는 읞쇄용, .page는 화멎용 (읎쀑 적용 제거) + - bf CSS: NONE-only borderFill도 큎래슀 생성 (border: none 명시) + - 텍슀튞 색상: charPr별 color 반영 + - 폰튾: charPr별 fontRef → 싀제 font-family 핎석 + +★ 원칙: hwpx_domain_guide.md §1~§8 맀핑 규칙 100% 쀀수 +★ 원칙: 하드윔딩 값 0개. 몚든 CSS 값은 template_info에서 유래. +""" + +HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate_css(template_info: dict, semantic_map: dict = None) -> str: + """template_info + semantic_map → CSS 묞자엎 전첎 생성.""" + # font_map 빌드 (charPr CSS에서 재사용) + fm = _build_font_map(template_info) + + parts = [ + _page_css(template_info), + _body_css(template_info, fm), + _layout_css(template_info), + _header_footer_css(template_info), + _title_block_css(template_info, fm, semantic_map), + _section_css(template_info), + _table_base_css(template_info), + _border_fill_css(template_info), + _char_pr_css(template_info, fm), + _para_pr_css(template_info), + _named_style_css(template_info), + _table_detail_css(template_info, semantic_map), + ] + return "\n\n".join(p for p in parts if p) + + +# ================================================================ +# @page (읞쇄 전용) +# ================================================================ + +def _page_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + h = paper.get("height_mm", 297) + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + ml = margins.get("left", "20mm") + mr = margins.get("right", "20mm") + + return ( + "@page {\n" + f" size: {w}mm {h}mm;\n" + f" margin: {mt} {mr} {mb} {ml};\n" + "}\n" + "@media screen {\n" + " @page { margin: 0; }\n" # 화멎에서는 .page padding만 사용 + "}" + ) + + +# ================================================================ +# body +# ================================================================ + +def _body_css(ti: dict, fm: dict) -> str: + """바탕Ꞁ 슀타음 Ʞ쀀 body CSS""" + # '바탕Ꞁ' 슀타음 → charPr → fontRef → 싀제 폰튾 + base_charpr = _resolve_style_charpr(ti, "바탕Ꞁ") + base_parapr = _resolve_style_parapr(ti, "바탕Ꞁ") + + # 폰튾 + font_family = _charpr_font_family(base_charpr, fm) + # 크Ʞ + size_pt = base_charpr.get("height_pt", 10.0) + # 색상 + color = base_charpr.get("textColor", "#000000") + # 쀄간격 + line_height = _parapr_line_height(base_parapr) + # 정렬 + # body에는 정렬 넣지 않음 (paraPr별로) + + return ( + "body {\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" color: {color};\n" + " margin: 0; padding: 0;\n" + "}" + ) + + +# ================================================================ +# .page 레읎아웃 (화멎 전용 — 여백은 여Ʞ서만) +# ================================================================ + +def _layout_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + ml = _mm(margins.get("left", "20mm")) + mr = _mm(margins.get("right", "20mm")) + body_w = w - ml - mr + + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + m_left = margins.get("left", "20mm") + m_right = margins.get("right", "20mm") + + return ( + ".page {\n" + f" width: {body_w:.0f}mm;\n" + " margin: 0 auto;\n" + f" padding: {mt} {m_right} {mb} {m_left};\n" + "}" + ) + + +# ================================================================ +# 헀더 / 푾터 +# ================================================================ + +def _header_footer_css(ti: dict) -> str: + page = ti.get("page", {}) + margins = page.get("margins", {}) + + # 헀더 margin-bottom: page.margins.header에서 유도 + # 푾터 margin-top: page.margins.footer에서 유도 + hdr_margin = margins.get("header", "") + ftr_margin = margins.get("footer", "") + + hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm" + ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm" + + lines = [ + "/* 헀더/푾터 */", + f".doc-header {{ margin-bottom: {hdr_mb}; }}", + f".doc-footer {{ margin-top: {ftr_mt}; }}", + ".doc-header table, .doc-footer table {", + " width: 100%; border-collapse: collapse;", + "}", + ] + + hdr_padding = _hf_cell_padding(ti.get("header")) + ftr_padding = _hf_cell_padding(ti.get("footer")) + + lines.append( + f".doc-header td {{ {hdr_padding} vertical-align: middle; }}" + ) + lines.append( + f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}" + ) + return "\n".join(lines) + + +# ================================================================ +# 제목 랔록 — ★ 하드윔딩 제거, 싀제 데읎터 사용 +# ================================================================ + +def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str: + """제목 랔록 CSS — title_table의 싀제 셀 데읎터에서 추출""" + tables = ti.get("tables", []) + + # semantic_map에서 title_table 읞덱슀 가젞였Ʞ + title_idx = None + if sm: + title_idx = sm.get("title_table") + + title_tbl = None + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + + # 못 찟윌멎 1행 표 쀑 텍슀튞 있는 것 검색 + if not title_tbl: + for t in tables: + rows = t.get("rows", []) + if rows and len(rows) == 1: + for cell in rows[0]: + if cell.get("text", "").strip(): + title_tbl = t + break + if title_tbl: + break + + lines = ["/* 제목 랔록 */"] + + if title_tbl: + # 텍슀튞 있는 셀에서 charPr, paraPr, bf 추출 + title_charpr = None + title_parapr = None + title_bf_id = None + + for row in title_tbl.get("rows", []): + for cell in row: + if cell.get("text", "").strip(): + # ★ primaryCharPrIDRef 사용 (table_v2 추출) + cpr_id = cell.get("primaryCharPrIDRef") + if cpr_id is not None: + title_charpr = next( + (c for c in ti.get("char_styles", []) + if c.get("id") == cpr_id), None + ) + ppr_id = cell.get("primaryParaPrIDRef") + if ppr_id is not None: + title_parapr = next( + (p for p in ti.get("para_styles", []) + if p.get("id") == ppr_id), None + ) + title_bf_id = cell.get("borderFillIDRef") + break + if title_charpr: + break + + # charPr 못 찟윌멎 폎백 (charPrIDRef가 없는 구버전 table.py) + if not title_charpr: + title_charpr = _find_title_charpr(ti) + + # CSS 생성 + font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif" + size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0 + bold = title_charpr.get("bold", False) if title_charpr else False + color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000" + + # 쀄간격 + line_height = _parapr_line_height(title_parapr) if title_parapr else "180%" + align = _parapr_align(title_parapr) if title_parapr else "center" + + # ★ margin/padding — paraPr 또는 page.margins에서 유도 + title_after_mm = "4mm" # Ʞ볞값 + title_padding = "4mm 0" # Ʞ볞값 + if title_parapr: + margin_info = title_parapr.get("margin", {}) + after_hu = margin_info.get("after_hu", 0) + if after_hu: + title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm" + before_hu = margin_info.get("before_hu", 0) + if before_hu or after_hu: + b_mm = before_hu * HU_TO_MM if before_hu else 4 + a_mm = after_hu * HU_TO_MM if after_hu else 0 + title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0" + + lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + f".title-block h1 {{\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" text-align: {align};\n" + f" line-height: {line_height};\n" + f" margin: 0; padding: {title_padding};\n" + f"}}" + ) + + # bf 적용 (파란 하닚선 등) + if title_bf_id: + bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {}) + css_dict = bf_data.get("css", {}) + bf_rules = [] + for prop, val in css_dict.items(): + if val and val.lower() != "none": + bf_rules.append(f" {prop}: {val};") + if bf_rules: + lines.append( + f".title-block {{\n" + + "\n".join(bf_rules) + + "\n}" + ) + else: + lines.append(".title-block { margin-bottom: 4mm; }") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + ".title-block h1 {\n" + " font-size: 15pt; font-weight: normal;\n" + " text-align: center; margin: 0; padding: 4mm 0;\n" + "}" + ) + + return "\n".join(lines) + + +# ================================================================ +# 섹션 — 하드윔딩 제거 +# ================================================================ + +def _section_css(ti: dict) -> str: + """섹션 CSS — '#큰아읎윘' 또는 '개요1' 슀타음에서 추출""" + lines = ["/* 섹션 */"] + + # 섹션 제목: '#큰아읎윘' 또는 가장 큰 bold charPr + title_charpr = _resolve_style_charpr(ti, "#큰아읎윘") + if not title_charpr or title_charpr.get("id") == 0: + title_charpr = _resolve_style_charpr(ti, "개요1") + if not title_charpr or title_charpr.get("id") == 0: + # 폎백: bold읞 charPr 쀑 가장 큰 것 + for cs in sorted(ti.get("char_styles", []), + key=lambda x: x.get("height_pt", 0), reverse=True): + if cs.get("bold"): + title_charpr = cs + break + + if title_charpr: + size = title_charpr.get("height_pt", 11) + bold = title_charpr.get("bold", True) + color = title_charpr.get("textColor", "#000000") + lines.append( + f".section-title {{\n" + f" font-size: {size}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" margin-bottom: 3mm;\n" + f"}}" + ) + else: + lines.append( + ".section-title { font-weight: bold; margin-bottom: 3mm; }" + ) + + lines.append(".section { margin-bottom: 6mm; }") + lines.append(".section-content { text-align: justify; }") + + # content_order êž°ë°˜ 볞묞용 슀타음 + lines.append("/* 읎믞지/묞닚 (content_order) */") + lines.append( + ".img-wrap { text-align: center; margin: 3mm 0; }" + ) + lines.append( + ".img-wrap img { max-width: 100%; height: auto; }" + ) + lines.append( + ".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }" + ) + + return "\n".join(lines) + + +# ================================================================ +# 데읎터 표 Ʞ볞 CSS +# ================================================================ + +def _table_base_css(ti: dict) -> str: + """표 Ʞ볞 — '표낎용' 슀타음 charPr에서 추출""" + tbl_charpr = _resolve_style_charpr(ti, "표낎용") + tbl_parapr = _resolve_style_parapr(ti, "표낎용") + + size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0 + line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%" + align = _parapr_align(tbl_parapr) if tbl_parapr else "justify" + + border_fills = ti.get("border_fills", {}) + if border_fills: + # bf-{id} 큎래슀가 셀별 테두늬륌 닎당 → Ʞ볞값은 none + # (하드윔딩 border륌 넣윌멎 bf 큎래슀볎닀 specificity가 높아 덮얎씀) + border_rule = "border: none;" + else: + # border_fills 추출 싀팚 시에만 폎백 + border_rule = "border: 1px solid #000;" + + return ( + "/* 데읎터 표 */\n" + ".data-table {\n" + " width: 100%; border-collapse: collapse; margin: 4mm 0;\n" + "}\n" + ".data-table th, .data-table td {\n" + f" {border_rule}\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" text-align: {align};\n" + " vertical-align: middle;\n" + "}\n" + ".data-table th {\n" + " font-weight: bold; text-align: center;\n" + "}" + ) + + +# ================================================================ +# borderFill → .bf-{id} CSS 큎래슀 +# ================================================================ + +def _border_fill_css(ti: dict) -> str: + """★ v2.0: NONE-only bf도 큎래슀 생성 (border: none 명시)""" + border_fills = ti.get("border_fills", {}) + if not border_fills: + return "" + + parts = ["/* borderFill → CSS 큎래슀 */"] + + for bf_id, bf in border_fills.items(): + rules = [] + + css_dict = bf.get("css", {}) + for prop, val in css_dict.items(): + if val: + # NONE도 포핚 (border: none 명시) + rules.append(f" {prop}: {val};") + + # background + if "background-color" not in css_dict: + bg = bf.get("background", "") + if bg and bg.lower() not in ("", "none", "transparent", + "#ffffff", "#fff"): + rules.append(f" background-color: {bg};") + + if rules: + parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: charPr → .cpr-{id} CSS 큎래슀 +# ================================================================ + +def _char_pr_css(ti: dict, fm: dict) -> str: + """charPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포핚. + HTML에서 등윌로 ì°žì¡°. + """ + char_styles = ti.get("char_styles", []) + if not char_styles: + return "" + + parts = ["/* charPr → CSS 큎래슀 (Ꞁ자 몚양) */"] + + for cs in char_styles: + cid = cs.get("id") + rules = [] + + # font-family + ff = _charpr_font_family(cs, fm) + if ff: + rules.append(f" font-family: {ff};") + + # font-size + pt = cs.get("height_pt") + if pt: + rules.append(f" font-size: {pt}pt;") + + # bold + if cs.get("bold"): + rules.append(" font-weight: bold;") + + # italic + if cs.get("italic"): + rules.append(" font-style: italic;") + + # color + color = cs.get("textColor", "#000000") + if color and color.lower() != "#000000": + rules.append(f" color: {color};") + + # underline — type읎 NONE읎 아닌 싀제 밑쀄만 + underline = cs.get("underline", "NONE") + ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"} + if underline in ACTIVE_UNDERLINE: + rules.append(" text-decoration: underline;") + + # strikeout — shape="NONE" 또는 "3D"는 췚소선 아님 + # 싀제 췚소선: CONTINUOUS, DASH, DOT 등 선 슀타음만 + strikeout = cs.get("strikeout", "NONE") + ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT", + "DASH_DOT_DOT", "LONG_DASH", "DOUBLE"} + if strikeout in ACTIVE_STRIKEOUT: + rules.append(" text-decoration: line-through;") + + # ── 자간 (letter-spacing) ── + # HWPX spacing은 % 닚위: letter-spacing = height_pt × spacing / 100 + spacing_pct = cs.get("spacing", {}).get("hangul", 0) + if spacing_pct != 0 and pt: + ls_val = round(pt * spacing_pct / 100, 2) + rules.append(f" letter-spacing: {ls_val}pt;") + + # ── 장평 (scaleX) ── + # HWPX ratio는 Ꞁ자 폭 비윚 (100=Ʞ볞). CSS transform윌로 변환 + ratio_pct = cs.get("ratio", {}).get("hangul", 100) + if ratio_pct != 100: + rules.append(f" transform: scaleX({ratio_pct / 100});") + rules.append(" display: inline-block;") # scaleX 적용 필수 + + if rules: + parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: paraPr → .ppr-{id} CSS 큎래슀 +# ================================================================ + +def _para_pr_css(ti: dict) -> str: + """paraPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포핚. + HTML에서

                                    등윌로 ì°žì¡°. + """ + para_styles = ti.get("para_styles", []) + if not para_styles: + return "" + + parts = ["/* paraPr → CSS 큎래슀 (묞닚 몚양) */"] + + for ps in para_styles: + pid = ps.get("id") + rules = [] + + # text-align + align = _parapr_align(ps) + if align: + rules.append(f" text-align: {align};") + + # line-height + lh = _parapr_line_height(ps) + if lh: + rules.append(f" line-height: {lh};") + + # text-indent + margin = ps.get("margin", {}) + indent_hu = margin.get("indent_hu", 0) + if indent_hu: + indent_mm = indent_hu * HU_TO_MM + rules.append(f" text-indent: {indent_mm:.1f}mm;") + + # margin-left + left_hu = margin.get("left_hu", 0) + if left_hu: + left_mm = left_hu * HU_TO_MM + rules.append(f" margin-left: {left_mm:.1f}mm;") + + # margin-right + right_hu = margin.get("right_hu", 0) + if right_hu: + right_mm = right_hu * HU_TO_MM + rules.append(f" margin-right: {right_mm:.1f}mm;") + + # spacing before/after + before = margin.get("before_hu", 0) + if before: + rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;") + after = margin.get("after_hu", 0) + if after: + rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;") + + if rules: + parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: named style → .sty-{id} CSS 큎래슀 +# ================================================================ + +def _named_style_css(ti: dict) -> str: + """styles 목록 → .sty-{id} CSS 큎래슀. + + 각 style은 charPrIDRef + paraPrIDRef 조합. + → .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의믞. + HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 ì°žì¡°. + """ + styles = ti.get("styles", []) + if not styles: + return "" + + parts = ["/* named styles */"] + + for s in styles: + sid = s.get("id") + name = s.get("name", "") + cpr_id = s.get("charPrIDRef") + ppr_id = s.get("paraPrIDRef") + + # 죌석윌로 맀핑 Ʞ록 + parts.append( + f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */" + ) + + return "\n".join(parts) + + +# ================================================================ +# 표 상섞 CSS (ì—Ž 너비, 셀 팚딩) +# ================================================================ + +def _table_detail_css(ti: dict, sm: dict = None) -> str: + if not sm: + return "" + + body_indices = sm.get("body_tables", []) + tables = ti.get("tables", []) + if not body_indices or not tables: + return "" + + parts = ["/* 표 상섞 (tools 추출값) */"] + + for tbl_num, tbl_idx in enumerate(body_indices, 1): + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + cls = f"tbl-{tbl_num}" + + # ì—Ž 너비 + col_pcts = tbl.get("colWidths_pct", []) + if col_pcts: + for c_idx, pct in enumerate(col_pcts): + parts.append( + f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}" + ) + + # 셀 팚딩 + cm = _first_cell_margin(tbl) + if cm: + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + parts.append( + f".{cls} td, .{cls} th {{\n" + f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n" + f"}}" + ) + + # 헀더행 높읎 + first_row = tbl.get("rows", [[]])[0] + if first_row: + h_hu = first_row[0].get("height_hu", 0) + if h_hu > 0: + h_mm = h_hu * HU_TO_MM + parts.append( + f".{cls} thead th {{ height: {h_mm:.1f}mm; }}" + ) + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# 볎조 핚수 +# ================================================================ + +def _build_font_map(ti: dict) -> dict: + """fonts → {(lang, id): face_name} 딕셔너늬""" + fm = {} + for lang, flist in ti.get("fonts", {}).items(): + if isinstance(flist, list): + for f in flist: + fm[(lang, f.get("id", 0))] = f.get("face", "") + return fm + + +def _charpr_font_family(charpr: dict, fm: dict) -> str: + """charPr의 fontRef → 싀제 font-family CSS 값""" + if not charpr: + return "'맑은 고딕', sans-serif" + + fr = charpr.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + latin_id = fr.get("latin", 0) + + hangul_face = fm.get(("HANGUL", hangul_id), "") + latin_face = fm.get(("LATIN", latin_id), "") + + faces = [] + if hangul_face: + faces.append(f"'{hangul_face}'") + if latin_face and latin_face != hangul_face: + faces.append(f"'{latin_face}'") + faces.append("sans-serif") + + return ", ".join(faces) + + +def _resolve_style_charpr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → charPr dict 핎석""" + styles = ti.get("styles", []) + char_styles = ti.get("char_styles", []) + + for s in styles: + if s.get("name") == style_name: + cpr_id = s.get("charPrIDRef") + for cs in char_styles: + if cs.get("id") == cpr_id: + return cs + + # 못 찟윌멎 charPr[0] (바탕Ꞁ Ʞ볞) + return char_styles[0] if char_styles else {} + + +def _resolve_style_parapr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → paraPr dict 핎석""" + styles = ti.get("styles", []) + para_styles = ti.get("para_styles", []) + + for s in styles: + if s.get("name") == style_name: + ppr_id = s.get("paraPrIDRef") + for ps in para_styles: + if ps.get("id") == ppr_id: + return ps + + return para_styles[0] if para_styles else {} + + +def _find_title_charpr(ti: dict) -> dict: + """제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폎백). + + 헀드띌읞 폰튾 or 가장 큰 크Ʞ Ʞ쀀. + """ + headline_keywords = ["헀드띌읞", "headline", "제목", "title"] + fm = _build_font_map(ti) + + best = {} + best_pt = 0 + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + fr = cs.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + face = fm.get(("HANGUL", hangul_id), "").lower() + + # 헀드띌읞 폰튞멎 우선 + if any(kw in face for kw in headline_keywords): + if pt > best_pt: + best_pt = pt + best = cs + + # 헀드띌읞 폰튾 못 찟윌멎 가장 큰 것 + if not best: + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + if pt > best_pt: + best_pt = pt + best = cs + + return best + + +def _parapr_line_height(parapr: dict) -> str: + """paraPr → CSS line-height""" + if not parapr: + return "160%" + ls = parapr.get("lineSpacing", {}) + ls_type = ls.get("type", "PERCENT") + ls_val = ls.get("value", 160) + if ls_type == "PERCENT": + return f"{ls_val}%" + elif ls_type == "FIXED": + return f"{ls_val / 100:.1f}pt" + else: + return f"{ls_val}%" + + +def _parapr_align(parapr: dict) -> str: + """paraPr → CSS text-align""" + if not parapr: + return "justify" + align = parapr.get("align", "JUSTIFY") + return { + "JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right", + "CENTER": "center", "DISTRIBUTE": "justify", + "DISTRIBUTE_SPACE": "justify" + }.get(align, "justify") + + +def _hf_cell_padding(hf_info: dict | None) -> str: + if not hf_info or not hf_info.get("table"): + return "padding: 2px 4px;" + rows = hf_info["table"].get("rows", []) + if not rows or not rows[0]: + return "padding: 2px 4px;" + cm = rows[0][0].get("cellMargin", {}) + if not cm: + return "padding: 2px 4px;" + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;" + + +def _first_cell_margin(tbl: dict) -> dict | None: + for row in tbl.get("rows", []): + for cell in row: + cm = cell.get("cellMargin") + if cm: + return cm + return None + + +def _mm(val) -> float: + if isinstance(val, (int, float)): + return float(val) + try: + return float(str(val).replace("mm", "").strip()) + except (ValueError, TypeError): + return 20.0 \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/template/__init__.py b/03. Code/geulbeot_8th/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/template/html_table_template_css.txt b/03. Code/geulbeot_8th/handlers/template/html_table_template_css.txt new file mode 100644 index 0000000..1868522 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/template/html_table_template_css.txt @@ -0,0 +1,1442 @@ +- type 1 table + +1) HTML + +

                                    HTML table advanced features and accessibility

                                    + +

                                    Origin: HTML table advanced features and accessibility - Learn web development | MDN.

                                    + +

                                    Adding a <caption>, and structure with <thead>, <tfoot> and <tbody>

                                    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                    How I chose to spend my money
                                    PurchaseLocationDateEvaluationCost (€)
                                    HaircutHairdresser12/09Great idea30
                                    LasagnaRestaurant12/09Regrets18
                                    ShoesShoeshop13/09Big regrets65
                                    ToothpasteSupermarket13/09Good5
                                    SUM118
                                    + +

                                    Using the scope attribute

                                    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                    Items Sold August 2016
                                      ClothesAccessories
                                      TrousersSkirtsDressesBraceletsRings
                                    BelgiumAntwerp5622437223
                                    Gent4618506115
                                    Brussels5127386928
                                    The NetherlandsAmsterdam8934698538
                                    Utrecht8012433619
                                    + +

                                    Using the id and headers attributes

                                    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                    Items Sold August 2016
                                      ClothesAccessories
                                      TrousersSkirtsDressesBraceletsRings
                                    BelgiumAntwerp5622437223
                                    Gent4618506115
                                    Brussels5127386928
                                    The NetherlandsAmsterdam8934698538
                                    Utrecht8012433619
                                    + + +2) CSS +@import "https://germanfrelo.github.io/base-css-stylesheet/base.css" layer(base); +@import "https://codepen.io/germanfrelo/pen/mdMYKza.css" layer(styles); + +:root { + --page-max-inline-size: 100%; +} + +body { + padding-block: 2rem; +} + +caption { + text-align: start; +} + + + +- type 2 table + +1) HTML +
                                    +

                                    CSS responsive table

                                    +

                                    ...with fixed column and row headers and scroll snap. - @scottjehl

                                    +
                                    +
                                    +
                                    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                    Col HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol Header
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                    +
                                    + +
                                    +
                                    + Site footer +
                                    + +2) CSS +body { + font: 90%/1.4 system-ui; + margin: 0; + font-family: sans-serif; +} +header { + padding: 7vh 5vw; + border-bottom: 1px solid #ddd; +} +header h1, +header p { + margin: 0; +} +footer { + padding: 7vh 5vw; + border-top: 1px solid #ddd; +} +aside { + padding: 7vh 5vw; +} +.primary { + overflow: auto; + scroll-snap-type: both mandatory; + height: 80vh; +} +@media (min-width: 40em) { + main { + display: flex; + } + aside { + flex: 0 1 20vw; + order: 1; + border-right: 1px solid #ddd; + } + .primary { + order: 2; + } +} +table { + border-collapse: collapse; + border: 0; +} +th, +td { + border: 1px solid #aaa; + background-clip: padding-box; + scroll-snap-align: start; +} +tbody tr:last-child th, +tbody tr:last-child td { + border-bottom: 0; +} +thead { + z-index: 1000; + position: relative; +} +th, +td { + padding: 0.6rem; + min-width: 6rem; + text-align: left; + margin: 0; +} +thead th { + position: sticky; + top: 0; + border-top: 0; + background-clip: padding-box; +} +thead th.pin { + left: 0; + z-index: 1001; + border-left: 0; +} +tbody th { + background-clip: padding-box; + border-left: 0; +} +tbody { + z-index: 10; + position: relative; +} +tbody th { + position: sticky; + left: 0; +} +thead th, +tbody th { + background-color: #f8f8f8; +} + + +- type 3 tabel +1) HTML + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                    Developers Rating
                                    AvatarGroupNamePointsControl
                                    imgNinjaAsma Ad120 + + +
                                    imgShadesDavid180 + + +
                                    imgAlex160 + + +
                                    imgValhala Kawtar190 + + +
                                    imgKatara110 + + +
                                    imgUnionAshraf90 + + +
                                    + +2) CSS +table { + width: 700px; + text-align: center; + border: 1px solid #fff; + border-spacing: 1px; + font-family: 'Cairo', sans-serif; + margin: auto; +} + +caption { + font-weight: bold; +} + +table td { + padding: 10px; + background-color: #eee; +} + +table th { + background-color: #333; + color: #fff; + padding: 10px; +} + +img { + width: 90px; + height: 90px; +} + +.view, +.delete { + border: none; + padding: 5px 10px; + color: #fff; + font-weight: bold; +} + +.view { + background-color: #03A9F4; +} + +.delete { + background-color: #E91E63; +} + +.tablefoot { + padding: 0; + border-bottom: 3px solid #009688; +} + +HTML 표 슀타음링은 섞계에서 가장 맀력적읞 음읎 아니지만, 때로는 우늬 몚두가 핎알할 음입니닀. 읎 Ʞ사에서는 특정 표 슀타음링 Ʞ술을 강조 표시하여 HTML 표륌 볎Ʞ좋게 만드는 방법에 대한 안낎서륌 제공합니닀. + +전제조걎: HTML Ʞ볞 사항 (HTML 소개 학습), HTML 표 에 대한 지식 및 CSS 작동 방식에 대한 읎핎 (CSS 첫 번짞 닚계 학습.) +목적: HTML 표륌 횚곌적윌로 슀타음링하는 방법 ë°°ìš°êž°. +In this article +전형적읞 HTML 표 +우늬의 표 슀타음링 +적극적읞 학습: 나만의 표 슀타음 +표 슀타음링 빠륎게 하는 팁 +요앜 +Auth0 +Make login our problem. Not yours. +Your time is valuable. Use it to focus on your app, and let us handle login (and much more). +Try it Free Now +Ad +전형적읞 HTML 표 +전형적읞 HTML 표륌 삎펎 뎅시닀. Ꞁ쎄요, 음반적읞 표의 예듀은 — 신발, 날씚 또는 직원듀에 ꎀ한 것입니닀; 우늬는 영국의 유명한 펑크 밎드에 ꎀ한것을 만듀얎서 더 흥믞롭게 만듀Ʞ로 결정했습니닀. 윔드는 닀음곌 같습니닀. + +html + +Copy + + + + + + + + + + + + + + + + + + + + + + + + + ... some rows removed for brevity + + + + + + + + + + + + + + +
                                    + A summary of the UK's most famous punk bands +
                                    BandYear formedNo. of AlbumsMost famous song
                                    Buzzcocks19769Ever fallen in love (with someone you shouldn't've)
                                    The Clash19766London Calling
                                    The Stranglers197417No More Heroes
                                    Total albums77
                                    +scope,
                                  ,
                                  요소읞 n번짞 자식 요소
                                  및 요소에 padding 을 섀정했습니닀 — 읎렇게 하멎 데읎터 항목에 숚을 공간읎 생깁니닀. 표륌 훚씬 읜Ʞ 쉜게 볎읎게합니닀. + +읎 시점에서, 우늬 표는 읎믞 훚씬 좋아볎입니닀. + + + +간닚한 typography +읎제 텍슀튞륌 앜간 정늬핎 볎겠습니닀. + +우선, Google Fonts 에서 펑크 밮드 ꎀ렚 표에 적합한 Ꞁꌎ을 찟았습니닀. 원하는 겜우 거Ʞ에 가서 닀륞 것을 찟을 수 있습니닀. 제공된 요소 및 custom font-family 선얞을 Google Fonts 에서 제공하는 선얞윌로 바꟞멎 됩니닀. + +뚌저, 닀음 요소륌 Ʞ졎 요소 바로 위의 HTML head 에 추가하십시였. + +html + +Copy + +읎제 읎전 CSS 아래의 style.css 파음에, 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* typography */ + +html { + font-family: "helvetica neue", helvetica, arial, sans-serif; +} + +thead th, +tfoot th { + font-family: "Rock Salt", cursive; +} + +th { + letter-spacing: 2px; +} + +td { + letter-spacing: 1px; +} + +tbody td { + text-align: center; +} + +tfoot th { + text-align: right; +} +여Ʞ에서는 표에 특별한 것은 없습니닀. 우늬는 음반적윌로 쉜게 읜을 수 있도록 Ꞁꌎ 슀타음을 조정합니닀. + +전역 sans-serif Ꞁꌎ 슀택을 섀정했습니닀; 읎것은 순전히 묞첎 선택입니닀. 또한
                                  및 요소에 선형 귞띌데읎션을 추가하여 앜간의 질감을 개선하였, 밝은 볎띌색 테두늬륌 부여했습니닀. 쀑첩된 요소륌 여러 개의 쀑첩된 요소륌 사용하여 슀타음을 서로 겹칠 수 있는 것읎 유용합니닀. 예, 여러 배겜 읎믞지륌 사용하여
                                  ) 에서 width 륌 섀정하여 ì—Ž 너비륌 쉜게 섀정할 수 있는 예잡 가능한 표 레읎아웃을 작성합니닀. +border-collapse: collapse 륌 사용하여 표 요소 테두늬륌 서로 접얎서 깔끔하게 만듀 수 있습니닀. +
                                  및 텍슀튞륌 정렬하여, 더 깜끔하고 쉜게 따띌할 수 있도록 하십시였. \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/template/processor.py b/03. Code/geulbeot_8th/handlers/template/processor.py new file mode 100644 index 0000000..f8cb6d1 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/template/processor.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +""" +템플늿 처늬 로직 (v3 - 싀제 구조 정확 분석) +- HWPX 파음의 싀제 표 구조, 읎믞지 배겜, 테두늬 정확히 추출 +- ARGB 8자늬 색상 정규화 +- NONE 테두늬 색상 제왞 +""" + +import os +import json +import uuid +import shutil +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from collections import Counter, defaultdict + +# 템플늿 저장 겜로 +TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store' +TEMPLATES_DIR.mkdir(exist_ok=True) + +# HWP 명섞서 êž°ë°˜ 상수 +LINE_TYPES = { + 'NONE': '없음', + 'SOLID': '싀선', + 'DASH': 'ꞎ 점선', + 'DOT': '점선', + 'DASH_DOT': '-.-.-.-.', + 'DASH_DOT_DOT': '-..-..-..', + 'DOUBLE_SLIM': '2쀑선', + 'SLIM_THICK': '가는선+굵은선', + 'THICK_SLIM': '굵은선+가는선', + 'SLIM_THICK_SLIM': '가는선+굵은선+가는선', + 'WAVE': '묌결', + 'DOUBLE_WAVE': '묌결 2쀑선', +} + + +class TemplateProcessor: + """템플늿 처늬 큎래슀 (v3)""" + + NS = { + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'hs': 'http://www.hancom.co.kr/hwpml/2011/section', + } + + def __init__(self): + self.templates_dir = TEMPLATES_DIR + self.templates_dir.mkdir(exist_ok=True) + + # ========================================================================= + # 공개 API + # ========================================================================= + + def get_list(self) -> Dict[str, Any]: + """저장된 템플늿 목록""" + templates = [] + for item in self.templates_dir.iterdir(): + if item.is_dir(): + meta_path = item / 'meta.json' + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + templates.append({ + 'id': meta.get('id', item.name), + 'name': meta.get('name', item.name), + 'features': meta.get('features', []), + 'created_at': meta.get('created_at', '') + }) + except: + pass + templates.sort(key=lambda x: x.get('created_at', ''), reverse=True) + return {'templates': templates} + + def analyze(self, file, name: str) -> Dict[str, Any]: + """템플늿 파음 분석 및 저장""" + filename = file.filename + ext = Path(filename).suffix.lower() + + if ext not in ['.hwpx', '.hwp', '.pdf']: + return {'error': f'지원하지 않는 파음 형식: {ext}'} + + template_id = str(uuid.uuid4())[:8] + template_dir = self.templates_dir / template_id + template_dir.mkdir(exist_ok=True) + + try: + original_path = template_dir / f'original{ext}' + file.save(str(original_path)) + + if ext == '.hwpx': + style_data = self._analyze_hwpx(original_path, template_dir) + else: + style_data = self._analyze_fallback(ext) + + if 'error' in style_data: + shutil.rmtree(template_dir) + return style_data + + # 특징 추출 + features = self._extract_features(style_data) + + # 메타 저장 + meta = { + 'id': template_id, + 'name': name, + 'original_file': filename, + 'file_type': ext, + 'features': features, + 'created_at': datetime.now().isoformat() + } + (template_dir / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # 슀타음 저장 + (template_dir / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # CSS 저장 + css = style_data.get('css', '') + css_dir = template_dir / 'css' + css_dir.mkdir(exist_ok=True) + (css_dir / 'template.css').write_text(css, encoding='utf-8') + + return { + 'success': True, + 'template': { + 'id': template_id, + 'name': name, + 'features': features, + 'created_at': meta['created_at'] + } + } + except Exception as e: + if template_dir.exists(): + shutil.rmtree(template_dir) + raise e + + def delete(self, template_id: str) -> Dict[str, Any]: + """템플늿 삭제""" + template_dir = self.templates_dir / template_id + if not template_dir.exists(): + return {'error': '템플늿을 찟을 수 없습니닀'} + shutil.rmtree(template_dir) + return {'success': True, 'deleted': template_id} + + def get_style(self, template_id: str) -> Optional[Dict[str, Any]]: + """템플늿 슀타음 반환""" + style_path = self.templates_dir / template_id / 'style.json' + if not style_path.exists(): + return None + return json.loads(style_path.read_text(encoding='utf-8')) + + # ========================================================================= + # HWPX 분석 (핵심) + # ========================================================================= + + def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]: + """HWPX 분석 - 싀제 구조 정확히 추출""" + extract_dir = template_dir / 'extracted' + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + zf.extractall(extract_dir) + + result = { + 'version': 'v3', + 'fonts': {}, + 'colors': { + 'background': [], + 'border': [], + 'text': [] + }, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': {}, + 'css': '' + } + + # 1. header.xml 분석 + header_path = extract_dir / 'Contents' / 'header.xml' + if header_path.exists(): + self._parse_header(header_path, result) + + # 2. section0.xml 분석 + section_path = extract_dir / 'Contents' / 'section0.xml' + if section_path.exists(): + self._parse_section(section_path, result) + + # 3. 슀타음 요앜 생성 + result['style_summary'] = self._create_style_summary(result) + + # 4. CSS 생성 + result['css'] = self._generate_css(result) + + return result + + finally: + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + def _parse_header(self, header_path: Path, result: Dict): + """header.xml 파싱 - 폰튾, borderFill""" + tree = ET.parse(header_path) + root = tree.getroot() + + # 폰튾 + for fontface in root.findall('.//hh:fontface', self.NS): + if fontface.get('lang') == 'HANGUL': + for font in fontface.findall('hh:font', self.NS): + result['fonts'][font.get('id')] = font.get('face') + + # borderFill + for bf in root.findall('.//hh:borderFill', self.NS): + bf_id = bf.get('id') + bf_data = self._parse_border_fill(bf, result) + result['border_fills'][bf_id] = bf_data + + def _parse_border_fill(self, bf, result: Dict) -> Dict: + """개별 borderFill 파싱""" + bf_id = bf.get('id') + data = { + 'id': bf_id, + 'type': 'empty', + 'background': None, + 'image': None, + 'borders': {} + } + + # 읎믞지 배겜 + img_brush = bf.find('.//hc:imgBrush', self.NS) + if img_brush is not None: + img = img_brush.find('hc:img', self.NS) + if img is not None: + data['type'] = 'image' + data['image'] = { + 'ref': img.get('binaryItemIDRef'), + 'effect': img.get('effect') + } + + # 닚색 배겜 + win_brush = bf.find('.//hc:winBrush', self.NS) + if win_brush is not None: + face_color = self._normalize_color(win_brush.get('faceColor')) + if face_color and face_color != 'none': + if data['type'] == 'empty': + data['type'] = 'solid' + data['background'] = face_color + if face_color not in result['colors']['background']: + result['colors']['background'].append(face_color) + + # 4방향 테두늬 + for side in ['top', 'bottom', 'left', 'right']: + border = bf.find(f'hh:{side}Border', self.NS) + if border is not None: + border_type = border.get('type', 'NONE') + width = border.get('width', '0.1 mm') + color = self._normalize_color(border.get('color', '#000000')) + + data['borders'][side] = { + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'width_mm': self._parse_width(width), + 'color': color + } + + # 볎읎는 테두늬만 색상 수집 + if border_type != 'NONE': + if data['type'] == 'empty': + data['type'] = 'border_only' + if color and color not in result['colors']['border']: + result['colors']['border'].append(color) + + # 특수 테두늬 수집 + if border_type not in ['SOLID', 'NONE']: + result['special_borders'].append({ + 'bf_id': bf_id, + 'side': side, + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'color': color + }) + + return data + + def _parse_section(self, section_path: Path, result: Dict): + """section0.xml 파싱 - 표 구조""" + tree = ET.parse(section_path) + root = tree.getroot() + + border_fills = result['border_fills'] + + for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'): + table_data = { + 'rows': int(tbl.get('rowCnt', 0)), + 'cols': int(tbl.get('colCnt', 0)), + 'cells': [], + 'structure': { + 'header_row_style': None, + 'first_col_style': None, + 'body_style': None, + 'has_image_cells': False + } + } + + # 셀별 분석 + cell_by_position = {} + for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'): + cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr') + if cell_addr is None: + continue + + row = int(cell_addr.get('rowAddr', 0)) + col = int(cell_addr.get('colAddr', 0)) + bf_id = tc.get('borderFillIDRef') + bf_info = border_fills.get(bf_id, {}) + + # 텍슀튞 추출 + text = '' + for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'): + if t.text: + text += t.text + + cell_data = { + 'row': row, + 'col': col, + 'bf_id': bf_id, + 'bf_type': bf_info.get('type'), + 'background': bf_info.get('background'), + 'image': bf_info.get('image'), + 'text_preview': text[:30] if text else '' + } + + table_data['cells'].append(cell_data) + cell_by_position[(row, col)] = cell_data + + if bf_info.get('type') == 'image': + table_data['structure']['has_image_cells'] = True + + # 구조 분석: 헀더행, 첫엎 슀타음 + self._analyze_table_structure(table_data, cell_by_position, border_fills) + + result['tables'].append(table_data) + + def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict): + """표 구조 분석 - 헀더행/첫엎 슀타음 파악""" + rows = table_data['rows'] + cols = table_data['cols'] + + if rows == 0 or cols == 0: + return + + # 첫 행 (헀더) 분석 + header_styles = [] + for c in range(cols): + cell = cells.get((0, c)) + if cell: + header_styles.append(cell.get('bf_id')) + + if header_styles: + # 가장 많읎 쓰읞 슀타음 + most_common = Counter(header_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['header_row_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background'), + 'borders': bf.get('borders', {}) + } + + # 첫 ì—Ž 분석 (행 1부터) + first_col_styles = [] + for r in range(1, rows): + cell = cells.get((r, 0)) + if cell: + first_col_styles.append(cell.get('bf_id')) + + if first_col_styles: + most_common = Counter(first_col_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['first_col_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') + } + + # 볞묞 셀 슀타음 (첫엎 제왞) + body_styles = [] + for r in range(1, rows): + for c in range(1, cols): + cell = cells.get((r, c)) + if cell: + body_styles.append(cell.get('bf_id')) + + if body_styles: + most_common = Counter(body_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + table_data['structure']['body_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') if bf else None + } + + def _create_style_summary(self, result: Dict) -> Dict: + """AI 프롬프튞용 슀타음 요앜""" + summary = { + '폰튾': list(result['fonts'].values())[:3], + '색상': { + '배겜색': result['colors']['background'], + '테두늬색': result['colors']['border'] + }, + '표_슀타음': [], + '특수_테두늬': [] + } + + # 표별 슀타음 요앜 + for i, tbl in enumerate(result['tables']): + tbl_summary = { + '표번혞': i + 1, + '크Ʞ': f"{tbl['rows']}행 × {tbl['cols']}ì—Ž", + '읎믞지셀': tbl['structure']['has_image_cells'] + } + + header = tbl['structure'].get('header_row_style') + if header: + tbl_summary['헀더행'] = f"배겜={header.get('background')}" + + first_col = tbl['structure'].get('first_col_style') + if first_col: + tbl_summary['첫엎'] = f"배겜={first_col.get('background')}" + + body = tbl['structure'].get('body_style') + if body: + tbl_summary['볞묞'] = f"배겜={body.get('background') or '없음'}" + + summary['표_슀타음'].append(tbl_summary) + + # 특수 테두늬 요앜 + seen = set() + for sb in result['special_borders']: + key = f"{sb['type_name']} {sb['width']} {sb['color']}" + if key not in seen: + seen.add(key) + summary['특수_테두늬'].append(key) + + return summary + + def _generate_css(self, result: Dict) -> str: + """CSS 생성 - 싀제 구조 반영""" + fonts = list(result['fonts'].values())[:2] + font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'" + + bg_colors = result['colors']['background'] + header_bg = bg_colors[0] if bg_colors else '#D6D6D6' + + # 특수 테두늬에서 2쀑선 ì°Ÿêž° + double_border = None + for sb in result['special_borders']: + if 'DOUBLE' in sb['type']: + double_border = sb + break + + css = f"""/* 템플늿 슀타음 v3 - HWPX 구조 êž°ë°˜ */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +:root {{ + --font-primary: 'Noto Sans KR', {font_family}, sans-serif; + --color-header-bg: {header_bg}; + --color-border: #000000; +}} + +body {{ + font-family: var(--font-primary); + font-size: 10pt; + line-height: 1.6; + color: #000000; +}} + +.sheet {{ + width: 210mm; + min-height: 297mm; + padding: 20mm; + margin: 10px auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +}} + +@media print {{ + .sheet {{ margin: 0; box-shadow: none; page-break-after: always; }} +}} + +/* 표 Ʞ볞 */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 9pt; +}} + +th, td {{ + border: 0.12mm solid var(--color-border); + padding: 6px 8px; + vertical-align: middle; +}} + +/* 헀더 행 */ +thead th, tr:first-child th, tr:first-child td {{ + background-color: var(--color-header-bg); + font-weight: bold; + text-align: center; +}} + +/* 첫 ì—Ž (구분 ì—Ž) - 배겜색 */ +td:first-child {{ + background-color: var(--color-header-bg); + text-align: center; + font-weight: 500; +}} + +/* 볞묞 셀 - 배겜 없음 */ +td:not(:first-child) {{ + background-color: transparent; +}} + +/* 2쀑선 테두늬 (헀더 하당) */ +thead tr:last-child th, +thead tr:last-child td, +tr:first-child th, +tr:first-child td {{ + border-bottom: 0.5mm double var(--color-border); +}} +""" + return css + + # ========================================================================= + # 유틞늬티 + # ========================================================================= + + def _normalize_color(self, color: str) -> str: + """ARGB 8자늬 → RGB 6자늬""" + if not color or color == 'none': + return color + color = color.strip() + # #AARRGGBB → #RRGGBB + if color.startswith('#') and len(color) == 9: + return '#' + color[3:] + return color + + def _parse_width(self, width_str: str) -> float: + """너비 묞자엎 → mm""" + if not width_str: + return 0.1 + try: + return float(width_str.split()[0]) + except: + return 0.1 + + def _extract_features(self, data: Dict) -> List[str]: + """특징 목록""" + features = [] + + fonts = list(data.get('fonts', {}).values()) + if fonts: + features.append(f"폰튾: {', '.join(fonts[:2])}") + + bg_colors = data.get('colors', {}).get('background', []) + if bg_colors: + features.append(f"배겜색: {', '.join(bg_colors[:2])}") + + tables = data.get('tables', []) + if tables: + has_img = any(t['structure']['has_image_cells'] for t in tables) + if has_img: + features.append("읎믞지 배겜 셀") + + special = data.get('special_borders', []) + if special: + types = set(s['type_name'] for s in special) + features.append(f"특수 테두늬: {', '.join(list(types)[:2])}") + + return features if features else ['Ʞ볞 템플늿'] + + def _analyze_fallback(self, ext: str) -> Dict: + """HWP, PDF Ʞ볞 분석""" + return { + 'version': 'v3', + 'fonts': {'0': '맑은 고딕'}, + 'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']}, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': { + '폰튾': ['맑은 고딕'], + '색상': {'배겜색': [], '테두늬색': ['#000000']}, + '표_슀타음': [], + '특수_테두늬': [] + }, + 'css': self._get_default_css(), + 'note': f'{ext} 파음은 Ʞ볞 분석만 지원. HWPX 권장.' + } + + def _get_default_css(self) -> str: + return """/* Ʞ볞 슀타음 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; } +.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; } +table { width: 100%; border-collapse: collapse; } +th, td { border: 0.5pt solid #000; padding: 8px; } +th { background: #D6D6D6; } +""" \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/template/prompts/analyze_template.txt b/03. Code/geulbeot_8th/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 묞서 템플늿 분석 전묞가입니닀. + +죌얎진 HWPX/HWP/PDF 템플늿의 구조륌 분석하여 닀음 정볎륌 추출핎죌섞요: + +1. 제목 슀타음 (H1~H6) + - 폰튞명, 크Ʞ(pt), 굵Ʞ, 색상 + - 정렬 방식 + - 번혞 첎계 (제1장, 1.1, 가. 등) + +2. 볞묞 슀타음 + - Ʞ볞 폰튾, 크Ʞ, 쀄간격 + - 듀여쓰Ʞ + +3. 표 슀타음 + - 헀더 배겜색 + - 테두늬 슀타음 (선 두께, 색상) + - 읎쀑선 사용 여부 + +4. 귞늌/캡션 슀타음 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페읎지 구성 + - 표지 유묎 + - 목찚 유묎 + - 뚞늬말/ꌬ늬말 + +분석 결곌륌 JSON 형식윌로 출력핎죌섞요. \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/template_manager.py b/03. Code/geulbeot_8th/handlers/template_manager.py new file mode 100644 index 0000000..1a6de40 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/template_manager.py @@ -0,0 +1,1008 @@ +# -*- coding: utf-8 -*- +""" +템플늿 ꎀ늬자 (Template Manager) v5.2 +- 템플늿의 독늜적 CRUD (생성/조회/삭제/교첎) +- 묞서 유형(DocType)곌 분늬된 저장 구조 +- HWPX에서 템플늿 추출 → templates/user/templates/{tpl_id}/ 에 저장 + +★ v5.2 변겜: + - _build_body_html() 재섀계: content_order êž°ë°˜ 볞묞 생성 + → 묞닚·읎믞지·표륌 원볞 순서 귞대로 HTML 조늜 + → content_order 없윌멎 Ʞ졎 legacy 방식 자동 fallback + - _build_title_block_html() 분늬 (재사용성) + +★ 저장 구조: +templates/user/ +├── doc_types/{type_id}/ +│ ├── config.json ← 유형 정볎 (맥띜/구조/가읎드) +│ └── template_id: "tpl_xxx" ← ì–Žë–€ 템플늿 찞조하는지 +│ +└── templates/{tpl_id}/ + ├── template.html ← HTML 곚격 + placeholder + ├── style.json ← 테두늬/폰튾/색상/여백/borderFill + └── meta.json ← 읎늄, 출처, 생성음 + +★ 사용 흐멄: +1) "템플늿 추가" → extract_and_save(hwpx_path, name) → tpl_id +2) "묞서 유형 추가" → doc_type_analyzer가 낎부적윌로 extract_and_save 혞출 +3) "템플늿 교첎" → change_template(type_id, new_tpl_id) +4) "묞서 생성" → load_template(tpl_id) → template.html + style.json +""" + +import json +import time +import shutil +from pathlib import Path +from typing import Optional + + +class TemplateManager: + """템플늿 독늜 ꎀ늬""" + + # Ʞ볞 겜로 + TEMPLATES_USER = Path('templates/user/templates') + TEMPLATES_DEFAULT = Path('templates/default/templates') + DOC_TYPES_USER = Path('templates/user/doc_types') + + def __init__(self, base_path: str = None): + if base_path: + self.TEMPLATES_USER = Path(base_path) / 'user' / 'templates' + self.TEMPLATES_DEFAULT = Path(base_path) / 'default' / 'templates' + self.DOC_TYPES_USER = Path(base_path) / 'user' / 'doc_types' + + # ================================================================ + # 핵심 API + # ================================================================ + + def extract_and_save(self, parsed: dict, name: str, + source_file: str = "", description: str = "") -> dict: + """ + HWPX 파싱 결곌에서 템플늿 추출 후 저장 + + Args: + parsed: HWPX 파서 결곌 (raw_xml, tables, section_xml, header_xml, footer_xml) + name: 템플늿 읎늄 (예: "GPD 발표Ʞ획서 양식") + source_file: 원볞 파음명 + description: 섀명 + + Returns: + {"success": True, "template_id": "tpl_xxx", "path": "...", "template_info": {...}} + """ + from .doc_template_analyzer import DocTemplateAnalyzer + + try: + analyzer = DocTemplateAnalyzer() + + # ① 구조 추출 (template_info) + template_info = analyzer.analyze(parsed) + + # ①-b semantic_map 생성 (표 역할 분류, 섹션 감지) + from . import semantic_mapper + semantic_map = semantic_mapper.generate(template_info, parsed) + + # ② HTML 생성 (semantic_map윌로 표 필터링) + template_html = self._generate_basic_html(template_info, parsed, semantic_map) + + # 저장 + tpl_id = f"tpl_{int(time.time())}" + tpl_path = self.TEMPLATES_USER / tpl_id + tpl_path.mkdir(parents=True, exist_ok=True) + + # template.html + (tpl_path / 'template.html').write_text(template_html, encoding='utf-8') + + # style.json (template_info + 추출된 슀타음) + style_data = { + "version": "v4", + "source": "doc_template_analyzer", + "template_info": template_info, + "css": "", # 추후 컀슀텀 CSS 였버띌읎드용 + "fonts": {}, + "colors": self._extract_colors(template_info), + "border_fills": template_info.get("border_fills", {}), + "tables": [], + "style_summary": {} + } + (tpl_path / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # meta.json + meta = { + "id": tpl_id, + "name": name, + "original_file": source_file, + "file_type": Path(source_file).suffix if source_file else ".hwpx", + "description": description, + "features": self._summarize_features(template_info, semantic_map), + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": "doc_template_analyzer" + } + (tpl_path / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # semantic_map.json + (tpl_path / 'semantic_map.json').write_text( + json.dumps(semantic_map, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "template_id": tpl_id, + "path": str(tpl_path), + "template_info": template_info, + "semantic_map": semantic_map, + "meta": meta + } + + except Exception as e: + import traceback + return {"error": str(e), "trace": traceback.format_exc()} + + def load_template(self, tpl_id: str) -> dict: + """ + 템플늿 로드 (template.html + style.json) + + Returns: + {"html": "...", "style": {...}, "meta": {...}} + """ + # 사용자 템플늿 → Ʞ볞 템플늿 순서로 탐색 + for base in [self.TEMPLATES_USER, self.TEMPLATES_DEFAULT]: + tpl_path = base / tpl_id + if tpl_path.exists(): + result = {} + + html_file = tpl_path / 'template.html' + if html_file.exists(): + result["html"] = html_file.read_text(encoding='utf-8') + + style_file = tpl_path / 'style.json' + if style_file.exists(): + result["style"] = json.loads(style_file.read_text(encoding='utf-8')) + + meta_file = tpl_path / 'meta.json' + if meta_file.exists(): + result["meta"] = json.loads(meta_file.read_text(encoding='utf-8')) + + result["template_id"] = tpl_id + result["path"] = str(tpl_path) + return result + + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + def list_templates(self) -> list: + """몚든 템플늿 목록 조회""" + templates = [] + + for base, is_default in [(self.TEMPLATES_DEFAULT, True), (self.TEMPLATES_USER, False)]: + if not base.exists(): + continue + for folder in sorted(base.iterdir()): + if not folder.is_dir(): + continue + meta_file = folder / 'meta.json' + if meta_file.exists(): + try: + meta = json.loads(meta_file.read_text(encoding='utf-8')) + meta["is_default"] = is_default + templates.append(meta) + except: + templates.append({ + "id": folder.name, + "name": folder.name, + "is_default": is_default + }) + + return templates + + def delete_template(self, tpl_id: str) -> dict: + """템플늿 삭제 (사용자 템플늿만)""" + tpl_path = self.TEMPLATES_USER / tpl_id + + if not tpl_path.exists(): + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + # 읎 템플늿을 찞조하는 DocType읎 있는지 확읞 + referencing = self._find_referencing_doc_types(tpl_id) + if referencing: + names = ', '.join(r['name'] for r in referencing[:3]) + return { + "error": f"읎 템플늿을 사용 쀑읞 묞서 유형읎 있습니닀: {names}", + "referencing_types": referencing + } + + shutil.rmtree(tpl_path) + return {"success": True, "deleted": tpl_id} + + def change_template(self, type_id: str, new_tpl_id: str) -> dict: + """ + 묞서 유형의 템플늿 교첎 + + Args: + type_id: 묞서 유형 ID + new_tpl_id: 새 템플늿 ID + """ + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + # 새 템플늿 졎재 확읞 + new_tpl = self.load_template(new_tpl_id) + if "error" in new_tpl: + return new_tpl + + # config 업데읎튞 + config = json.loads(config_path.read_text(encoding='utf-8')) + old_tpl_id = config.get("template_id", "") + config["template_id"] = new_tpl_id + config["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ") + + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "type_id": type_id, + "old_template_id": old_tpl_id, + "new_template_id": new_tpl_id + } + + def get_template_for_doctype(self, type_id: str) -> dict: + """묞서 유형에 연결된 템플늿 로드""" + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + # default에서도 탐색 + config_path = self.TEMPLATES_DEFAULT.parent / 'doc_types' / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + config = json.loads(config_path.read_text(encoding='utf-8')) + tpl_id = config.get("template_id") + + if not tpl_id: + # ★ 하위 혾환: template_id가 없윌멎 같은 폎더의 template.html 사용 + legacy_path = config_path.parent / 'template.html' + if legacy_path.exists(): + return { + "html": legacy_path.read_text(encoding='utf-8'), + "style": {}, + "meta": {"id": type_id, "name": "레거시 템플늿"}, + "template_id": None, + "legacy": True + } + return {"error": "연결된 템플늿읎 없습니닀"} + + return self.load_template(tpl_id) + + # ================================================================ + # 낎부 유틞 + # ================================================================ + + def _find_referencing_doc_types(self, tpl_id: str) -> list: + """특정 템플늿을 찞조하는 DocType 목록""" + result = [] + if not self.DOC_TYPES_USER.exists(): + return result + + for folder in self.DOC_TYPES_USER.iterdir(): + config_file = folder / 'config.json' + if config_file.exists(): + try: + config = json.loads(config_file.read_text(encoding='utf-8')) + if config.get("template_id") == tpl_id: + result.append({ + "id": config.get("id", folder.name), + "name": config.get("name", folder.name) + }) + except: + pass + return result + + + def _generate_basic_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """tools 추출 결곌 + style_generator → template.html 생성""" + # ① CSS 생성 (style_generator) + from . import style_generator + css = style_generator.generate_css(template_info, semantic_map) + + # ② 헀더 HTML + header_html = self._build_header_html(template_info.get("header")) + + # ③ 푾터 HTML + footer_html = self._build_footer_html(template_info.get("footer")) + + # ④ 볞묞 HTML (섹션 + 표) + body_html = self._build_body_html(template_info, parsed, semantic_map) + + # â‘€ 조늜 + html = f""" + + + +Template + + + +
                                  + +{header_html} + +{body_html} + +{footer_html} + +
                                  + +""" + return html + + # ── 볎조 메서드듀 ── + def _build_header_html(self, header_info: dict | None) -> str: + """header tools 추출값 → HTML + placeholder""" + if not header_info or not header_info.get("exists"): + return "" + + html = '
                                  \n' + + if header_info.get("type") == "table" and header_info.get("table"): + tbl = header_info["table"] + rows = tbl.get("rows", []) + col_pcts = tbl.get("colWidths_pct", []) + + # ★ 추가: colWidths_pct 없윌멎 셀 width_hu에서 계산 + if not col_pcts and rows: + widths = [c.get("width_hu", 0) for c in rows[0]] + total = sum(widths) + if total > 0: + col_pcts = [round(w / total * 100) for w in widths] + + html += '\n' + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + cell_text = cell.get("text", "").strip() # ★ 추가 + ph_name = f"HEADER_R{r_idx+1}_C{c_idx+1}" + + # ★ 수정: 텍슀튞 없는 셀은 비움 + if not cell_text and not lines: + content = "" + elif len(lines) > 1: + # 멀티띌읞 셀 → 각 띌읞별 placeholder + line_phs = [] + for l_idx in range(len(lines)): + line_phs.append(f"{{{{{ph_name}_LINE_{l_idx+1}}}}}") + content = "
                                  ".join(line_phs) + else: + content = f"{{{{{ph_name}}}}}" + + # colSpan/rowSpan + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + if cell.get("colSpan", 1) > 1: + attrs += f' colspan="{cell["colSpan"]}"' + if cell.get("rowSpan", 1) > 1: + attrs += f' rowspan="{cell["rowSpan"]}"' + + html += f' {content}\n' + html += '\n' + + html += '
                                  \n' + else: + # 텍슀튞형 헀더 + texts = header_info.get("texts", []) + for i in range(max(len(texts), 1)): + html += f'
                                  {{{{{f"HEADER_TEXT_{i+1}"}}}}}
                                  \n' + + html += '
                                  ' + return html + + def _build_footer_html(self, footer_info: dict | None) -> str: + """footer tools 추출값 → HTML + placeholder""" + if not footer_info or not footer_info.get("exists"): + return "" + + html = '' + return html + + def _build_body_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """볞묞 영역 HTML 생성. + + ★ v5.2: content_order가 있윌멎 원볞 순서 귞대로 조늜. + content_order 없윌멎 Ʞ졎 섹션+표 방식 (하위 혾환). + """ + content_order = template_info.get("content_order") + + if content_order and self._has_paragraph_content(content_order): + return self._build_body_from_content_order( + template_info, content_order, semantic_map + ) + else: + return self._build_body_legacy( + template_info, parsed, semantic_map + ) + + # ── content_order êž°ë°˜ 볞묞 생성 (v5.2+) ── + + def _has_paragraph_content(self, content_order: list) -> bool: + """content_order에 묞닚읎 있는지 (표만 있윌멎 legacy 사용)""" + return any( + c['type'] == 'paragraph' for c in content_order + ) + + def _build_body_from_content_order(self, template_info: dict, + content_order: list, + semantic_map: dict = None) -> str: + """content_order êž°ë°˜ — 원볞 묞서 순서 귞대로 HTML 조늜. + + 윘텐잠 유형별 처늬: + paragraph →

                                  {{CONTENT_n}}

                                  + table → data-table placeholder (title_table 제왞) + image →
                                  {{IMAGE_n}}
                                  + empty → 생략 (연속 빈 묞닚 의믞 없음) + """ + import re + + tables = template_info.get("tables", []) + + # semantic_map에서 title/body 읞덱슀 + title_table_idx = None + body_table_indices = [] + if semantic_map: + title_table_idx = semantic_map.get("title_table") + body_table_indices = semantic_map.get("body_tables", []) + else: + body_table_indices = [t["index"] for t in tables] + + # ★ v5.3: content_order table_idx → tables 늬슀튞 맀핑 + # content_order.table_idx = section body에서 만난 표 순번 (0-based) + # tables 늬슀튞 = HWPX 전첎 표 (header/footer 포핚) + # → header/footer 제왞한 "볞묞 가시 표" 늬슀튞로 맀핑핎알 정확핚 + header_footer_indices = set() + if semantic_map: + for idx_key, role_info in semantic_map.get("table_roles", {}).items(): + role = role_info.get("role", "") + if role in ("header_table", "footer_table"): + try: + header_footer_indices.add(int(idx_key)) + except (ValueError, TypeError): + pass + + body_visible_tables = [ + t for t in tables + if t["index"] not in header_footer_indices + ] + + body_parts = [] + + # ── 제목 랔록 (title_table읎 있윌멎) ── + if title_table_idx is not None: + title_tbl = next( + (t for t in tables if t["index"] == title_table_idx), None + ) + if title_tbl: + body_parts.append( + self._build_title_block_html(title_tbl) + ) + + # ── content_order 순회 ── + para_num = 0 # 묞닚 placeholder 번혞 + tbl_num = 0 # 데읎터 표 번혞 (1-based) + img_num = 0 # 읎믞지 placeholder 번혞 + in_section = False + section_num = 0 + + # 섹션 제목 팹턮 + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + def _is_section_title(text: str) -> bool: + return any(p.match(text) for p in sec_patterns) + + for item in content_order: + itype = item['type'] + + # ── 빈 묞닚: 생략 ── + if itype == 'empty': + continue + + # ── 표: title_table은 읎믞 처늬, body_table만 ── + # table_idx = content_order.py가 부여한 등장순서 0-based + # ★ v5.3: body_visible_tables로 맀핑 (header/footer 표 제왞) + if itype == 'table': + t_idx = item.get('table_idx', 0) + # body_visible_tables에서 핎당 읞덱슀의 표 가젞였Ʞ + if t_idx < len(body_visible_tables): + tbl_data = body_visible_tables[t_idx] + if tbl_data["index"] == title_table_idx: + continue # title_table 걎너뛰Ʞ + if tbl_data["index"] not in body_table_indices: + continue # body 데읎터 표가 아니멎 걎너뛰Ʞ + + tbl_num += 1 + col_cnt = item.get('colCnt', '3') + try: + col_cnt = int(col_cnt) + except (ValueError, TypeError): + col_cnt = 3 + + # semantic_map에서 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + if t_idx < len(body_visible_tables): + tbl_data = body_visible_tables[t_idx] + tbl_role = _roles.get(tbl_data["index"], + _roles.get(str(tbl_data["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + rows = tbl_data.get("rows", []) + header_row_data = rows[0] if rows else None + col_pcts = tbl_data.get("colWidths_pct", []) + else: + actual_col_cnt = col_cnt + header_row_data = None + col_pcts = [] + + body_parts.append( + self._build_table_placeholder( + tbl_num, actual_col_cnt, col_pcts, + header_row=header_row_data + ) + ) + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + ppr = item.get('paraPrIDRef', '0') + caption = item.get('text', '') + ref = item.get('binaryItemIDRef', '') + + img_html = f'
                                  \n' + img_html += f' {{{{IMAGE_{img_num}}}}}\n' + if caption: + img_html += f'

                                  {{{{IMAGE_{img_num}_CAPTION}}}}

                                  \n' + img_html += '
                                  ' + body_parts.append(img_html) + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + ppr = item.get('paraPrIDRef', '0') + cpr = item.get('charPrIDRef', '0') + + # 섹션 제목 감지 + if _is_section_title(text): + # 읎전 섹션 ë‹«êž° + if in_section: + body_parts.append('\n') + + section_num += 1 + in_section = True + body_parts.append( + f'
                                  \n' + f'

                                  ' + f'{{{{SECTION_{section_num}_TITLE}}}}

                                  ' + ) + continue + + # 음반 묞닚 + para_num += 1 + + # runs가 여러 개멎 닀쀑 span + runs = item.get('runs', []) + if len(runs) > 1: + spans = [] + for r_idx, run in enumerate(runs): + r_cpr = run.get('charPrIDRef', cpr) + spans.append( + f'' + f'{{{{PARA_{para_num}_RUN_{r_idx+1}}}}}' + ) + inner = ''.join(spans) + else: + inner = ( + f'' + f'{{{{PARA_{para_num}}}}}' + ) + + body_parts.append( + f'

                                  {inner}

                                  ' + ) + + # 마지막 섹션 ë‹«êž° + if in_section: + body_parts.append('
                                  \n') + + return "\n\n".join(body_parts) + + def _build_title_block_html(self, title_tbl: dict) -> str: + """제목표 → title-block HTML (Ʞ졎 로직 분늬)""" + rows = title_tbl.get("rows", []) + col_pcts = title_tbl.get("colWidths_pct", []) + + html = '
                                  \n\n' + + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + cs = cell.get("colSpan", 1) + if cs > 1: + attrs += f' colspan="{cs}"' + rs = cell.get("rowSpan", 1) + if rs > 1: + attrs += f' rowspan="{rs}"' + + cell_text = cell.get("text", "").strip() + if cell_text: + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + html += f' {{{{{ph_name}}}}}\n' + else: + html += f' \n' + html += '\n' + + html += '
                                  \n
                                  \n' + return html + + # ── Ʞ졎 섹션+표 방식 (하위 혾환) ── + + def _build_body_legacy(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """content_order 없을 때 — Ʞ졎 v5.1 방식 유지""" + body_parts = [] + tables = template_info.get("tables", []) + + # ── semantic_map읎 있윌멎 활용 ── + if semantic_map: + body_table_indices = semantic_map.get("body_tables", []) + title_idx = semantic_map.get("title_table") + else: + # semantic_map 없윌멎 전첎 표 사용 (하위 혾환) + body_table_indices = [t["index"] for t in tables] + title_idx = None + + # ── 제목 랔록 ── + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if title_tbl: + body_parts.append(self._build_title_block_html(title_tbl)) + + # ── 볞묞 데읎터 표만 필터링 ── + body_tables = [t for t in tables if t["index"] in body_table_indices] + + # ── 섹션 감지 ── + section_titles = self._detect_section_titles(parsed) + + if not section_titles and not body_tables: + # 구조 정볎 부족 → Ʞ볞 1섹션 + body_parts.append( + '
                                  \n' + '
                                  {{SECTION_1_TITLE}}
                                  \n' + '
                                  {{SECTION_1_CONTENT}}
                                  \n' + '
                                  ' + ) + else: + sec_count = max(len(section_titles), 1) + tbl_idx = 0 + + for s in range(sec_count): + s_num = s + 1 + body_parts.append( + f'
                                  \n' + f'
                                  {{{{SECTION_{s_num}_TITLE}}}}
                                  \n' + f'
                                  {{{{SECTION_{s_num}_CONTENT}}}}
                                  \n' + ) + + # 읎 섹션에 표 배분 + if tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + + # semantic_map에서 싀제 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + # 헀더행 셀 데읎터 (bf_id 포핚) + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data # ★ 헀더행 전달 + ) + ) + tbl_idx += 1 + + body_parts.append('
                                  \n') + + # 낚은 표 + while tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data + ) + ) + tbl_idx += 1 + + return "\n".join(body_parts) + + def _build_table_placeholder(self, tbl_num: int, col_cnt: int, + col_pcts: list = None, + header_row: list = None) -> str: + """표 1개의 placeholder HTML 생성 + + Args: + tbl_num: 표 번혞 (1-based) + col_cnt: ì—Ž 수 + col_pcts: ì—Ž 너비 % 늬슀튞 + header_row: 헀더행 셀 늬슀튞 [{bf_id, colSpan, ...}, ...] + """ + # colgroup + colgroup = "" + num_cols = len(col_pcts) if col_pcts else col_cnt + if num_cols > 0: + colgroup = "
                                  {{{{TABLE_{tbl_num}_H_C{c+1}}}}}
                                  \n' + f'{colgroup}' + f'\n' + f' \n{header_row_html}\n \n' + f'\n' + f'\n' + f' {{{{TABLE_{tbl_num}_BODY}}}}\n' + f'\n' + f'
                                  ' + ) + + def _detect_section_titles(self, parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색""" + import re + titles = [] + + # parsed에서 텍슀튞 추출 + paragraphs = parsed.get("paragraphs", []) + if not paragraphs: + # raw_xml에서 태귞 텍슀튞 추출 시도 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower(): + section_xml = val if isinstance(val, str) else "" + break + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + t_matches = re.findall(r'([^<]+)', section_xml) + paragraphs = [t.strip() for t in t_matches if t.strip()] + + # 섹션 제목 팹턮 + patterns = [ + r'^(\d+)\.\s+\S', # "1. 제목" + r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S', # "Ⅰ. 제목" + r'^제\s*\d+\s*[장절항]\s*\S', # "제1장 제목" + ] + + for text in paragraphs: + if isinstance(text, dict): + text = text.get("text", "") + text = str(text).strip() + if not text: + continue + for pat in patterns: + if re.match(pat, text): + titles.append(text) + break + + return titles + + def _extract_colors(self, template_info: dict) -> dict: + """template_info에서 색상 정볎 추출""" + colors = {"background": [], "border": [], "text": []} + + bf = template_info.get("border_fills", {}) + for fill_id, fill_data in bf.items(): + # ★ background í‚€ 사용 (bg → background) + bg = fill_data.get("background", fill_data.get("bg", "")) + if bg and bg.lower() not in ("", "none", "transparent") \ + and bg not in colors["background"]: + colors["background"].append(bg) + + # ★ css dict에서 border 색상 추출 + css_dict = fill_data.get("css", {}) + for prop, val in css_dict.items(): + if "border" in prop and val and val != "none": + # "0.12mm solid #999999" → "#999999" + parts = val.split() + if len(parts) >= 3: + c = parts[-1] + if c.startswith("#") and c not in colors["border"]: + colors["border"].append(c) + + # fallback: 직접 side í‚€ (top/bottom/left/right) + for side_key in ("top", "bottom", "left", "right"): + side = fill_data.get(side_key, {}) + if isinstance(side, dict): + c = side.get("color", "") + if c and c not in colors["border"]: + colors["border"].append(c) + + return colors + + def _summarize_features(self, template_info: dict, + semantic_map: dict = None) -> list: + """template_info에서 특징 요앜""" + features = [] + + header = template_info.get("header", {}) + footer = template_info.get("footer", {}) + tables = template_info.get("tables", []) + + # 폰튾 (fonts 구조: {"HANGUL": [{"face": "맑은 고딕"}], ...}) + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + features.append(f"폰튾: {hangul[0].get('face', '?')}") + + # 뚞늿말 (header.table.colCnt) + if header.get("exists"): + col_cnt = header.get("table", {}).get("colCnt", "?") + features.append(f"뚞늿말: {col_cnt}ì—Ž") + + # ꌬ늿말 (footer.table.colCnt) + if footer.get("exists"): + col_cnt = footer.get("table", {}).get("colCnt", "?") + features.append(f"ꌬ늿말: {col_cnt}ì—Ž") + + # 표 — semantic_map읎 있윌멎 데읎터 표만 + if semantic_map and semantic_map.get("body_tables"): + for idx in semantic_map["body_tables"]: + t = next((tb for tb in tables if tb["index"] == idx), None) + if t: + features.append( + f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}" + ) + elif tables: + t = tables[0] + features.append(f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}") + + return features \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/__init__.py b/03. Code/geulbeot_8th/handlers/tools/__init__.py new file mode 100644 index 0000000..14b8b13 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +HWPX 템플늿 추출 도구 몚음 + +각 몚듈은 HWPX XML에서 특정 항목을 윔드 Ʞ반윌로 추출한닀. +- 추출 싀팚 시 None 반환 (디폎튞값 절대 생성 안 핹) +- 몚든 닚위 변환은 hwpx_utils 사용 +- hwpx_domain_guide.md Ʞ쀀 쀀수 + +몚듈 목록: + page_setup : §7 용지/여백 (pagePr + margin) + font : §3 Ꞁꌎ (fontface → font) + char_style : §4 Ꞁ자 몚양 (charPr) + para_style : §5 묞닚 몚양 (paraPr) + border_fill : §2 테두늬/배겜 (borderFill) + table : §6 표 (tbl, tc) + header_footer: §8 뚞늬말/ꌬ늬말 (headerFooter) + section : §9 구역 정의 (secPr) + style_def : 슀타음 정의 (styles) + numbering : 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image : 읎믞지/귞늬Ʞ 객첎 + content_order: 볞묞 윘텐잠 순서 (section*.xml) +""" + +from . import page_setup +from . import font +from . import char_style +from . import para_style +from . import border_fill +from . import table +from . import header_footer +from . import section +from . import style_def +from . import numbering +from . import image +from . import content_order + +__all__ = [ + "page_setup", + "font", + "char_style", + "para_style", + "border_fill", + "table", + "header_footer", + "section", + "style_def", + "numbering", + "image", + "content_order" +] \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/border_fill.py b/03. Code/geulbeot_8th/handlers/tools/border_fill.py new file mode 100644 index 0000000..1f72936 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/border_fill.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +""" +§2 테두늬/배겜(BorderFill) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import BORDER_TYPE_TO_CSS, hwpx_border_to_css + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§2 borderFill 전첎 추출 → id별 dict. + + Returns: + { + 3: { + "id": 3, + "left": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "right": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "top": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "bottom": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "diagonal": {"type": "SOLID", "width": "0.1 mm", "color": "#000000"}, + "background": "#EDEDED", # fillBrush faceColor + "css": { # 펞의: 믞늬 변환된 CSS + "border-left": "0.12mm solid #000000", + ... + "background-color": "#EDEDED", + } + }, + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = {} + for attrs_str, inner in blocks: + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if not id_m: + continue + bf_id = int(id_m.group(1)) + + item = {"id": bf_id} + + # 4방향 + diagonal + for side, tag in [ + ("left", "leftBorder"), + ("right", "rightBorder"), + ("top", "topBorder"), + ("bottom", "bottomBorder"), + ("diagonal", "diagonal"), + ]: + # 태귞 전첎륌 뚌저 ì°Ÿê³ , 속성을 개별 추출 (순서 묎ꎀ) + tag_m = re.search(rf'', inner) + if tag_m: + tag_attrs = tag_m.group(1) + t = re.search(r'\btype="([^"]+)"', tag_attrs) + w = re.search(r'\bwidth="([^"]+)"', tag_attrs) + c = re.search(r'\bcolor="([^"]+)"', tag_attrs) + item[side] = { + "type": t.group(1) if t else "NONE", + "width": w.group(1).replace(" ", "") if w else "0.12mm", + "color": c.group(1) if c else "#000000", + } + + # 배겜 (fillBrush > winBrush faceColor) + bg_m = re.search( + r']*\bfaceColor="([^"]+)"', inner + ) + if bg_m: + face = bg_m.group(1) + if face and face.lower() != "none": + item["background"] = face + + # CSS 펞의 변환 + css = {} + for side in ["left", "right", "top", "bottom"]: + border_data = item.get(side) + if border_data: + css[f"border-{side}"] = hwpx_border_to_css(border_data) + else: + css[f"border-{side}"] = "none" + # border_data가 없윌멎 CSS에도 넣지 않음 + + if "background" in item: + css["background-color"] = item["background"] + + if css: + item["css"] = css + + result[bf_id] = item + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/char_style.py b/03. Code/geulbeot_8th/handlers/tools/char_style.py new file mode 100644 index 0000000..52b9c9f --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/char_style.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +§4 Ꞁ자 몚양(CharShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import charsize_to_pt + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§4 charPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "bold": False, + "italic": False, + "underline": "NONE", + "strikeout": "NONE", + "fontRef": {"hangul": 7, "latin": 6, ...}, + "ratio": {"hangul": 100, "latin": 100, ...}, + "spacing": {"hangul": 0, "latin": 0, ...}, + "borderFillIDRef": 2, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + # charPr 랔록 추출 (self-closing읎 아닌 랔록) + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # 속성 파싱 + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + height_m = re.search(r'\bheight="(\d+)"', attrs_str) + if height_m: + item["height_pt"] = charsize_to_pt(int(height_m.group(1))) + + color_m = re.search(r'\btextColor="([^"]+)"', attrs_str) + if color_m: + item["textColor"] = color_m.group(1) + + shade_m = re.search(r'\bshadeColor="([^"]+)"', attrs_str) + if shade_m and shade_m.group(1) != "none": + item["shadeColor"] = shade_m.group(1) + + bf_m = re.search(r'\bborderFillIDRef="(\d+)"', attrs_str) + if bf_m: + item["borderFillIDRef"] = int(bf_m.group(1)) + + # bold / italic (태귞 졎재 여부로 판당) + item["bold"] = bool(re.search(r'', inner)) + item["italic"] = bool(re.search(r'', inner)) + + # fontRef + fr = re.search(r'', inner) + if fr: + item["fontRef"] = _parse_lang_attrs(fr.group(1)) + + # ratio + ra = re.search(r'', inner) + if ra: + item["ratio"] = _parse_lang_attrs(ra.group(1)) + + # spacing + sp = re.search(r'', inner) + if sp: + item["spacing"] = _parse_lang_attrs(sp.group(1)) + + # underline + ul = re.search(r']*\btype="([^"]+)"', inner) + if ul: + item["underline"] = ul.group(1) + + # strikeout + so = re.search(r']*\bshape="([^"]+)"', inner) + if so: + item["strikeout"] = so.group(1) + + result.append(item) + + return result if result else None + + +def _parse_lang_attrs(attrs_str: str) -> dict: + """hangul="7" latin="6" ... → {"hangul": 7, "latin": 6, ...}""" + pairs = re.findall(r'(\w+)="(-?\d+)"', attrs_str) + return {k: int(v) for k, v in pairs} + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/content_order.py b/03. Code/geulbeot_8th/handlers/tools/content_order.py new file mode 100644 index 0000000..eca9e40 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/content_order.py @@ -0,0 +1,529 @@ +# -*- coding: utf-8 -*- +""" +content_order.py — HWPX section*.xml 볞묞 윘텐잠 순서 추출 + +Ʞ졎 12개 tool읎 header.xml의 "정의(definition)"륌 추출하는 반멎, +읎 tool은 section0.xml의 "볞묞(content)" 순서륌 추출한닀. + +추출 결곌는 template_manager._build_body_html()읎 +원볞 순서 귞대로 HTML을 조늜하는 데 사용된닀. + +윘텐잠 유형: + - paragraph : 음반 텍슀튞 묞닚 + - table : 표 () + - image : 읎믞지 () + - empty : 빈 묞닚 (쀄바꿈 역할) + +ì°žì¡°: hwpx_domain_guide.md §6(표), §7(볞묞 구조) +""" + +import re +import logging + +logger = logging.getLogger(__name__) + +# ================================================================ +# 넀임슀페읎슀 +# ================================================================ +# HWPX는 여러 넀임슀페읎슀륌 사용한닀. +# section*.xml: hp: (볞묞), ha: (속성) +# header.xml: hh: (헀더 정의) +# 싀제 파음에서 넀임슀페읎슀 URI가 닀륌 수 있윌므로 로컬명 êž°ë°˜ 탐색도 병행한닀. + +DEFAULT_NS = { + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes', + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', +} + + +# ================================================================ +# 공개 API +# ================================================================ + +def extract(raw_xml, parsed, ns=None): + """section*.xml에서 볞묞 윘텐잠 순서륌 추출한닀. + + Args: + raw_xml (dict): 원볞 XML 묞자엎 딕셔너늬. + raw_xml.get("section0") 등윌로 section XML에 ì ‘ê·Œ. + parsed (dict): processor.py가 HWPX륌 파싱한 전첎 결곌 dict. + parsed.get("section_xml") 등윌로 parsed Element에 ì ‘ê·Œ. + ns (dict, optional): 넀임슀페읎슀 맀핑. None읎멎 자동 감지. + + Returns: + list[dict]: 윘텐잠 순서 늬슀튞. 각 항목은 닀음 킀륌 포핚: + - type: "paragraph" | "table" | "image" | "empty" + - index: 전첎 순서 낮 읞덱슀 (0부터) + - paraPrIDRef: 묞닚몚양 ì°žì¡° ID (str or None) + - styleIDRef: 슀타음 ì°žì¡° ID (str or None) + + type별 추가 í‚€ (아래 ì°žì¡°) + 추출 싀팚 시 None 반환 (analyzer가 결곌에서 제왞핚). + """ + # ── section XML ì°Ÿêž° ── + # raw_xml dict에서 section 원볞 묞자엎 추출 + section_raw = None + if isinstance(raw_xml, dict): + # í‚€ 읎늄은 프로젝튞마닀 닀륌 수 있음: section0, section_xml 등 + for key in ['section0', 'section_xml', 'section0.xml']: + if key in raw_xml: + section_raw = raw_xml[key] + break + # 못 찟윌멎 "section"윌로 시작하는 첫 번짞 í‚€ + if section_raw is None: + for key, val in raw_xml.items(): + if key.startswith('section') and isinstance(val, str): + section_raw = val + break + elif isinstance(raw_xml, str): + section_raw = raw_xml + + # parsed dict에서 section Element 또는 묞자엎 추출 + section_parsed = None + if isinstance(parsed, dict): + for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']: + val = parsed.get(key) + if val is None: + continue + if isinstance(val, str): + # 묞자엎읎멎 section_raw로 활용 (table.py와 동음) + if section_raw is None: + section_raw = val + elif not isinstance(val, dict): + # Element 객첎로 추정 + section_parsed = val + break + # fallback: raw_xml 묞자엎을 직접 파싱 + if section_parsed is None and section_raw: + import xml.etree.ElementTree as ET + try: + section_parsed = ET.fromstring(section_raw) + except ET.ParseError: + logger.warning("section XML 파싱 싀팚") + return None + else: + # parsed 자첎가 Element음 수 있음 (직접 혞출 시) + section_parsed = parsed + + if section_parsed is None: + logger.warning("section XML을 찟을 수 없음 — content_order 추출 생략") + return None + + if ns is None: + ns = _detect_namespaces(section_raw or '', section_parsed) + + # 엘늬뚌튞 수집 — secPr 낎부는 제왞 + paragraphs = _collect_body_paragraphs(section_parsed, ns) + + content_order = [] + table_idx = 0 + image_idx = 0 + + for p_elem in paragraphs: + para_pr_id = _get_attr(p_elem, 'paraPrIDRef') + style_id = _get_attr(p_elem, 'styleIDRef') + + base = { + 'index': len(content_order), + 'paraPrIDRef': para_pr_id, + 'styleIDRef': style_id, + } + + # ── (1) 표 확읞 ── + tbl = _find_element(p_elem, 'tbl', ns) + if tbl is not None: + tbl_info = _extract_table_info(tbl, ns) + content_order.append({ + **base, + 'type': 'table', + 'table_idx': table_idx, + **tbl_info, + }) + table_idx += 1 + continue + + # ── (2) 읎믞지 확읞 ── + pic = _find_element(p_elem, 'pic', ns) + if pic is not None: + img_info = _extract_image_info(pic, p_elem, ns) + content_order.append({ + **base, + 'type': 'image', + 'image_idx': image_idx, + **img_info, + }) + image_idx += 1 + continue + + # ── (3) 텍슀튞 묞닚 / 빈 묞닚 ── + text = _collect_text(p_elem, ns) + runs_info = _extract_runs_info(p_elem, ns) + + if not text.strip(): + content_order.append({ + **base, + 'type': 'empty', + }) + else: + content_order.append({ + **base, + 'type': 'paragraph', + 'text': text, + 'charPrIDRef': runs_info.get('first_charPrIDRef'), + 'runs': runs_info.get('runs', []), + }) + + logger.info( + "content_order 추출 완료: %d items " + "(paragraphs=%d, tables=%d, images=%d, empty=%d)", + len(content_order), + sum(1 for c in content_order if c['type'] == 'paragraph'), + table_idx, + image_idx, + sum(1 for c in content_order if c['type'] == 'empty'), + ) + + return content_order + + +# ================================================================ +# 볞묞 수집 — secPr 낎부 제왞 +# ================================================================ + +def _collect_body_paragraphs(root, ns): + """ 직계 만 수집한닀. + + secPr, headerFooter 낎부의 는 볞묞읎 아니므로 제왞. + subList 낎부(셀 안 묞닚)도 제왞 — 표는 통짞로 하나의 항목. + """ + paragraphs = [] + + # 방법 1: sec 직계 자식 쀑 p 태귞만 + sec = _find_element(root, 'sec', ns) + if sec is None: + # 룚튞 자첎가 sec음 수 있음 + sec = root + + for child in sec: + tag = _local_tag(child) + if tag == 'p': + paragraphs.append(child) + + # 직계 자식에서 못 찟았윌멎 fallback: 전첎 탐색 (but secPr/subList 제왞) + if not paragraphs: + paragraphs = _collect_paragraphs_fallback(root, ns) + + return paragraphs + + +def _collect_paragraphs_fallback(root, ns): + """fallback: 전첎에서 륌 찟되, secPr/headerFooter/subList 낎부는 제왞""" + skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'} + result = [] + + def _walk(elem, skip=False): + if skip: + return + tag = _local_tag(elem) + if tag in skip_tags: + return + if tag == 'p': + # 부몚가 sec읎거나 룚튞 직계읞 겜우만 + result.append(elem) + return # p 낎부의 하위 p는 수집하지 않음 + for child in elem: + _walk(child) + + _walk(root) + return result + + +# ================================================================ +# 표 정볎 추출 +# ================================================================ + +def _extract_table_info(tbl, ns): + """ 에서 Ʞ볞 메타 정볎 추출""" + info = { + 'rowCnt': _get_attr(tbl, 'rowCnt'), + 'colCnt': _get_attr(tbl, 'colCnt'), + 'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'), + } + + # ì—Ž 너비 + col_sz = _find_element(tbl, 'colSz', ns) + if col_sz is not None: + width_list_elem = _find_element(col_sz, 'widthList', ns) + if width_list_elem is not None and width_list_elem.text: + info['colWidths'] = width_list_elem.text.strip().split() + + return info + + +# ================================================================ +# 읎믞지 정볎 추출 +# ================================================================ + +def _extract_image_info(pic, p_elem, ns): + """ 에서 읎믞지 ì°žì¡° 정볎 추출""" + info = { + 'binaryItemIDRef': None, + 'text': '', # 읎믞지와 같은 묞닚에 있는 텍슀튞 (캡션 등) + } + + # img 태귞에서 binaryItemIDRef + img = _find_element(pic, 'img', ns) + if img is not None: + info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef') + + # imgRect에서 크Ʞ 정볎 + img_rect = _find_element(pic, 'imgRect', ns) + if img_rect is not None: + info['imgRect'] = { + 'x': _get_attr(img_rect, 'x'), + 'y': _get_attr(img_rect, 'y'), + 'w': _get_attr(img_rect, 'w'), + 'h': _get_attr(img_rect, 'h'), + } + + # 같은 묞닚 낮 텍슀튞 (pic 바깥의 runë“€) + info['text'] = _collect_text_outside(p_elem, pic, ns) + + return info + + +# ================================================================ +# 텍슀튞 수집 +# ================================================================ + +def _collect_text(p_elem, ns): + """ 낮 몚든 텍슀튞륌 순서대로 합칚 + + 죌의: t.tail은 XML 듀여쓰Ʞ 공백읎므로 수집하지 않는닀. + HWPX에서 싀제 텍슀튞는 항상 ... 안에 있닀. + """ + parts = [] + for t in _find_all_elements(p_elem, 't', ns): + if t.text: + parts.append(t.text) + return ''.join(parts) + + +def _collect_text_outside(p_elem, exclude_elem, ns): + """p_elem 낎에서 exclude_elem(예: pic) 바깥의 텍슀튞만 수집""" + parts = [] + + def _walk(elem): + if elem is exclude_elem: + return + tag = _local_tag(elem) + if tag == 't' and elem.text: + parts.append(elem.text) + for child in elem: + _walk(child) + + _walk(p_elem) + return ''.join(parts) + + +# ================================================================ +# Run 정볎 추출 +# ================================================================ + +def _extract_runs_info(p_elem, ns): + """ 낮 듀의 charPrIDRef와 텍슀튞 추출 + + Returns: + { + 'first_charPrIDRef': str or None, + 'runs': [ + {'charPrIDRef': '8', 'text': '1. SamanPro...'}, + {'charPrIDRef': '24', 'text': '포장섀계...'}, + ] + } + """ + runs = [] + first_char_pr = None + + for run_elem in _find_direct_runs(p_elem, ns): + char_pr = _get_attr(run_elem, 'charPrIDRef') + if first_char_pr is None and char_pr is not None: + first_char_pr = char_pr + + text_parts = [] + for t in _find_all_elements(run_elem, 't', ns): + if t.text: + text_parts.append(t.text) + + if text_parts: + runs.append({ + 'charPrIDRef': char_pr, + 'text': ''.join(text_parts), + }) + + return { + 'first_charPrIDRef': first_char_pr, + 'runs': runs, + } + + +def _find_direct_runs(p_elem, ns): + """ 직계 만 찟음 (subList 낎부 제왞)""" + results = [] + for child in p_elem: + tag = _local_tag(child) + if tag == 'run': + results.append(child) + return results + + +# ================================================================ +# 넀임슀페읎슀 감지 +# ================================================================ + +def _detect_namespaces(raw_xml, parsed): + """XML에서 싀제 사용된 넀임슀페읎슀 URI륌 감지한닀. + + HWPX 버전에 따띌 넀임슀페읎슀 URI가 닀륌 수 있닀: + - 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph + - 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (음부) + """ + ns = dict(DEFAULT_NS) + + if raw_xml: + # xmlns:hp="..." 팚턎윌로 싀제 URI 추출 + for prefix in ['hp', 'ha', 'hh', 'hc']: + pattern = rf'xmlns:{prefix}="([^"]+)"' + match = re.search(pattern, raw_xml) + if match: + ns[prefix] = match.group(1) + + return ns + + +# ================================================================ +# XML 유틞늬티 — 넀임슀페읎슀 불가지론적 탐색 +# ================================================================ + +def _local_tag(elem): + """'{namespace}localname' → 'localname'""" + tag = elem.tag + if '}' in tag: + return tag.split('}', 1)[1] + return tag + + +def _get_attr(elem, attr_name): + """속성값 가젞였Ʞ. 넀임슀페읎슀 유묎 몚두 시도.""" + # 직접 속성명 + val = elem.get(attr_name) + if val is not None: + return val + + # 넀임슀페읎슀 접두사가 붙은 속성 시도 + for full_attr in elem.attrib: + if full_attr.endswith(attr_name): + return elem.attrib[full_attr] + + return None + + +def _find_element(parent, local_name, ns): + """자식 쀑 로컬명읎 음치하는 첫 번짞 엘늬뚌튞륌 찟는닀. + + 넀임슀페읎슀 prefix 시도 후, 싀팚하멎 로컬명 직접 비교. + """ + # 1ì°š: 넀임슀페읎슀 prefix로 탐색 + for prefix in ['hp', 'hh', 'hc', 'ha']: + uri = ns.get(prefix, '') + found = parent.find(f'{{{uri}}}{local_name}') + if found is not None: + return found + + # 2ì°š: 직계 자식 로컬명 비교 + for child in parent: + if _local_tag(child) == local_name: + return child + + # 3ì°š: 재귀 탐색 (1닚계만) + for child in parent: + for grandchild in child: + if _local_tag(grandchild) == local_name: + return grandchild + + return None + + +def _find_all_elements(parent, local_name, ns): + """하위 전첎에서 로컬명읎 음치하는 몚든 엘늬뚌튞륌 찟는닀.""" + results = [] + + def _walk(elem): + if _local_tag(elem) == local_name: + results.append(elem) + for child in elem: + _walk(child) + + _walk(parent) + return results + + +# ================================================================ +# 펞의 핚수 +# ================================================================ + +def summarize(content_order): + """content_order 늬슀튞륌 사람읎 읜Ʞ 쉬욎 요앜윌로 변환""" + lines = [] + for item in content_order: + idx = item['index'] + t = item['type'] + + if t == 'paragraph': + text_preview = item['text'][:50] + if len(item['text']) > 50: + text_preview += '...' + lines.append( + f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} " + f"charPr={item.get('charPrIDRef', '-'):<4s} " + f"\"{text_preview}\"" + ) + elif t == 'table': + lines.append( + f"[{idx:3d}] T table_idx={item['table_idx']} " + f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})" + ) + elif t == 'image': + ref = item.get('binaryItemIDRef', '?') + caption = item.get('text', '')[:30] + lines.append( + f"[{idx:3d}] I image_idx={item['image_idx']} " + f"ref={ref} \"{caption}\"" + ) + elif t == 'empty': + lines.append(f"[{idx:3d}] _ (empty)") + + return '\n'.join(lines) + + +def get_stats(content_order): + """content_order 통계 반환""" + type_map = { + 'paragraph': 'paragraphs', + 'table': 'tables', + 'image': 'images', + 'empty': 'empty', + } + stats = { + 'total': len(content_order), + 'paragraphs': 0, + 'tables': 0, + 'images': 0, + 'empty': 0, + } + for item in content_order: + key = type_map.get(item['type']) + if key: + stats[key] += 1 + return stats \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/font.py b/03. Code/geulbeot_8th/handlers/tools/font.py new file mode 100644 index 0000000..a4ea867 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/font.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +§3 Ꞁꌎ(FaceName) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§3 fontface에서 얞얎별 Ꞁꌎ 정의 추출. + + Returns: + { + "HANGUL": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "LATIN": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "HANJA": [...], + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # fontface 랔록을 lang별로 추출 + fontface_blocks = re.findall( + r']*\blang="([^"]+)"[^>]*>(.*?)', + header_xml, re.DOTALL + ) + + if not fontface_blocks: + return None + + for lang, block_content in fontface_blocks: + fonts = [] + font_matches = re.finditer( + r']*' + r'\bid="(\d+)"[^>]*' + r'\bface="([^"]+)"[^>]*' + r'\btype="([^"]+)"', + block_content + ) + for fm in font_matches: + fonts.append({ + "id": int(fm.group(1)), + "face": fm.group(2), + "type": fm.group(3), + }) + + if fonts: + result[lang] = fonts + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """header.xml 묞자엎을 가젞옚닀.""" + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/header_footer.py b/03. Code/geulbeot_8th/handlers/tools/header_footer.py new file mode 100644 index 0000000..7dc9b30 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/header_footer.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +§8 뚞늬말/ꌬ늬말(HeaderFooter) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + 뚞늬말/ꌬ늬말 안에 표가 있는 겜우: + - 표의 셀에 닀쀑행 텍슀튞가 포핚될 수 있음 + - 각 셀의 colSpan, rowSpan, width, borderFillIDRef 등 추출 필요 + +secPr 낮 속성: + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract_header(raw_xml: dict, parsed: dict = None) -> dict | None: + """뚞늬말 구조 추출. + + Returns: + { + "exists": True, + "type": "table" | "text", + "hidden": False, + "table": { ... } | None, # 표가 있는 겜우 + "texts": ["부서명", ...], + } + """ + return _extract_hf(raw_xml, parsed, "header") + + +def extract_footer(raw_xml: dict, parsed: dict = None) -> dict | None: + """ꌬ늬말 구조 추출.""" + return _extract_hf(raw_xml, parsed, "footer") + + +def _extract_hf(raw_xml: dict, parsed: dict, hf_type: str) -> dict | None: + """header 또는 footer 추출 공통 로직""" + # 1) parsed에서 직접 제공된 header/footer XML + hf_xml = None + if parsed: + key = f"page_{hf_type}_xml" + hf_xml = parsed.get(key, "") + + # 2) section XML에서 headerFooter 랔록 탐색 + section_xml = _get_section_xml(raw_xml, parsed) + + if not hf_xml and section_xml: + # headerFooter 태귞에서 header/footer 구분 + hf_blocks = re.findall( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for attrs, inner in hf_blocks: + # type 속성윌로 구분 (HEADER / FOOTER) + type_m = re.search(r'\btype="([^"]+)"', attrs) + if type_m: + if type_m.group(1).upper() == hf_type.upper(): + hf_xml = inner + break + + if not hf_xml or not hf_xml.strip(): + return None # 핎당 뚞늬말/ꌬ늬말 없음 + + result = {"exists": True} + + # hidden 여부 + if section_xml: + hide_key = f"hideFirst{'Header' if hf_type == 'header' else 'Footer'}" + hide_m = re.search(rf'\b{hide_key}="(\d+)"', section_xml) + if hide_m: + result["hidden"] = bool(int(hide_m.group(1))) + + # 텍슀튞 추출 + texts = re.findall(r'([^<]*)', hf_xml) + clean_texts = [t.strip() for t in texts if t.strip()] + if clean_texts: + result["texts"] = clean_texts + + # 표 졎재 여부 + tbl_match = re.search( + r']*)>(.*?)', + hf_xml, re.DOTALL + ) + if tbl_match: + result["type"] = "table" + result["table"] = _parse_hf_table(tbl_match.group(1), tbl_match.group(2)) + else: + result["type"] = "text" + + return result + + +def _parse_hf_table(tbl_attrs: str, tbl_inner: str) -> dict: + """뚞늬말/ꌬ늬말 낮 표 파싱""" + table = {} + + # rowCnt, colCnt + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + table[attr] = int(m.group(1)) + + # ì—Ž 너비 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + widths = [int(w) for w in wl.group(1).strip().split()] + table["colWidths_hu"] = widths + total = sum(widths) or 1 + table["colWidths_pct"] = [round(w / total * 100) for w in widths] + except ValueError: + pass + + # 행/셀 + rows = [] + tr_blocks = re.findall(r']*>(.*?)', tbl_inner, re.DOTALL) + for tr in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr, re.DOTALL + ) + for tc in tc_blocks: + cell = _parse_hf_cell(tc.group(1), tc.group(2)) + cells.append(cell) + rows.append(cells) + + if rows: + table["rows"] = rows + + return table + + +def _parse_hf_cell(tc_attrs: str, tc_inner: str) -> dict: + """뚞늬말/ꌬ늬말 셀 파싱""" + cell = {} + + # borderFillIDRef + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + + # 셀 텍슀튞 (닀쀑행) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + + if lines: + cell["text"] = " ".join(lines) + cell["lines"] = lines + + return cell + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/image.py b/03. Code/geulbeot_8th/handlers/tools/image.py new file mode 100644 index 0000000..d989ccb --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/image.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +읎믞지/귞늬Ʞ 객첎(ShapeObject) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + + + + 또는 귞늬Ʞ 객첎: + + + ... + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """읎믞지/귞늬Ʞ 객첎 추출. + + Returns: + [ + { + "type": "image", + "binaryItemRef": "image1.JPG", + "width_hu": 28346, "height_hu": 14173, + "width_mm": 100.0, "height_mm": 50.0, + "offset": {"x": 0, "y": 0}, + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = [] + + # 랔록 + pic_blocks = re.finditer( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for pm in pic_blocks: + pic_inner = pm.group(2) + item = {"type": "image"} + + # binaryItemRef + img = re.search(r']*\bbinaryItemIDRef="([^"]+)"', pic_inner) + if img: + item["binaryItemRef"] = img.group(1) + + # curSz (현재 크Ʞ) + csz = re.search( + r']*\bwidth="(\d+)"[^>]*\bheight="(\d+)"', + pic_inner + ) + if csz: + w, h = int(csz.group(1)), int(csz.group(2)) + item["width_hu"] = w + item["height_hu"] = h + item["width_mm"] = round(hwpunit_to_mm(w), 1) + item["height_mm"] = round(hwpunit_to_mm(h), 1) + + # offset + off = re.search( + r']*\bx="(-?\d+)"[^>]*\by="(-?\d+)"', + pic_inner + ) + if off: + item["offset"] = {"x": int(off.group(1)), "y": int(off.group(2))} + + result.append(item) + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/numbering.py b/03. Code/geulbeot_8th/handlers/tools/numbering.py new file mode 100644 index 0000000..b6e048d --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/numbering.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +번혞맀ꞰꞰ(Numbering) / Ꞁ뚞늬표(Bullet) 추출 + +HWPX 싀제 태귞 (header.xml): + + ^1. + ^2. + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """번혞맀ꞰꞰ + Ꞁ뚞늬표 정의 추출. + + Returns: + { + "numberings": [ + { + "id": 1, "start": 0, + "levels": [ + {"level": 1, "numFormat": "DIGIT", "pattern": "^1.", + "align": "LEFT"}, + {"level": 2, "numFormat": "HANGUL_SYLLABLE", "pattern": "^2."}, + ... + ] + } + ], + "bullets": [ + {"id": 1, "char": "-", "useImage": False} + ] + } + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # ── 번혞맀ꞰꞰ ── + numbering_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if numbering_blocks: + nums = [] + for attrs, inner in numbering_blocks: + num = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + num["id"] = int(id_m.group(1)) + start_m = re.search(r'\bstart="(\d+)"', attrs) + if start_m: + num["start"] = int(start_m.group(1)) + + # paraHead 레벚듀 + levels = [] + heads = re.finditer( + r']*)>([^<]*)', + inner + ) + for h in heads: + h_attrs = h.group(1) + h_pattern = h.group(2).strip() + level = {} + + lv = re.search(r'\blevel="(\d+)"', h_attrs) + if lv: + level["level"] = int(lv.group(1)) + + fmt = re.search(r'\bnumFormat="([^"]+)"', h_attrs) + if fmt: + level["numFormat"] = fmt.group(1) + + al = re.search(r'\balign="([^"]+)"', h_attrs) + if al: + level["align"] = al.group(1) + + if h_pattern: + level["pattern"] = h_pattern + + if level: + levels.append(level) + + if levels: + num["levels"] = levels + nums.append(num) + + if nums: + result["numberings"] = nums + + # ── Ꞁ뚞늬표 ── + bullet_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if bullet_blocks: + bullets = [] + for attrs, inner in bullet_blocks: + bullet = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + bullet["id"] = int(id_m.group(1)) + char_m = re.search(r'\bchar="([^"]*)"', attrs) + if char_m: + bullet["char"] = char_m.group(1) + img_m = re.search(r'\buseImage="(\d+)"', attrs) + if img_m: + bullet["useImage"] = bool(int(img_m.group(1))) + bullets.append(bullet) + + if bullets: + result["bullets"] = bullets + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/page_setup.py b/03. Code/geulbeot_8th/handlers/tools/page_setup.py new file mode 100644 index 0000000..b31994a --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/page_setup.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +""" +§7 용지 섀정 추출 (pagePr + margin) + +HWPX 싀제 태귞: + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm, mm_format, detect_paper_size + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§7 pagePr + margin에서 용지/여백 정볎 추출. + + Returns: + { + "paper": {"name": "A4", "width_mm": 210.0, "height_mm": 297.0, + "landscape": True/False}, + "margins": {"top": "10.0mm", "bottom": "10.0mm", + "left": "20.0mm", "right": "20.0mm", + "header": "15.0mm", "footer": "15.0mm", + "gutter": "0.0mm"} + } + 또는 추출 싀팚 시 None + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = {} + + # ── 용지 크Ʞ ───────────────────────────────── + page_match = re.search( + r']*' + r'\bwidth="(\d+)"[^>]*' + r'\bheight="(\d+)"', + section_xml + ) + if not page_match: + # 속성 순서가 닀륌 수 있음 + page_match = re.search( + r']*' + r'\bheight="(\d+)"[^>]*' + r'\bwidth="(\d+)"', + section_xml + ) + if page_match: + h_hu, w_hu = int(page_match.group(1)), int(page_match.group(2)) + else: + return None + else: + w_hu, h_hu = int(page_match.group(1)), int(page_match.group(2)) + + landscape_match = re.search( + r']*\blandscape="([^"]+)"', section_xml + ) + is_landscape = False + if landscape_match: + is_landscape = landscape_match.group(1) == "WIDELY" + + paper_name = detect_paper_size(w_hu, h_hu) + + result["paper"] = { + "name": paper_name, + "width_mm": round(hwpunit_to_mm(w_hu), 1), + "height_mm": round(hwpunit_to_mm(h_hu), 1), + "landscape": is_landscape, + } + + # ── 여백 ────────────────────────────────────── + margin_match = re.search(r'', section_xml) + if not margin_match: + return result # 용지 크Ʞ는 있윌나 여백은 없을 수 있음 + + attrs_str = margin_match.group(1) + margins = {} + for key in ["top", "bottom", "left", "right", "header", "footer", "gutter"]: + m = re.search(rf'\b{key}="(\d+)"', attrs_str) + if m: + margins[key] = mm_format(int(m.group(1))) + + if margins: + result["margins"] = margins + + return result + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """section XML 묞자엎을 가젞옚닀.""" + # parsed에서 직접 제공 + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + + # raw_xml dict에서 section 파음 ì°Ÿêž° + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + + # raw_xml읎 묞자엎읎멎 귞대로 + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/para_style.py b/03. Code/geulbeot_8th/handlers/tools/para_style.py new file mode 100644 index 0000000..2b6dd3a --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/para_style.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +""" +§5 묞닚 몚양(ParaShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§5 paraPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": {"type": "NONE", "idRef": 0, "level": 0}, + "breakSetting": { + "widowOrphan": False, "keepWithNext": False, + "keepLines": False, "pageBreakBefore": False, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, "left_hu": 0, "right_hu": 0, + "before_hu": 0, "after_hu": 0, + }, + "lineSpacing": {"type": "PERCENT", "value": 130}, + "borderFillIDRef": 2, + "tabPrIDRef": 1, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # id + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + # tabPrIDRef + tab_m = re.search(r'\btabPrIDRef="(\d+)"', attrs_str) + if tab_m: + item["tabPrIDRef"] = int(tab_m.group(1)) + + # align + al = re.search(r']*\bhorizontal="([^"]+)"', inner) + if al: + item["align"] = al.group(1) + + val = re.search(r']*\bvertical="([^"]+)"', inner) + if val: + item["verticalAlign"] = val.group(1) + + # heading + hd = re.search( + r']*\btype="([^"]+)"[^>]*' + r'\bidRef="(\d+)"[^>]*\blevel="(\d+)"', inner + ) + if hd: + item["heading"] = { + "type": hd.group(1), + "idRef": int(hd.group(2)), + "level": int(hd.group(3)), + } + + # breakSetting + bs = re.search(r'', inner) + if bs: + bstr = bs.group(1) + item["breakSetting"] = { + "widowOrphan": _bool_attr(bstr, "widowOrphan"), + "keepWithNext": _bool_attr(bstr, "keepWithNext"), + "keepLines": _bool_attr(bstr, "keepLines"), + "pageBreakBefore": _bool_attr(bstr, "pageBreakBefore"), + "lineWrap": _str_attr(bstr, "lineWrap"), + "breakLatinWord": _str_attr(bstr, "breakLatinWord"), + "breakNonLatinWord": _str_attr(bstr, "breakNonLatinWord"), + } + + # margin (hp:case 랔록 낮 첫 번짞 사용 — HwpUnitChar case 우선) + case_block = re.search( + r']*required-namespace="[^"]*HwpUnitChar[^"]*"[^>]*>' + r'(.*?)', + inner, re.DOTALL + ) + margin_src = case_block.group(1) if case_block else inner + + margin = {} + for tag, key in [ + ("intent", "indent_hu"), + ("left", "left_hu"), + ("right", "right_hu"), + ("prev", "before_hu"), + ("next", "after_hu"), + ]: + m = re.search( + rf']*\bvalue="(-?\d+)"', margin_src + ) + if m: + margin[key] = int(m.group(1)) + + if margin: + item["margin"] = margin + + # lineSpacing + ls = re.search( + r']*\btype="([^"]+)"[^>]*\bvalue="(\d+)"', + margin_src + ) + if ls: + item["lineSpacing"] = { + "type": ls.group(1), + "value": int(ls.group(2)), + } + + # borderFillIDRef + bf = re.search(r']*\bborderFillIDRef="(\d+)"', inner) + if bf: + item["borderFillIDRef"] = int(bf.group(1)) + + result.append(item) + + return result if result else None + + +def _bool_attr(s: str, name: str) -> bool | None: + m = re.search(rf'\b{name}="(\d+)"', s) + return bool(int(m.group(1))) if m else None + + +def _str_attr(s: str, name: str) -> str | None: + m = re.search(rf'\b{name}="([^"]+)"', s) + return m.group(1) if m else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/section.py b/03. Code/geulbeot_8th/handlers/tools/section.py new file mode 100644 index 0000000..c93e2b0 --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/section.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +§9 구역 정의(Section) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§9 구역 속성 추출. + + Returns: + { + "textDirection": "HORIZONTAL", + "hideFirstHeader": False, + "hideFirstFooter": False, + "pageNum": {"pos": "BOTTOM_CENTER", "formatType": "DIGIT", + "sideChar": "-"}, + "startNum": {"page": 0}, + "colDef": None, + } + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + sec_match = re.search( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + if not sec_match: + return None + + attrs_str = sec_match.group(1) + inner = sec_match.group(2) + + result = {} + + # textDirection + td = re.search(r'\btextDirection="([^"]+)"', attrs_str) + if td: + result["textDirection"] = td.group(1) + + # visibility + vis = re.search(r'', inner) + if vis: + v = vis.group(1) + for attr in ["hideFirstHeader", "hideFirstFooter", + "hideFirstMasterPage", "hideFirstPageNum", + "hideFirstEmptyLine"]: + m = re.search(rf'\b{attr}="(\d+)"', v) + if m: + result[attr] = bool(int(m.group(1))) + + # startNum + sn = re.search(r'', inner) + if sn: + sns = sn.group(1) + start = {} + pso = re.search(r'\bpageStartsOn="([^"]+)"', sns) + if pso: + start["pageStartsOn"] = pso.group(1) + pg = re.search(r'\bpage="(\d+)"', sns) + if pg: + start["page"] = int(pg.group(1)) + if start: + result["startNum"] = start + + # pageNum + pn = re.search(r'', inner) + if pn: + pns = pn.group(1) + pagenum = {} + for attr in ["pos", "formatType", "sideChar"]: + m = re.search(rf'\b{attr}="([^"]*)"', pns) + if m: + pagenum[attr] = m.group(1) + if pagenum: + result["pageNum"] = pagenum + + # colDef (당 섀정) + cd = re.search(r']*)>(.*?)', inner, re.DOTALL) + if cd: + cds = cd.group(1) + coldef = {} + cnt = re.search(r'\bcount="(\d+)"', cds) + if cnt: + coldef["count"] = int(cnt.group(1)) + layout = re.search(r'\blayout="([^"]+)"', cds) + if layout: + coldef["layout"] = layout.group(1) + if coldef: + result["colDef"] = coldef + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/style_def.py b/03. Code/geulbeot_8th/handlers/tools/style_def.py new file mode 100644 index 0000000..f055bdd --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/style_def.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +슀타음 정의(Style) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + +charPrIDRef → charPr(Ꞁ자몚양), paraPrIDRef → paraPr(묞닚몚양) 연결. +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """슀타음 정의 추출. + + Returns: + [ + { + "id": 0, "type": "PARA", + "name": "바탕Ꞁ", "engName": "Normal", + "paraPrIDRef": 3, "charPrIDRef": 0, + "nextStyleIDRef": 0, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + styles = re.findall(r'', header_xml) + if not styles: + return None + + result = [] + for s in styles: + item = {} + for attr in ["id", "paraPrIDRef", "charPrIDRef", "nextStyleIDRef"]: + m = re.search(rf'\b{attr}="(\d+)"', s) + if m: + item[attr] = int(m.group(1)) + + for attr in ["type", "name", "engName"]: + m = re.search(rf'\b{attr}="([^"]*)"', s) + if m: + item[attr] = m.group(1) + + result.append(item) + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/handlers/tools/table.py b/03. Code/geulbeot_8th/handlers/tools/table.py new file mode 100644 index 0000000..d1f160c --- /dev/null +++ b/03. Code/geulbeot_8th/handlers/tools/table.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +§6 표(Table) 구조 추출 + +HWPX 싀제 태귞 (section0.xml): + + 8504 8504 8504 + 또는 ì—Ž 수에 맞는 hp:colSz 형태 + + + + + + + + 셀 텍슀튞 + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§6 몚든 표 추출. + + Returns: + [ + { + "index": 0, + "rowCnt": 5, "colCnt": 3, + "repeatHeader": True, + "pageBreak": "CELL", + "colWidths_hu": [8504, 8504, 8504], + "colWidths_pct": [33, 34, 33], + "rows": [ + [ # row 0 + { + "colAddr": 0, "rowAddr": 0, + "colSpan": 2, "rowSpan": 1, + "width_hu": 17008, "height_hu": 2400, + "borderFillIDRef": 5, + "cellMargin": {"left": 510, "right": 510, + "top": 142, "bottom": 142}, + "text": "셀 텍슀튞", + "lines": ["셀 텍슀튞"], + }, + ... + ], + ... + ], + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + # tbl 랔록 전첎 추출 + tbl_blocks = _find_tbl_blocks(section_xml) + if not tbl_blocks: + return None + + result = [] + for idx, (tbl_attrs, tbl_inner) in enumerate(tbl_blocks): + tbl = {"index": idx} + + # 표 속성 + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + tbl[attr] = int(m.group(1)) + + rh = re.search(r'\brepeatHeader="(\d+)"', tbl_attrs) + if rh: + tbl["repeatHeader"] = bool(int(rh.group(1))) + + pb = re.search(r'\bpageBreak="([^"]+)"', tbl_attrs) + if pb: + tbl["pageBreak"] = pb.group(1) + + # 행/셀 (ì—Ž 너비볎닀 뚌저 — 첫 행에서 ì—Ž 너비 추출 가능) + rows = _extract_rows(tbl_inner) + if rows: + tbl["rows"] = rows + + # ì—Ž 너비 + col_widths = _extract_col_widths(tbl_inner) + if not col_widths and rows: + # colSz 없윌멎 행 데읎터에서 추출 (colspan ê³ ë €) + col_cnt = tbl.get("colCnt", 0) + col_widths = _col_widths_from_rows(rows, col_cnt) + if not col_widths: + col_widths = _col_widths_from_first_row(rows[0]) + if col_widths: + tbl["colWidths_hu"] = col_widths + total = sum(col_widths) or 1 + tbl["colWidths_pct"] = [round(w / total * 100) for w in col_widths] + + result.append(tbl) + + return result if result else None + + +def _find_tbl_blocks(xml: str) -> list: + """쀑첩 표륌 고렀하여 최상위 tbl 랔록 추출""" + blocks = [] + start = 0 + while True: + # ]*)>', xml[start:]) + if not m: + break + + attrs = m.group(1) + tag_start = start + m.start() + content_start = start + m.end() + + # 쀑첩 칎욎튞로 닫는 태귞 ì°Ÿêž° + depth = 1 + pos = content_start + while depth > 0 and pos < len(xml): + open_m = re.search(r'', xml[pos:]) + + if close_m is None: + break + + if open_m and open_m.start() < close_m.start(): + depth += 1 + pos += open_m.end() + else: + depth -= 1 + if depth == 0: + inner = xml[content_start:pos + close_m.start()] + blocks.append((attrs, inner)) + pos += close_m.end() + + start = pos + + return blocks + + +def _extract_col_widths(tbl_inner: str) -> list | None: + """ì—Ž 너비 HWPUNIT 추출""" + # 팹턮 1: 8504 8504 8504 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + return [int(w) for w in wl.group(1).strip().split()] + except ValueError: + pass + + # 팹턮 2: 개별 colSz 태귞 + cols = re.findall(r']*\bwidth="(\d+)"', tbl_inner) + if cols: + return [int(c) for c in cols] + + return None + + +def _extract_rows(tbl_inner: str) -> list: + """tr/tc 파싱하여 2D 셀 ë°°ì—Ž 반환""" + rows = [] + + tr_blocks = re.findall( + r']*>(.*?)', tbl_inner, re.DOTALL + ) + + for tr_inner in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr_inner, re.DOTALL + ) + + for tc_match in tc_blocks: + tc_attrs = tc_match.group(1) + tc_inner = tc_match.group(2) + cell = _parse_cell(tc_attrs, tc_inner) + cells.append(cell) + + rows.append(cells) + + return rows + + +def _parse_cell(tc_attrs: str, tc_inner: str) -> dict: + """개별 셀 파싱""" + cell = {} + + # borderFillIDRef on tc tag + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # header flag + hd = re.search(r'\bheader="(\d+)"', tc_attrs) + if hd: + cell["isHeader"] = bool(int(hd.group(1))) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + h = re.search(r'\bheight="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + if h: + cell["height_hu"] = int(h.group(1)) + + # cellMargin + cm = re.search(r'', tc_inner) + if cm: + margin = {} + for side in ["left", "right", "top", "bottom"]: + m = re.search(rf'\b{side}="(\d+)"', cm.group(1)) + if m: + margin[side] = int(m.group(1)) + if margin: + cell["cellMargin"] = margin + + # 셀 텍슀튞 + texts = re.findall(r'([^<]*)', tc_inner) + all_text = " ".join(t.strip() for t in texts if t.strip()) + if all_text: + cell["text"] = all_text + + # ★ v2: 셀 낮 run의 charPrIDRef 추출 (슀타음 연결용) + run_cprs = re.findall(r']*\bcharPrIDRef="(\d+)"', tc_inner) + if run_cprs: + cell["charPrIDRefs"] = [int(c) for c in run_cprs] + cell["primaryCharPrIDRef"] = int(run_cprs[0]) + + # ★ v2: 셀 낮 p의 paraPrIDRef, styleIDRef 추출 + para_pprs = re.findall(r']*\bparaPrIDRef="(\d+)"', tc_inner) + if para_pprs: + cell["paraPrIDRefs"] = [int(p) for p in para_pprs] + cell["primaryParaPrIDRef"] = int(para_pprs[0]) + + para_stys = re.findall(r']*\bstyleIDRef="(\d+)"', tc_inner) + if para_stys: + cell["styleIDRefs"] = [int(s) for s in para_stys] + + # 닀쀑행 (p 태귞 Ʞ쀀) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + if lines: + cell["lines"] = lines + + return cell + + +def _col_widths_from_first_row(first_row: list) -> list | None: + """첫 행 셀의 width_hu에서 ì—Ž 너비 추출 (colSz 없을 때 대첎)""" + widths = [] + for cell in first_row: + w = cell.get("width_hu") + if w: + widths.append(w) + return widths if widths else None + + +def _col_widths_from_rows(rows: list, col_cnt: int) -> list | None: + """★ v2: 몚든 행을 순회하여 colspan=1읞 행에서 정확한 ì—Ž 너비 추출. + + 첫 행에 colspan읎 있윌멎 ì—Ž 너비가 부정확하므로, + 몚든 엎읎 colspan=1읞 행을 ì°Ÿì•„ 사용. + """ + if not rows or not col_cnt: + return None + + # colspan=1읞 셀만 있는 행 ì°Ÿêž° (몚든 ì—Ž 졎재) + for row in rows: + # 읎 행의 몚든 셀읎 colspan=1읎고, 셀 수 == col_cnt읞지 + all_single = all(cell.get("colSpan", 1) == 1 for cell in row) + if all_single and len(row) == col_cnt: + widths = [] + for cell in sorted(row, key=lambda c: c.get("colAddr", 0)): + w = cell.get("width_hu") + if w: + widths.append(w) + if len(widths) == col_cnt: + return widths + + # 못 찟윌멎 첫 행 폎백 + return _col_widths_from_first_row(rows[0]) if rows else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_8th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_8th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_8th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_8th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_8th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_8th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_8th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_8th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_8th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_8th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_8th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_8th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_8th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_8th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_8th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_8th/prompts/step1_5_plan.txt b/03. Code/geulbeot_8th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_8th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_8th/prompts/step1_extract.txt b/03. Code/geulbeot_8th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_8th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_8th/prompts/step2_generate.txt b/03. Code/geulbeot_8th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_8th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                                  + +
                                  +

                                  {{title}}

                                  +
                                  +
                                  +
                                  +
                                  +
                                  {{lead.text}} - 킀워드 강조
                                  +
                                  + +
                                  +
                                  {{conclusion.label}}
                                  +
                                  {{conclusion.text}}
                                  +
                                  +
                                  +
                                  - 1 -
                                  +
                                  + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                                  +
                                  {{section.title}}
                                  +
                                    +
                                  • {{item.keyword}}: {{item.text}} {{highlight}}
                                  • +
                                  +
                                  +``` + +### table → data-table +```html +
                                  +
                                  {{section.title}}
                                  + + + + + + + + + + + + + +
                                  {{col1}}{{col2}}
                                  {{text}}{{text}}
                                  +
                                  +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                                  +
                                  {{section.title}}
                                  +
                                  +
                                  +
                                  {{item.title}}
                                  +

                                  {{item.text}} {{highlight}}

                                  +
                                  +
                                  +
                                  +``` + +### two-column → two-col +```html +
                                  +
                                  {{section.title}}
                                  +
                                  +
                                  +
                                  {{item.title}}
                                  +

                                  {{item.text}} {{highlight}}

                                  +
                                  +
                                  +
                                  +``` + +### process → process-container +```html +
                                  +
                                  {{section.title}}
                                  +
                                  +
                                  +
                                  {{step.number}}
                                  +
                                  {{step.title}}: {{step.text}}
                                  +
                                  +
                                  ▌
                                  + +
                                  +
                                  +``` + +### qa → qa-grid +```html +
                                  +
                                  {{section.title}}
                                  +
                                  +
                                  + Q. {{question}}
                                  + A. {{answer}} +
                                  +
                                  +
                                  +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                                  [첚부] 핎당 낎용에 맞는 제목

                                  ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                                  [첚부] 핎당 페읎지 낎용에 맞는 제목

                                  ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_8th/requirements.txt b/03. Code/geulbeot_8th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_8th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_8th/static/css/editor.css b/03. Code/geulbeot_8th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_8th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/static/js/editor.js b/03. Code/geulbeot_8th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_8th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                                  + + + +
                                  + + + + +
                                  + + +
                                  +
                                  + A + +
                                  +
                                  + A + +
                                  + + +
                                  + + + +
                                  + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                                  +
                                  +
                                  ▩ 표 삜입
                                  +
                                  + + +
                                  +
                                  + + +
                                  +
                                  + + +
                                  +
                                  + + +
                                  +
                                  +
                                  + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                                  헀더낎용
                                  '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                                  + +
                                  귞늌 섀명
                                  +
                                  `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                                  '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                                  + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                                  + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_8th/templates/default/doc_types/briefing/config.json b/03. Code/geulbeot_8th/templates/default/doc_types/briefing/config.json new file mode 100644 index 0000000..20369db --- /dev/null +++ b/03. Code/geulbeot_8th/templates/default/doc_types/briefing/config.json @@ -0,0 +1,26 @@ +{ + "id": "briefing", + "name": "Ʞ획서", + "icon": "📋", + "description": "1~2페읎지 분량의 임원 볎고용 묞서", + "features": [ + {"icon": "📌", "text": "헀더 + 제목 랔록"}, + {"icon": "💡", "text": "핵심 요앜 (Lead Box)"}, + {"icon": "📊", "text": "볞묞 섹션 + 첚부"} + ], + "thumbnailType": "briefing", + "enabled": true, + "isDefault": true, + "order": 1, + "options": { + "pageConfig": { + "type": "radio-with-input", + "choices": [ + {"value": "body-only", "label": "(볞묞) 1p"}, + {"value": "body-attach", "label": "(볞묞) 1p + (첚부)", "hasInput": true, "inputSuffix": "p", "inputDefault": 1, "inputMin": 1, "inputMax": 10, "default": true} + ] + } + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/default/doc_types/presentation/config.json b/03. Code/geulbeot_8th/templates/default/doc_types/presentation/config.json new file mode 100644 index 0000000..3b8c5db --- /dev/null +++ b/03. Code/geulbeot_8th/templates/default/doc_types/presentation/config.json @@ -0,0 +1,27 @@ +{ + "id": "presentation", + "name": "발표자료", + "icon": "📊", + "description": "슬띌읎드 형식의 프레젠테읎션", + "features": [ + {"icon": "🎯", "text": "슬띌읎드 레읎아웃"}, + {"icon": "📈", "text": "찚튞/닀읎얎귞랚"}, + {"icon": "🎚", "text": "비죌얌 쀑심 구성"} + ], + "thumbnailType": "ppt", + "enabled": false, + "isDefault": true, + "order": 3, + "badge": "쀀비쀑", + "options": { + "slideCount": [ + {"value": "auto", "label": "자동 (낎용 êž°ë°˜)", "default": true}, + {"value": "5", "label": "5장 읎낎"}, + {"value": "10", "label": "10장 읎낎"}, + {"value": "20", "label": "20장 읎낎"} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/default/doc_types/report/config.json b/03. Code/geulbeot_8th/templates/default/doc_types/report/config.json new file mode 100644 index 0000000..578026e --- /dev/null +++ b/03. Code/geulbeot_8th/templates/default/doc_types/report/config.json @@ -0,0 +1,26 @@ +{ + "id": "report", + "name": "볎고서", + "icon": "📄", + "description": "닀페읎지 분량의 상섞 볎고서", + "features": [ + {"icon": "📘", "text": "표지 (선택)"}, + {"icon": "📑", "text": "목찚 자동 생성"}, + {"icon": "📝", "text": "챕터별 낎지"} + ], + "thumbnailType": "report", + "enabled": true, + "isDefault": true, + "order": 2, + "options": { + "components": [ + {"id": "cover", "label": "표지", "icon": "📘", "default": true}, + {"id": "toc", "label": "목찚", "icon": "📑", "default": true}, + {"id": "divider", "label": "간지", "icon": "📄", "default": false}, + {"id": "content", "label": "낎지 (필수)", "icon": "📝", "default": true, "required": true} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/hwp_guide.md b/03. Code/geulbeot_8th/templates/hwp_guide.md new file mode 100644 index 0000000..da7aafa --- /dev/null +++ b/03. Code/geulbeot_8th/templates/hwp_guide.md @@ -0,0 +1,302 @@ +# A4 HTML 묞서 레읎아웃 가읎드 +> 읎 가읎드는 Ꞁ벗 doc_template_analyzer가 HWPX에서 추출한 구조륌 +> A4 규격 HTML template.html로 변환할 때 찞조하는 레읎아웃 규격입니닀. +> +> ★ 읎 파음의 값은 윔드에 하드윔딩하지 않습니닀. +> ★ doc_template_analyzer._build_css(), _build_full_html() 등에서 읎 파음을 읜얎 적용합니닀. +> ★ 색상, 폰튾 등 슀타음은 HWPX에서 추출한 값을 우선 사용하고, 없윌멎 읎 가읎드의 Ʞ볞값을 사용합니닀. + +--- + +## 1. 페읎지 규격 (Page Dimensions) + +```yaml +page: + width: 210mm # A4 가로 + height: 297mm # A4 섞로 + background: white + boxSizing: border-box + +margins: + top: 20mm # 상닚 여백 (뚞늿말 + 볞묞 시작) + bottom: 20mm # 하당 여백 (ꌬ늿말 + 볞묞 끝) + left: 20mm # 좌잡 여백 + right: 20mm # ìš°ìž¡ 여백 + +# 볞묞 가용 높읎 = 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px +bodyMaxHeight: 970px +``` + +## 2. HTML 곚격 구조 (Page Structure) + +각 페읎지는 `.sheet` 큎래슀로 감싞며, 낎부에 header/body/footer륌 absolute로 배치합니닀. + +```html + +
                                  + + + + +
                                  + +
                                  + + + +
                                  +``` + +## 3. 핵심 CSS 레읎아웃 (Layout CSS) + +### 3.1 용지 (.sheet) +```css +.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); +} +``` + +### 3.2 읞쇄 대응 +```css +@media print { + .sheet { margin: 0; break-after: page; box-shadow: none; } + body { background: white; } +} +``` + +### 3.3 뚞늿말 (.page-header) +```css +.page-header { + position: absolute; + top: 10mm; /* 상닚 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + padding-bottom: 5px; +} +``` +- 뚞늿말읎 **테읎랔 형태**읞 겜우: `` 사용, 테두늬 없음 +- HWPX에서 추출한 ì—Ž 수와 셀 낎용을 placeholder로 배치 +- 닀쀑행 셀은 `
                                  `로 쀄바꿈 + +### 3.4 ꌬ늿말 (.page-footer) +```css +.page-footer { + position: absolute; + bottom: 10mm; /* 하당 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + color: #555; + border-top: 1px solid #eee; + padding-top: 5px; +} +``` +- ꌬ늿말읎 **테읎랔 형태**읞 겜우: `
                                  ` 사용, 테두늬 없음 +- 2ì—Ž 읎상음 때 `display: flex; justify-content: space-between` 팹턮도 가능 +- 페읎지 번혞는 별도 `` 윌로 + +### 3.5 볞묞 영역 (.body-content) +```css +.body-content { + position: absolute; + top: 20mm; + left: 20mm; + right: 20mm; + bottom: 20mm; /* 또는 auto + JS 제얎 */ +} +``` + +## 4. 타읎포귞래플 Ʞ볞값 (Typography Defaults) + +> HWPX에서 폰튾/크Ʞ륌 추출했윌멎 ê·ž 값을 사용합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 적용합니닀. + +```yaml +typography: + fontFamily: "'Noto Sans KR', sans-serif" + fontImport: "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap" + + body: + fontSize: 12pt + lineHeight: 1.6 + textAlign: justify + wordBreak: keep-all # 한Ꞁ 닚얎 쀑간 끊김 방지 + + heading: + h1: { fontSize: 20pt, fontWeight: 900 } + h2: { fontSize: 18pt, fontWeight: 700 } + h3: { fontSize: 14pt, fontWeight: 700 } + + headerFooter: + fontSize: 9pt + + table: + fontSize: 9.5pt + thFontSize: 9pt +``` + +## 5. 표 슀타음 Ʞ볞값 (Table Defaults) + +```yaml +table: + width: "100%" + borderCollapse: collapse + tableLayout: fixed # colgroup 비윚 적용 시 fixed 필수 + borderTop: "2px solid" # 상닚 굵은 선 (색상은 HWPX 추출) + + th: + fontWeight: 900 + textAlign: center + verticalAlign: middle + whiteSpace: nowrap # 헀더 셀은 한 쀄 유지 + wordBreak: keep-all + padding: "6px 5px" + + td: + textAlign: center + verticalAlign: middle + wordBreak: keep-all + wordWrap: break-word + padding: "6px 5px" + border: "1px solid #ddd" +``` + +## 6. 뚞늿말/ꌬ늿말 테읎랔 (Header/Footer Table) + +뚞늿말/ꌬ늿말읎 HWPX에서 테읎랔로 구성된 겜우: + +```yaml +headerFooterTable: + border: none # 테두늬 없음 + width: "100%" + fontSize: 9pt + + # ì—Ž 역할 팹턮 (HWPX에서 추출) + # 볎통 3ì—Ž: [소속정볎 | 빈칞/로고 | 작성자/날짜] + # 또는 2ì—Ž: [제목 | 페읎지번혞] + + cellStyle: + padding: "2px 5px" + verticalAlign: middle + border: none +``` + +## 7. 개조식 (Bullet Style) + +```yaml +bulletList: + marker: "·" # 한국 묞서 Ʞ볞 불늿 + className: "bullet-list" + + css: | + .bullet-list { + list-style: none; + padding-left: 15px; + margin: 5px 0; + } + .bullet-list li::before { + content: "· "; + font-weight: bold; + } + .bullet-list li { + margin-bottom: 3px; + line-height: 1.5; + } +``` + +## 8. 색상 (Color Scheme) + +> HWPX에서 추출한 색상을 CSS 변수로 죌입합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 사용합니닀. + +```yaml +colors: + # Navy 계엎 (Ʞ볞) + primary: "#1a365d" + accent: "#2c5282" + lightBg: "#EBF4FF" + + # 묞서별 였버띌읎드 (HWPX 추출값) + # doc_template_analyzer가 HWPX의 Ꞁ자색/배겜색을 분석하여 + # 읎 값을 덮얎씁니닀. + + css: | + :root { + --primary: #1a365d; + --accent: #2c5282; + --light-bg: #EBF4FF; + --bg: #f5f5f5; + } +``` + +## 9. 페읎지 분할 규칙 (Page Break Rules) + +```yaml +pageBreak: + # H1(대제목)에서만 강제 페읎지 분할 + h1Break: true + + # H2/H3읎 페읎지 하닚에 홀로 낚지 않도록 + orphanControl: true + orphanMinSpace: 90px # 읎 공간 믞만읎멎 닀음 페읎지로 + + # 표/귞늌은 분할하지 않음 + atomicBlocks: + - table + - figure + - ".highlight-box" + + # break-inside: avoid 적용 대상 + avoidBreakInside: + - table + - figure + - ".atomic-block" +``` + +## 10. 배겜 (Preview Background) + +```yaml +preview: + bodyBackground: "#525659" # 회색 배겜 위에 흰색 용지 + # 읞쇄 시 배겜 제거 (@media print) +``` + +--- + +## ★ 사용 방법 (How doc_template_analyzer uses this guide) + +1. `doc_template_analyzer._build_full_html()` 혞출 시: + - 읎 가읎드 파음을 읜음 + - HWPX에서 추출한 슀타음(색상, 폰튾, 크Ʞ)읎 있윌멎 였버띌읎드 + - 없윌멎 가읎드 Ʞ볞값 사용 + +2. CSS 생성 순서: + ``` + 가읎드 Ʞ볞값 → HWPX 추출 슀타음 였버띌읎드 → CSS 변수로 통합 + ``` + +3. HTML 구조: + ``` + 가읎드의 곚격(.sheet > .page-header + .body-content + .page-footer) + + HWPX에서 추출한 placeholder 배치 + = template.html + ``` + +4. 색상 결정: + ``` + HWPX headerTextColor → --primary + HWPX headerBgColor → --light-bg + 없윌멎 → 가읎드 Ʞ볞값(Navy 계엎) + ``` \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/hwp_html_defaults.json b/03. Code/geulbeot_8th/templates/hwp_html_defaults.json new file mode 100644 index 0000000..34b5243 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/hwp_html_defaults.json @@ -0,0 +1,116 @@ +{ + "_comment": "A4 HTML 묞서 레읎아웃 Ʞ볞값 - hwp_html_guide.md ì°žì¡°. HWPX 추출값읎 있윌멎 였버띌읎드됚", + + "page": { + "width": "210mm", + "height": "297mm", + "background": "white" + }, + + "margins": { + "top": "20mm", + "bottom": "20mm", + "left": "20mm", + "right": "20mm" + }, + + "headerPosition": { + "top": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "footerPosition": { + "bottom": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "bodyContent": { + "top": "20mm", + "left": "20mm", + "right": "20mm", + "bottom": "20mm" + }, + + "bodyMaxHeight": "970px", + + "preview": { + "bodyBackground": "#f5f5f5", + "sheetMargin": "20px auto", + "sheetShadow": "0 0 15px rgba(0,0,0,0.1)" + }, + + "typography": { + "fontFamily": "'Noto Sans KR', sans-serif", + "fontImport": "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap", + "body": { + "fontSize": "10pt", + "lineHeight": "1.6", + "textAlign": "justify", + "wordBreak": "keep-all" + }, + "heading": { + "h1": { "fontSize": "20pt", "fontWeight": "900" }, + "h2": { "fontSize": "16pt", "fontWeight": "700" }, + "h3": { "fontSize": "13pt", "fontWeight": "700" } + }, + "headerFooter": { + "fontSize": "9pt" + } + }, + + "colors": { + "primary": "#1a365d", + "accent": "#2c5282", + "lightBg": "#EBF4FF", + "text": "#000", + "headerText": "#000", + "footerText": "#555", + "footerBorder": "#eee", + "tableBorderTop": "#1a365d", + "tableBorder": "#ddd", + "tableHeaderBg": "#EBF4FF" + }, + + "table": { + "width": "100%", + "borderCollapse": "collapse", + "tableLayout": "fixed", + "fontSize": "9.5pt", + "th": { + "fontSize": "9pt", + "fontWeight": "900", + "textAlign": "center", + "verticalAlign": "middle", + "whiteSpace": "nowrap", + "padding": "6px 5px" + }, + "td": { + "textAlign": "center", + "verticalAlign": "middle", + "wordBreak": "keep-all", + "padding": "6px 5px" + } + }, + + "headerFooterTable": { + "border": "none", + "width": "100%", + "fontSize": "9pt", + "cellPadding": "2px 5px" + }, + + "bulletList": { + "marker": "·", + "className": "bullet-list", + "paddingLeft": "15px", + "itemMargin": "3px 0" + }, + + "pageBreak": { + "h1Break": true, + "orphanControl": true, + "orphanMinSpace": "90px" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/index.html b/03. Code/geulbeot_8th/templates/index.html new file mode 100644 index 0000000..d805e73 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/index.html @@ -0,0 +1,3766 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + +
                                  + + +
                                  + + + +
                                  + + + +
                                  + + + + + +
                                  + + +
                                  + + + + +
                                  +
                                  +
                                  +
                                  + +
                                  +
                                  📄
                                  +
                                  HTML을 입력하고 생성하섞요
                                  +
                                  좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                                  +
                                  +
                                  +
                                  +
                                  + + +
                                  + + +
                                  + + + +
                                  + + +
                                  +
                                  + 묞서 섀정 +
                                  +
                                  + +
                                  +
                                  묞서 유형
                                  +
                                  + +
                                  + + + +
                                  + + +
                                  + +
                                  + + +
                                  +
                                  템플늿
                                  +
                                  +
                                  + + 📄 Ʞ볞 템플늿 +
                                  +
                                  +
                                  + + + +
                                  + + +
                                  +
                                  요청사항
                                  + +
                                  + + + +
                                  +
                                  +
                                  + + +
                                  +
                                  + + 쀀비됚 +
                                  +
                                  Ꞁ벗 Light v2.1
                                  +
                                  + + + + + + + + + + + +
                                  + +
                                  🀖 AI로 수정하Ʞ
                                  +
                                  선택된 텍슀튞:
                                  +
                                  + + +
                                  + + +
                                  +
                                  +
                                  +
                                  📁 템플늿 추가
                                  + +
                                  + +
                                  + + +
                                  + +
                                  + +
                                  +
                                  📄
                                  +
                                  파음을 드래귞하거나 큎늭하여 선택
                                  +
                                  HWPX, HWP, PDF 지원
                                  +
                                  + +
                                  + + ✕ +
                                  +
                                  + + +
                                  +
                                  + + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/config.json b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/config.json new file mode 100644 index 0000000..7e27ed6 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/config.json @@ -0,0 +1,165 @@ +{ + "id": "user_1770300969", + "name": "3", + "icon": "📄", + "description": "3", + "features": [ + { + "icon": "📋", + "text": "발표 Ʞ획서" + }, + { + "icon": "🎯", + "text": "특정 죌제에 대한 발표 낎용..." + }, + { + "icon": "👥", + "text": "상위 결재자 또는 발표 승읞권자" + }, + { + "icon": "📄", + "text": "앜 2p" + } + ], + "thumbnailType": "custom", + "enabled": true, + "isDefault": false, + "order": 100, + "template_id": "tpl_1770300969", + "context": { + "documentDefinition": "발표륌 하Ʞ 위한 Ʞ획서", + "documentType": "발표 Ʞ획서", + "purpose": "특정 죌제에 대한 발표 낎용곌 구성을 사전에 계획하고 승읞받Ʞ 위핚", + "perspective": "발표할 낎용을 첎계적윌로 구조화하여 청쀑에게 횚곌적윌로 전달할 수 있도록 Ʞ획하는 ꎀ점", + "audience": "상위 결재자 또는 발표 승읞권자", + "tone": "제안형" + }, + "layout": { + "hasHeader": true, + "headerLayout": { + "structure": "테읎랔", + "colCount": 3, + "rowCount": 1, + "cellTexts": [ + "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "", + "2025. 2. 5(목)" + ], + "cellLines": [ + [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ], + [], + [ + "2025. 2. 5(목)" + ] + ] + }, + "hasFooter": true, + "footerLayout": { + "structure": "테읎랔", + "colCount": 3, + "rowCount": 1, + "cellTexts": [ + "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "", + "" + ], + "cellLines": [ + [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ], + [], + [] + ] + }, + "titleBlock": { + "type": "테읎랔", + "colCount": 2, + "text": "AI 업묎 활용 적용 사례 발표 계획(안)" + }, + "sections": [ + { + "name": "개요", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "발표 구성(안)", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "발표 낎용", + "hasBulletIcon": true, + "hasTable": true, + "tableIndex": 0 + } + ], + "overallStyle": { + "writingStyle": "개조식", + "bulletType": "-", + "tableUsage": "볎통" + } + }, + "structure": { + "sectionGuides": [ + { + "name": "개요", + "role": "발표의 목적곌 배겜을 명확히 제시하여 청쀑의 읎핎륌 돕는 섹션", + "writingStyle": "개조식", + "contentGuide": "발표 죌제, 발표 목적, 대상 청쀑, 핵심 메시지륌 간결하게 나엎. 불늿 포읞튞로 구성하여 핵심 낎용을 한눈에 파악할 수 있도록 작성", + "hasTable": false + }, + { + "name": "발표 구성(안)", + "role": "발표의 전첎 흐늄곌 구조륌 믞늬 볎여죌얎 발표 진행 방향을 안낎하는 섹션", + "writingStyle": "개조식", + "contentGuide": "발표 제목곌 부제목을 명시하고, 발표의 전첎적읞 틀을 제시. 청쀑읎 발표 흐늄을 예잡할 수 있도록 구성", + "hasTable": false + }, + { + "name": "발표 낎용", + "role": "싀제 발표에서 닀룰 구첎적읞 낎용을 첎계적윌로 정늬하여 발표 쀀비륌 완성하는 핵심 섹션", + "writingStyle": "개조식", + "contentGuide": "발표 순서에 따띌 각 닚계별 낎용을 상섞히 Ʞ술. 표륌 활용하여 구조화된 정볎 제공", + "hasTable": true, + "tableStructure": { + "columns": 3, + "columnDefs": [ + { + "name": "구분", + "role": "발표 닚계나 죌제륌 구분하는 분류 Ʞ쀀", + "style": "간결한 킀워드나 닚계명윌로 작성" + }, + { + "name": "낎용", + "role": "각 구분별 구첎적읞 발표 낎용곌 섞부사항", + "style": "상섞한 섀명곌 하위 항목을 포핚한 개조식 나엎" + }, + { + "name": "비고", + "role": "추가 정볎나 찞고사항, 시간 배분 등 부가적읞 안낎", + "style": "간략한 메몚나 시간, 페읎지 수 등의 볎조 정볎" + } + ], + "rowGuide": "각 행은 발표의 녌늬적 흐늄에 따띌 순찚적윌로 배엎되며, 하나의 발표 닚계나 죌요 죌제륌 나타냄" + } + } + ], + "writingPrinciples": [ + "발표자와 청쀑 몚두가 읎핎하Ʞ 쉜도록 개조식윌로 간결하게 작성", + "발표의 녌늬적 흐늄을 고렀하여 구조화된 정볎 제공", + "표륌 활용하여 복잡한 낎용을 첎계적윌로 정늬", + "각 섹션은 발표 쀀비와 싀행에 필요한 싀용적 정볎 쀑심윌로 구성" + ], + "pageEstimate": 2 + }, + "options": {}, + "createdAt": "2026-02-05T23:16:09Z", + "updatedAt": "2026-02-05T23:16:09Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/content_prompt.json b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/content_prompt.json new file mode 100644 index 0000000..0be201c --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770300969/content_prompt.json @@ -0,0 +1,267 @@ +{ + "version": "1.0", + "document": { + "paper": "A4", + "layout": "landscape", + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + }, + "purpose_hint": "", + "audience_hint": "", + "tone_hint": "" + }, + "placeholders": { + "HEADER_R1_C1_LINE_1": { + "type": "department", + "pattern": "조직명", + "example": "쎝ꎄꞰ획싀", + "location": "header" + }, + "HEADER_R1_C1_LINE_2": { + "type": "team", + "pattern": "팀명", + "example": "Ʞ술Ʞ획팀", + "location": "header" + }, + "HEADER_R1_C2": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "header" + }, + "HEADER_R1_C3": { + "type": "date", + "pattern": "날짜 (YYYY. M. D)", + "example": "2025. 2. 5(목)", + "location": "header" + }, + "FOOTER_R1_C1_LINE_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "Ʞ술 로 사람 곌 자연 읎", + "location": "footer" + }, + "FOOTER_R1_C1_LINE_2": { + "type": "slogan", + "pattern": "회사 슬로걎/비전", + "example": "핚께하는 섞상을 만듀얎 갑니닀.", + "location": "footer" + }, + "FOOTER_R1_C2": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "footer" + }, + "FOOTER_R1_C3": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "footer" + }, + "TITLE_R1_C2": { + "type": "doc_title", + "pattern": "묞서 제목", + "example": "AI 업묎 활용 적용 사례 발표 계획(안)", + "location": "title_block" + }, + "SECTION_1_TITLE": { + "type": "section_title", + "pattern": "섹션 제목", + "example": "", + "location": "body" + }, + "IMAGE_1": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image1", + "location": "body" + }, + "IMAGE_1_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 개요", + "location": "body" + }, + "IMAGE_2": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_2_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " AI륌 활용한 “업묎 횚윚성 개선 사례”와 읎륌 구현한 방식에 대한 공유", + "location": "body" + }, + "PARA_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연", + "location": "body" + }, + "IMAGE_3": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image1", + "location": "body" + }, + "IMAGE_3_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 발표 구성(안)", + "location": "body" + }, + "IMAGE_4": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_4_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 제목 : AI 활용 묞서 업묎 개선 사례 -「Ꞁ벗」(사용자의 Ꞁ쓰Ʞ륌 돕는 친구) -", + "location": "body" + }, + "IMAGE_5": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_5_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 발표 낎용 ", + "location": "body" + }, + "TABLE_1_H_C1": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "구분", + "location": "table_1" + }, + "TABLE_1_H_C2": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "낎용", + "location": "table_1" + }, + "TABLE_1_H_C3": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "비고", + "location": "table_1" + }, + "TABLE_1_BODY": { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML
                                  반복)", + "example": "", + "location": "table_1" + } + }, + "table_guide": { + "1": { + "col_headers": [ + "구분", + "낎용", + "비고" + ], + "col_count": 3, + "row_count": 5, + "merge_pattern": { + "col_0": "col_span", + "col_3": "row_group" + }, + "bullet_chars": [ + "- ", + "· " + ], + "example_rows": [ + [ + "소개", + "개요", + "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입 · 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등) ...", + "1p" + ], + [ + "Ꞁ벗 소개", + "- Ꞁ벗 Ʞ능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ ·..." + ], + [ + "시연", + "Ꞁ벗 시연", + "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성 - (Ʞ능 2) (Process) 웹 펞집Ʞ - (Ʞ능 3) (Exp...", + "Ꞁ벗 & Visual Studio" + ] + ], + "col_types": [ + { + "col": 0, + "type": "category", + "header": "구분" + }, + { + "col": 1, + "type": "content", + "header": "낎용" + }, + { + "col": 2, + "type": "note", + "header": "비고" + } + ], + "row_bf_pattern": [ + { + "col": 0, + "bf_class": "bf-12", + "colSpan": 1, + "rowSpan": 2 + }, + { + "col": 1, + "bf_class": "bf-8", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 2, + "bf_class": "bf-7", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 3, + "bf_class": "bf-19", + "colSpan": 1, + "rowSpan": 2 + } + ] + } + }, + "writing_guide": { + "bullet_styles": [ + "- ", + "· " + ], + "numbering_patterns": [ + [ + "^1.", + "^2.", + "^3)" + ] + ], + "avg_line_length": 16, + "font_primary": "돋움", + "font_size_body": "10.0pt" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/config.json b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/config.json new file mode 100644 index 0000000..e765733 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/config.json @@ -0,0 +1,184 @@ +{ + "id": "user_1770301063", + "name": "55", + "icon": "📄", + "description": "55", + "features": [ + { + "icon": "📋", + "text": "평가볎고서" + }, + { + "icon": "🎯", + "text": "완성된 제품/솔룚션의 현황을..." + }, + { + "icon": "👥", + "text": "개발팀, ꎀ늬자, 의사결정권자" + }, + { + "icon": "📄", + "text": "앜 2p" + } + ], + "thumbnailType": "custom", + "enabled": true, + "isDefault": false, + "order": 100, + "template_id": "tpl_1770301063", + "context": { + "documentDefinition": "개발된 솔룚션을 검토하고 개선방향을 제시하Ʞ 위한 평가볎고서", + "documentType": "평가볎고서", + "purpose": "완성된 제품/솔룚션의 현황을 정늬하고, 장닚점을 분석하여 향후 개선방향을 제시", + "perspective": "객ꎀ적 평가와 걎섀적 개선안 도출 ꎀ점윌로 재구성", + "audience": "개발팀, ꎀ늬자, 의사결정권자", + "tone": "분석형/제안형" + }, + "layout": { + "hasHeader": false, + "headerLayout": { + "structure": "없음" + }, + "hasFooter": false, + "footerLayout": { + "structure": "없음" + }, + "titleBlock": { + "type": "없음" + }, + "sections": [ + { + "name": "1. (슀마튞섀계팀) SamanPro(V3.0)", + "hasBulletIcon": false, + "hasTable": false, + "tableIndex": null + }, + { + "name": "낎용 요앜", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "죌요 Ʞ능", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "ꎀ렚 의견", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "장점", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "확읞 필요 지점", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "개선 방향 제안", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + } + ], + "overallStyle": { + "writingStyle": "혌합", + "bulletType": "·", + "tableUsage": "볎통" + } + }, + "structure": { + "sectionGuides": [ + { + "name": "1. (슀마튞섀계팀) SamanPro(V3.0)", + "role": "평가 대상 솔룚션의 제목곌 개발팀 정볎륌 명시하는 헀더 섹션", + "writingStyle": "제목식", + "contentGuide": "순번, 개발팀명(ꎄ혞), 솔룚션명, 버전 정볎륌 포핚한 간결한 제목 형식", + "hasTable": false + }, + { + "name": "낎용 요앜", + "role": "솔룚션의 핵심 목적곌 AI 활용 방식을 간략히 개ꎄ하는 섹션", + "writingStyle": "서술식", + "contentGuide": "솔룚션의 죌요 목적, AI Ʞ술 활용 방법, 전첎적읞 ì ‘ê·Œ 방식을 2-3묞장윌로 요앜", + "hasTable": false + }, + { + "name": "죌요 Ʞ능", + "role": "솔룚션에서 제공하는 구첎적읞 Ʞ능듀을 칎테고늬별로 나엎하는 섹션", + "writingStyle": "개조식", + "contentGuide": "Ʞ능 칎테고늬별로 구분하여 나엎하며, 각 Ʞ능의 섞부 사항곌 활용 방법을 불늿 포읞튞로 정늬. 부가 섀명은 각죌나 ꎄ혞로 표Ʞ", + "hasTable": false + }, + { + "name": "ꎀ렚 의견", + "role": "솔룚션에 대한 전반적읞 평가와 특징을 제시하는 섹션", + "writingStyle": "개조식", + "hasTable": true, + "contentGuide": "솔룚션의 핵심 특징곌 ì ‘ê·Œ 방식을 평가자 ꎀ점에서 서술. 표륌 통핎 구첎적 낎용을 볎완", + "tableStructure": { + "columns": 3, + "columnDefs": [ + { + "name": "좌잡 여백", + "role": "여백 또는 표시자", + "style": "빈 칞" + }, + { + "name": "죌요 낎용", + "role": "평가 의견의 핵심 낎용", + "style": "서술식 묞장" + }, + { + "name": "ìš°ìž¡ 여백", + "role": "여백 또는 추가 정볎", + "style": "빈 칞 또는 볎조 정볎" + } + ], + "rowGuide": "평가자의 ꎀ점에서 볞 솔룚션의 특징곌 의의륌 쀑앙 엎에 Ʞ술" + } + }, + { + "name": "장점", + "role": "솔룚션의 Ɥ정적읞 잡멎듀을 구첎적윌로 나엎하는 섹션", + "writingStyle": "개조식", + "contentGuide": "솔룚션의 우수한 점듀을 불늿 포읞튞로 나엎하며, 정량적 성곌나 구첎적 사례륌 포핚하여 섀득력 있게 작성", + "hasTable": false + }, + { + "name": "확읞 필요 지점", + "role": "솔룚션 욎영 시 검토가 필요한 사항듀을 질묞 형태로 제시하는 섹션", + "writingStyle": "개조식", + "contentGuide": "욎영상 고렀핎알 할 읎슈듀을 질묞 형태로 제Ʞ하고, 화삎표(→)륌 사용하여 핎결 방향읎나 고렀사항을 제안", + "hasTable": false + }, + { + "name": "개선 방향 제안", + "role": "솔룚션의 향후 발전 방향곌 개선 사항듀을 구첎적윌로 제안하는 섹션", + "writingStyle": "개조식", + "contentGuide": "개선 영역별로 구분하여 제안사항을 나엎하며, 번혞나 ꎄ혞륌 사용하여 섞부 항목을 정늬. 예시나 구첎적 방안을 포핚하여 싀행 가능한 제안윌로 작성", + "hasTable": false + } + ], + "writingPrinciples": [ + "Ʞ술적 섞부사항곌 업묎 프로섞슀륌 정확히 읎핎하고 전묞적윌로 평가", + "장점곌 개선점을 균형있게 제시하여 객ꎀ적 평가 유지", + "구첎적 사례와 정량적 데읎터륌 활용하여 섀득력 있는 평가 작성", + "싀묎진읎 싀제 적용할 수 있는 구첎적읎고 싀행 가능한 개선 방안 제시", + "Ʞ술 발전곌 업묎 횚윚성 잡멎에서 솔룚션의 가치륌 닀각도로 분석" + ], + "pageEstimate": 2 + }, + "options": {}, + "createdAt": "2026-02-05T23:17:43Z", + "updatedAt": "2026-02-05T23:17:43Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/content_prompt.json b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/content_prompt.json new file mode 100644 index 0000000..0c83dba --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/doc_types/user_1770301063/content_prompt.json @@ -0,0 +1,295 @@ +{ + "version": "1.0", + "document": { + "paper": "A4", + "layout": "landscape", + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + }, + "purpose_hint": "", + "audience_hint": "", + "tone_hint": "" + }, + "placeholders": { + "SECTION_1_TITLE": { + "type": "section_title", + "pattern": "섹션 제목", + "example": "1. (슀마튞섀계팀) SamanPro(V3.0)", + "location": "body" + }, + "IMAGE_1": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image9", + "location": "body" + }, + "IMAGE_1_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 낎용 요앜", + "location": "body" + }, + "IMAGE_2": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image10", + "location": "body" + }, + "IMAGE_2_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 반복적 도로 섀계 계산 작업곌 행정의 자동화륌 위한 통합 섀계 플랫폌 구축", + "location": "body" + }, + "PARA_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "AI는 앱 개발 곌정에서 윔드 생성곌 에러 핎결을 위한 방식윌로 활용", + "location": "body" + }, + "IMAGE_3": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image10", + "location": "body" + }, + "IMAGE_3_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 죌요 Ʞ능", + "location": "body" + }, + "PARA_2": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "계산, 왞부 데읎터 확읞(API) 및 제안서 작성 시 활용할 수 있는 프롬프튞 등", + "location": "body" + }, + "PARA_3": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " · 포장섀계, 동결심도, 수늬계산, 확폭계산, 펞겜사계산 등 섀계 ꎀ렚 계산 Ʞ능곌 착수 음자와 곌업Ʞ간 입력 시, 쀑공음자 표출되는 쀀공계 볎조 계산 Ʞ능 등", + "location": "body" + }, + "PARA_4": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " · 한국은행 API륌 활용하여 (연도별/분Ʞ별) 걎섀투자 GDP 제공", + "location": "body" + }, + "PARA_5": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " · 제안서 작성 시 AI에게 입력할 발죌처(도로공사, 국토ꎀ늬청, 지자첎)별 Ʞ쎈 프롬프튞*", + "location": "body" + }, + "PARA_6": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " ※ 프롬프튞 구성 : 역학곌 목표, 입력 정의, 산출묌 요구사항, 작업절찚 닚계, 출력 형식 등", + "location": "body" + }, + "PARA_7_RUN_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "알귌음자 계산Ʞ, 몚니터 끄Ʞ, 자동종료 및 재시작, 계산Ʞ, pc 큎늬너 Ʞ능", + "location": "body", + "run_index": 1 + }, + "PARA_7_RUN_2": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "*", + "location": "body", + "run_index": 2 + }, + "PARA_8": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " ※ GUI Ʞ반의 앱윌로 시간에 맞추얎 자동종료, 재시작, PC 큎늰 등 수행", + "location": "body" + }, + "IMAGE_4": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image9", + "location": "body" + }, + "IMAGE_4_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " ꎀ렚 의견", + "location": "body" + }, + "PARA_9": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "※ 도로배수시섀 섀계 및 유지ꎀ늬지칚, 섀계유량(합늬식(Rational formula), 흐늄핎석(Manning 공식) 등 반영", + "location": "body" + }, + "IMAGE_5": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image10", + "location": "body" + }, + "IMAGE_5_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 장점", + "location": "body" + }, + "PARA_10": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "비개발자가 AI륌 통핎 개발 및 사낎에 공유 (겜영진 92회 포핚 쎝 712회 사용 등)", + "location": "body" + }, + "PARA_11": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "(제안서 프롬프튞) 질묞-수집-생성 파읎프띌읞까지 첎계적윌로 구축", + "location": "body" + }, + "IMAGE_6": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image10", + "location": "body" + }, + "IMAGE_6_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 확읞 필요 지점 ", + "location": "body" + }, + "PARA_12": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "지칚 개정 시, 계산 로직 또는 Ʞ쀀값 등을 사람읎 확읞, 반영하는 것?", + "location": "body" + }, + "PARA_13": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " → 개정 반영 표쀀화 또는 파읎프띌읞 등을 통하여 욎영 첎계륌 구축하는 것에 대한 ê³ ë €", + "location": "body" + }, + "IMAGE_7": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image10", + "location": "body" + }, + "IMAGE_7_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 개선 방향 제안", + "location": "body" + }, + "PARA_14": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "(제안서 프롬프튞) ① 상용 AI 몚덞의 업데읎튞 상황에 따륞 품질 변동, ② 특정 몚덞에 최적화, ③ 닚음 프롬프튞에 몚든 닚계륌 포핚하여 쀑간 결곌묌의 유싀될 가능성(닚계륌 ", + "location": "body" + }, + "PARA_15": { + "type": "slogan", + "pattern": "회사 슬로걎/비전", + "example": "(수늬 계산 Ʞ쀀 표출) Ʞ쀀곌 버전 사항듀도 핚께 계산Ʞ 낎에서 표출될 필요", + "location": "body" + }, + "PARA_16": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": " (예) 수늬계산(Box/Pipe) : 도로배수시섀 섀계 및 유지ꎀ늬지칚(2025) 반영 ", + "location": "body" + }, + "PARA_17": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "(계산 결곌 출력) Ʞ쀀, 입력 변수, 산식, 출력 결곌값 등읎 바로 활용될 수 있도록 한Ꞁ(HWP), 엑셀읎나 특정 템플늿 등윌로 출력을 ê³ ë €", + "location": "body" + }, + "PARA_18": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "(향후 로드맵) AI êž°ë°˜ 섀계 검토와 BIM 연동 등은 지ꞈ Ʞ술 대비 난읎도가 크게 상승(AI API 적용, 파읎프띌읞 구축 등), 닚계별 검슝곌 구첎적 마음슀톀 수늜읎 필요", + "location": "body" + }, + "TABLE_1_BODY": { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML 반복)", + "example": "", + "location": "table_1" + } + }, + "table_guide": { + "1": { + "col_headers": [], + "col_count": 3, + "row_count": 3, + "merge_pattern": {}, + "bullet_chars": [], + "example_rows": [ + [ + "", + "", + "" + ], + [ + "", + "지칚 Ʞ반의 정형 계산 * 곌 행정 볎조륌 GUI 앱윌로 통합핎 반복 업묎륌 자동화한 싀묎 도구", + "" + ], + [ + "", + "", + "" + ] + ], + "col_types": [], + "row_bf_pattern": [ + { + "col": 0, + "bf_class": "bf-4", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 1, + "bf_class": "bf-5", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 2, + "bf_class": "bf-6", + "colSpan": 1, + "rowSpan": 1 + } + ] + } + }, + "writing_guide": { + "bullet_styles": [ + "- " + ], + "numbering_patterns": [ + [ + "^1.", + "^2.", + "^3)" + ] + ], + "avg_line_length": 57, + "font_primary": "돋움", + "font_size_body": "10.0pt" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/meta.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/meta.json new file mode 100644 index 0000000..7a480b9 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/meta.json @@ -0,0 +1,15 @@ +{ + "id": "tpl_1770300969", + "name": "3 양식", + "original_file": "sample.hwpx", + "file_type": ".hwpx", + "description": "3에서 추출한 묞서 양식", + "features": [ + "폰튾: 돋움", + "뚞늿말: 3ì—Ž", + "ꌬ늿말: 3ì—Ž", + "표: 5x4" + ], + "created_at": "2026-02-05T23:16:09Z", + "source": "doc_template_analyzer" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/semantic_map.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/semantic_map.json new file mode 100644 index 0000000..ec0ff4d --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/semantic_map.json @@ -0,0 +1,222 @@ +{ + "version": "1.0", + "table_roles": { + "0": { + "role": "footer_table", + "match_source": "footer", + "matched_texts": [ + "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + "1": { + "role": "header_table", + "match_source": "header", + "matched_texts": [ + "2025. 2. 5(목)", + "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀" + ] + }, + "2": { + "role": "title_block", + "title_text": "AI 업묎 활용 적용 사례 발표 계획(안)" + }, + "3": { + "role": "data_table", + "header_row": 0, + "col_headers": [ + "구분", + "낎용", + "비고" + ], + "row_count": 5, + "col_count": 4 + } + }, + "body_tables": [ + 3 + ], + "title_table": 2, + "sections": [], + "style_mappings": { + "char_pr": {}, + "border_fill": { + "1": { + "css_class": "bf-1", + "bg": "", + "borders": {} + }, + "2": { + "css_class": "bf-2", + "bg": "", + "borders": {} + }, + "3": { + "css_class": "bf-3", + "bg": "", + "borders": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "css_class": "bf-4", + "bg": "", + "borders": { + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "css_class": "bf-5", + "bg": "", + "borders": {} + }, + "6": { + "css_class": "bf-6", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "css_class": "bf-7", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "css_class": "bf-8", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "9": { + "css_class": "bf-9", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "10": { + "css_class": "bf-10", + "bg": "#DCDCDC", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "11": { + "css_class": "bf-11", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "12": { + "css_class": "bf-12", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "13": { + "css_class": "bf-13", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "14": { + "css_class": "bf-14", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "15": { + "css_class": "bf-15", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "16": { + "css_class": "bf-16", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "17": { + "css_class": "bf-17", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "css_class": "bf-18", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "css_class": "bf-19", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "css_class": "bf-20", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "para_pr": {} + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/style.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/style.json new file mode 100644 index 0000000..5928bae --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/style.json @@ -0,0 +1,4688 @@ +{ + "version": "v4", + "source": "doc_template_analyzer", + "template_info": { + "page": { + "paper": { + "name": "A4", + "width_mm": 210.0, + "height_mm": 297.0, + "landscape": true + }, + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + } + }, + "fonts": { + "HANGUL": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양견명조", + "type": "HFT" + }, + { + "id": 6, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 7, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 8, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "LATIN": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "한양견명조", + "type": "HFT" + } + ], + "HANJA": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "JAPANESE": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "OTHER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "한양신명조", + "type": "HFT" + } + ], + "SYMBOL": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕330", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "USER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "명조", + "type": "HFT" + } + ] + }, + "char_styles": [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 1, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 2, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 3, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 4, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 5, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 6, + "latin": 5, + "hanja": 5, + "japanese": 5, + "other": 5, + "symbol": 5, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 6, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 7, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 8, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 9, + "height_pt": 8.0, + "textColor": "#0000FF", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 10, + "height_pt": 8.0, + "textColor": "#FF0000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 11, + "height_pt": 8.0, + "textColor": "#008000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 12, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 90, + "latin": 90, + "hanja": 90, + "japanese": 90, + "other": 90, + "symbol": 90, + "user": 90 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 13, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 14, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 15, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 16, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 17, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 18, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 8, + "latin": 7, + "hanja": 7, + "japanese": 7, + "other": 6, + "symbol": 7, + "user": 6 + }, + "ratio": { + "hangul": 98, + "latin": 98, + "hanja": 98, + "japanese": 98, + "other": 98, + "symbol": 98, + "user": 98 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 19, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 20, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 21, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 22, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 23, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -10, + "latin": -10, + "hanja": -10, + "japanese": -10, + "other": -10, + "symbol": -10, + "user": -10 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 24, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -17, + "latin": -17, + "hanja": -17, + "japanese": -17, + "other": -17, + "symbol": -17, + "user": -17 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 25, + "height_pt": 16.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 5, + "latin": 8, + "hanja": 8, + "japanese": 8, + "other": 7, + "symbol": 8, + "user": 7 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 26, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 0, + "latin": 0, + "hanja": 1, + "japanese": 1, + "other": 1, + "symbol": 1, + "user": 1 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + } + ], + "para_styles": [ + { + "id": 0, + "tabPrIDRef": 1, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 130 + }, + "borderFillIDRef": 2 + }, + { + "id": 1, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 2, + "tabPrIDRef": 2, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 150 + }, + "borderFillIDRef": 2 + }, + { + "id": 3, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 4, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 5, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 6, + "tabPrIDRef": 0, + "align": "RIGHT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 7, + "tabPrIDRef": 4, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 100 + }, + "borderFillIDRef": 2 + }, + { + "id": 8, + "tabPrIDRef": 0, + "align": "DISTRIBUTE", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 500, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 9, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 10, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 11, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 12, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 13, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 200, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 14, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 600, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 155 + }, + "borderFillIDRef": 1 + }, + { + "id": 15, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1200, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 16, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 17, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1396, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 18, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 19, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1000, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 20, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 1 + }, + { + "id": 21, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 852 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 1 + }, + { + "id": 22, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + } + ], + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [ + { + "index": 0, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "charPrIDRefs": [ + 9, + 8, + 10, + 8, + 11, + 8, + 8 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 12, + 8 + ], + "primaryCharPrIDRef": 12, + "paraPrIDRefs": [ + 6, + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0, + 0 + ] + } + ] + ], + "colWidths_hu": [ + 16723, + 2856, + 28043 + ], + "colWidths_pct": [ + 35, + 6, + 59 + ] + }, + { + "index": 1, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "charPrIDRefs": [ + 7, + 8 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "2025. 2. 5(목)", + "charPrIDRefs": [ + 7 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0 + ], + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ], + "colWidths_hu": [ + 11912, + 7950, + 27760 + ], + "colWidths_pct": [ + 25, + 17, + 58 + ] + }, + { + "index": 2, + "rowCnt": 1, + "colCnt": 2, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 773, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47185, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "text": "AI 업묎 활용 적용 사례 발표 계획(안)", + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ], + "lines": [ + "AI 업묎 활용 적용 사례 발표 계획(안)" + ] + } + ] + ], + "colWidths_hu": [ + 773, + 47185 + ], + "colWidths_pct": [ + 2, + 98 + ] + }, + { + "index": 3, + "rowCnt": 5, + "colCnt": 4, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 10, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 2, + "rowSpan": 1, + "width_hu": 14354, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "구분", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "구분" + ] + }, + { + "borderFillIDRef": 13, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "낎용", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "낎용" + ] + }, + { + "borderFillIDRef": 14, + "isHeader": false, + "colAddr": 3, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 5392, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "비고", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "비고" + ] + } + ], + [ + { + "borderFillIDRef": 12, + "isHeader": false, + "colAddr": 0, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "소개" + ] + }, + { + "borderFillIDRef": 8, + "isHeader": false, + "colAddr": 1, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "개요", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "개요" + ] + }, + { + "borderFillIDRef": 7, + "isHeader": false, + "colAddr": 2, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입 · 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등) · 발죌처별 상읎한 양식곌 확장자로 재펞집 - Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감", + "charPrIDRefs": [ + 24, + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0, + 0 + ], + "lines": [ + "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입", + "· 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등)", + "· 발죌처별 상읎한 양식곌 확장자로 재펞집", + "- Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감" + ] + }, + { + "borderFillIDRef": 19, + "isHeader": false, + "colAddr": 3, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "1p", + "charPrIDRefs": [ + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "1p" + ] + } + ], + [ + { + "borderFillIDRef": 16, + "isHeader": false, + "colAddr": 1, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 소개" + ] + }, + { + "borderFillIDRef": 18, + "isHeader": false, + "colAddr": 2, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- Ꞁ벗 Ʞ능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ · (Export) 읞쇄, PDF, HWP", + "charPrIDRefs": [ + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0 + ], + "lines": [ + "- Ꞁ벗 Ʞ능 소개", + "· (Input) 로컬, 링크, HTML 구조", + "· (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ", + "· (Export) 읞쇄, PDF, HWP" + ] + } + ], + [ + { + "borderFillIDRef": 11, + "isHeader": false, + "colAddr": 0, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "시연" + ] + }, + { + "borderFillIDRef": 15, + "isHeader": false, + "colAddr": 1, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 시연" + ] + }, + { + "borderFillIDRef": 17, + "isHeader": false, + "colAddr": 2, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성 - (Ʞ능 2) (Process) 웹 펞집Ʞ - (Ʞ능 3) (Export) PDF와 HWP 추출", + "charPrIDRefs": [ + 24, + 23, + 24, + 23, + 24, + 23 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0 + ], + "lines": [ + "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성", + "- (Ʞ능 2) (Process) 웹 펞집Ʞ", + "- (Ʞ능 3) (Export) PDF와 HWP 추출" + ] + }, + { + "borderFillIDRef": 20, + "isHeader": false, + "colAddr": 3, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 & Visual Studio", + "charPrIDRefs": [ + 23, + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 &", + "Visual Studio" + ] + } + ], + [ + { + "borderFillIDRef": 9, + "isHeader": false, + "colAddr": 1, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 만드는 곌정", + "charPrIDRefs": [ + 20, + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 만드는", + "곌정" + ] + }, + { + "borderFillIDRef": 6, + "isHeader": false, + "colAddr": 2, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "AI에게 활용할 자료 제공하Ʞ AI륌 활용하여 윔딩하Ʞ", + "charPrIDRefs": [ + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 16, + 16 + ], + "primaryParaPrIDRef": 16, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "AI에게 활용할 자료 제공하Ʞ", + "AI륌 활용하여 윔딩하Ʞ" + ] + } + ] + ], + "colWidths_hu": [ + 5054, + 9300, + 27183, + 5392 + ], + "colWidths_pct": [ + 11, + 20, + 58, + 11 + ] + } + ], + "header": { + "exists": true, + "hidden": false, + "texts": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀", + "2025. 2. 5(목)" + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "text": "2025. 2. 5(목)", + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ] + } + }, + "footer": { + "exists": true, + "hidden": false, + "texts": [ + "Ʞ술", + "로", + "사람", + "곌", + "자연", + "읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043 + } + ] + ] + } + }, + "section": { + "textDirection": "HORIZONTAL", + "hideFirstHeader": false, + "hideFirstFooter": false, + "hideFirstMasterPage": false, + "hideFirstPageNum": false, + "hideFirstEmptyLine": false, + "startNum": { + "pageStartsOn": "BOTH", + "page": 0 + } + }, + "styles": [ + { + "id": 0, + "paraPrIDRef": 3, + "charPrIDRef": 0, + "nextStyleIDRef": 0, + "type": "PARA", + "name": "바탕Ꞁ", + "engName": "Normal" + }, + { + "id": 1, + "paraPrIDRef": 2, + "charPrIDRef": 3, + "nextStyleIDRef": 1, + "type": "PARA", + "name": "뚞늬말", + "engName": "Header" + }, + { + "id": 2, + "paraPrIDRef": 1, + "charPrIDRef": 2, + "nextStyleIDRef": 2, + "type": "PARA", + "name": "쪜 번혞", + "engName": "Page Number" + }, + { + "id": 3, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 3, + "type": "PARA", + "name": "각죌", + "engName": "Footnote" + }, + { + "id": 4, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 4, + "type": "PARA", + "name": "믞죌", + "engName": "Endnote" + }, + { + "id": 5, + "paraPrIDRef": 4, + "charPrIDRef": 6, + "nextStyleIDRef": 5, + "type": "PARA", + "name": "표위", + "engName": "Memo" + }, + { + "id": 6, + "paraPrIDRef": 8, + "charPrIDRef": 0, + "nextStyleIDRef": 6, + "type": "PARA", + "name": "표옆", + "engName": "" + }, + { + "id": 7, + "paraPrIDRef": 10, + "charPrIDRef": 0, + "nextStyleIDRef": 7, + "type": "PARA", + "name": "표낎용", + "engName": "" + }, + { + "id": 8, + "paraPrIDRef": 9, + "charPrIDRef": 13, + "nextStyleIDRef": 8, + "type": "PARA", + "name": "죌)", + "engName": "" + }, + { + "id": 9, + "paraPrIDRef": 14, + "charPrIDRef": 18, + "nextStyleIDRef": 9, + "type": "PARA", + "name": "#큰아읎윘", + "engName": "" + }, + { + "id": 10, + "paraPrIDRef": 21, + "charPrIDRef": 25, + "nextStyleIDRef": 10, + "type": "PARA", + "name": "개요1", + "engName": "" + }, + { + "id": 11, + "paraPrIDRef": 20, + "charPrIDRef": 26, + "nextStyleIDRef": 11, + "type": "PARA", + "name": "xl63", + "engName": "xl63" + } + ], + "numbering": { + "numberings": [ + { + "id": 1, + "start": 0, + "levels": [ + { + "level": 1, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^1." + }, + { + "level": 2, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^2." + }, + { + "level": 3, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^3)" + }, + { + "level": 4, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^4)" + }, + { + "level": 5, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "(^5)" + }, + { + "level": 6, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "(^6)" + }, + { + "level": 7, + "numFormat": "CIRCLED_DIGIT", + "align": "LEFT", + "pattern": "^7" + } + ] + } + ], + "bullets": [ + { + "id": 1, + "char": "-", + "useImage": false + } + ] + }, + "images": [ + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + } + ], + "content_order": [ + { + "index": 0, + "paraPrIDRef": "7", + "styleIDRef": "0", + "type": "table", + "table_idx": 0, + "rowCnt": "1", + "colCnt": "2", + "borderFillIDRef": "3" + }, + { + "index": 1, + "paraPrIDRef": "17", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 2, + "paraPrIDRef": "15", + "styleIDRef": "9", + "type": "image", + "image_idx": 0, + "binaryItemIDRef": "image1", + "text": " 개요", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 3, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 1, + "binaryItemIDRef": "image2", + "text": " AI륌 활용한 “업묎 횚윚성 개선 사례”와 읎륌 구현한 방식에 대한 공유", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 4, + "paraPrIDRef": "18", + "styleIDRef": "7", + "type": "paragraph", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연", + "charPrIDRef": "22", + "runs": [ + { + "charPrIDRef": "22", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연" + } + ] + }, + { + "index": 5, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 6, + "paraPrIDRef": "19", + "styleIDRef": "9", + "type": "image", + "image_idx": 2, + "binaryItemIDRef": "image1", + "text": " 발표 구성(안)", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 7, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 3, + "binaryItemIDRef": "image2", + "text": " 제목 : AI 활용 묞서 업묎 개선 사례 -「Ꞁ벗」(사용자의 Ꞁ쓰Ʞ륌 돕는 친구) -", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 8, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 4, + "binaryItemIDRef": "image2", + "text": " 발표 낎용 ", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 9, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "table", + "table_idx": 1, + "rowCnt": "5", + "colCnt": "4", + "borderFillIDRef": "3" + }, + { + "index": 10, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 11, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 12, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + } + ] + }, + "css": "", + "fonts": {}, + "colors": { + "background": [ + "#EDEDED", + "#DCDCDC" + ], + "border": [ + "#000000", + "#3057B9", + "#999999", + "#BBBBBB" + ], + "text": [] + }, + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [], + "style_summary": {} +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/template.html b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/template.html new file mode 100644 index 0000000..d7ef470 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770300969/template.html @@ -0,0 +1,590 @@ + + + + +Template + + + +
                                  + +
                                  +
                                  ++ + + + + + + + + +
                                  {{HEADER_R1_C1_LINE_1}}
                                  {{HEADER_R1_C1_LINE_2}}
                                  {{HEADER_R1_C3}}
                                  + + +
                                  + ++ + + + + + + +
                                  {{TITLE_R1_C2}}
                                  +
                                  + + +
                                  + {{IMAGE_1}} +

                                  {{IMAGE_1_CAPTION}}

                                  +
                                  + +
                                  + {{IMAGE_2}} +

                                  {{IMAGE_2_CAPTION}}

                                  +
                                  + +

                                  {{PARA_1}}

                                  + +
                                  + {{IMAGE_3}} +

                                  {{IMAGE_3_CAPTION}}

                                  +
                                  + +
                                  + {{IMAGE_4}} +

                                  {{IMAGE_4_CAPTION}}

                                  +
                                  + +
                                  + {{IMAGE_5}} +

                                  {{IMAGE_5_CAPTION}}

                                  +
                                  + + ++ + + + + + + + + + + + + + {{TABLE_1_BODY}} + +
                                  {{TABLE_1_H_C1}}{{TABLE_1_H_C2}}{{TABLE_1_H_C3}}
                                  + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/meta.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/meta.json new file mode 100644 index 0000000..122d962 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/meta.json @@ -0,0 +1,13 @@ +{ + "id": "tpl_1770301063", + "name": "55 양식", + "original_file": "발표자료복사볞.hwpx", + "file_type": ".hwpx", + "description": "55에서 추출한 묞서 양식", + "features": [ + "폰튾: 돋움", + "표: 3x3" + ], + "created_at": "2026-02-05T23:17:43Z", + "source": "doc_template_analyzer" +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/semantic_map.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/semantic_map.json new file mode 100644 index 0000000..b14ba03 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/semantic_map.json @@ -0,0 +1,94 @@ +{ + "version": "1.0", + "table_roles": { + "0": { + "role": "data_table", + "header_row": null, + "col_headers": [], + "row_count": 3, + "col_count": 3 + } + }, + "body_tables": [ + 0 + ], + "title_table": null, + "sections": [ + { + "index": 1, + "title": "1. (슀마튞섀계팀) SamanPro(V3.0)", + "pattern_type": "numbered" + } + ], + "style_mappings": { + "char_pr": {}, + "border_fill": { + "1": { + "css_class": "bf-1", + "bg": "", + "borders": {} + }, + "2": { + "css_class": "bf-2", + "bg": "", + "borders": {} + }, + "3": { + "css_class": "bf-3", + "bg": "", + "borders": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "css_class": "bf-4", + "bg": "", + "borders": {} + }, + "5": { + "css_class": "bf-5", + "bg": "", + "borders": {} + }, + "6": { + "css_class": "bf-6", + "bg": "", + "borders": {} + }, + "7": { + "css_class": "bf-7", + "bg": "", + "borders": {} + }, + "8": { + "css_class": "bf-8", + "bg": "#F3F3F3", + "borders": {} + }, + "9": { + "css_class": "bf-9", + "bg": "", + "borders": {} + }, + "10": { + "css_class": "bf-10", + "bg": "", + "borders": {} + }, + "11": { + "css_class": "bf-11", + "bg": "", + "borders": {} + }, + "12": { + "css_class": "bf-12", + "bg": "", + "borders": {} + } + }, + "para_pr": {} + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/style.json b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/style.json new file mode 100644 index 0000000..7535138 --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/style.json @@ -0,0 +1,3355 @@ +{ + "version": "v4", + "source": "doc_template_analyzer", + "template_info": { + "page": { + "paper": { + "name": "A4", + "width_mm": 210.0, + "height_mm": 297.0, + "landscape": true + }, + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + } + }, + "fonts": { + "HANGUL": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "한양견명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "LATIN": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "한양견명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "HANJA": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "신명 견명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "JAPANESE": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "신명 견명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "OTHER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "한양신명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "SYMBOL": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "신명 견명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕330", + "type": "TTF" + } + ], + "USER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 3, + "face": "명조", + "type": "HFT" + }, + { + "id": 4, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 5, + "face": "-윀고딕130", + "type": "TTF" + } + ] + }, + "char_styles": [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 1, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 2, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 3, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 4, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 5, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 6, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 5, + "latin": 5, + "hanja": 5, + "japanese": 5, + "other": 5, + "symbol": 5, + "user": 5 + }, + "ratio": { + "hangul": 98, + "latin": 98, + "hanja": 98, + "japanese": 98, + "other": 98, + "symbol": 98, + "user": 98 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 7, + "height_pt": 16.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 8, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 0, + "latin": 0, + "hanja": 1, + "japanese": 1, + "other": 1, + "symbol": 1, + "user": 1 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 9, + "height_pt": 1.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 10, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 11, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 12, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 13, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 14, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 15, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 16, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 17, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 18, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -4, + "latin": -4, + "hanja": -4, + "japanese": -4, + "other": -4, + "symbol": -4, + "user": -4 + }, + "underline": "NONE", + "strikeout": "NONE" + } + ], + "para_styles": [ + { + "id": 0, + "tabPrIDRef": 1, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 130 + }, + "borderFillIDRef": 2 + }, + { + "id": 1, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 2, + "tabPrIDRef": 2, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 150 + }, + "borderFillIDRef": 2 + }, + { + "id": 3, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 4, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 5, + "tabPrIDRef": 0, + "align": "DISTRIBUTE", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 500, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 6, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 7, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 8, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 9, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 600, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 155 + }, + "borderFillIDRef": 1 + }, + { + "id": 10, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1396, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 11, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 12, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1000, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 13, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 1 + }, + { + "id": 14, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 852 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 1 + }, + { + "id": 15, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 16, + "tabPrIDRef": 3, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 17, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 500, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 18, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 19, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1724, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 20, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -2247, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 21, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 22, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + } + ], + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "7": { + "id": 7, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "8": { + "id": 8, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "background": "#F3F3F3", + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none", + "background-color": "#F3F3F3" + } + }, + "9": { + "id": 9, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + } + }, + "tables": [ + { + "index": 0, + "rowCnt": 3, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47063, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 6, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + } + ], + [ + { + "borderFillIDRef": 7, + "isHeader": false, + "colAddr": 0, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 2315, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 8, + "isHeader": false, + "colAddr": 1, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47063, + "height_hu": 2315, + "cellMargin": { + "left": 0, + "right": 0, + "top": 425, + "bottom": 425 + }, + "text": "지칚 Ʞ반의 정형 계산 * 곌 행정 볎조륌 GUI 앱윌로 통합핎 반복 업묎륌 자동화한 싀묎 도구", + "charPrIDRefs": [ + 10, + 11, + 10 + ], + "primaryCharPrIDRef": 10, + "paraPrIDRefs": [ + 16 + ], + "primaryParaPrIDRef": 16, + "styleIDRefs": [ + 7 + ], + "lines": [ + "지칚 Ʞ반의 정형 계산 * 곌 행정 볎조륌 GUI 앱윌로 통합핎 반복 업묎륌 자동화한 싀묎 도구" + ] + }, + { + "borderFillIDRef": 9, + "isHeader": false, + "colAddr": 2, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 2315, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + } + ], + [ + { + "borderFillIDRef": 10, + "isHeader": false, + "colAddr": 0, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 11, + "isHeader": false, + "colAddr": 1, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47063, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 12, + "isHeader": false, + "colAddr": 2, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 283, + "height_hu": 284, + "cellMargin": { + "left": 0, + "right": 0, + "top": 184, + "bottom": 0 + }, + "charPrIDRefs": [ + 9, + 9 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ] + } + ] + ], + "colWidths_hu": [ + 283, + 47063, + 283 + ], + "colWidths_pct": [ + 1, + 99, + 1 + ] + } + ], + "section": { + "textDirection": "HORIZONTAL", + "hideFirstHeader": false, + "hideFirstFooter": false, + "hideFirstMasterPage": false, + "hideFirstPageNum": false, + "hideFirstEmptyLine": false, + "startNum": { + "pageStartsOn": "BOTH", + "page": 0 + } + }, + "styles": [ + { + "id": 0, + "paraPrIDRef": 3, + "charPrIDRef": 0, + "nextStyleIDRef": 0, + "type": "PARA", + "name": "바탕Ꞁ", + "engName": "Normal" + }, + { + "id": 1, + "paraPrIDRef": 2, + "charPrIDRef": 3, + "nextStyleIDRef": 1, + "type": "PARA", + "name": "뚞늬말", + "engName": "Header" + }, + { + "id": 2, + "paraPrIDRef": 1, + "charPrIDRef": 2, + "nextStyleIDRef": 2, + "type": "PARA", + "name": "쪜 번혞", + "engName": "Page Number" + }, + { + "id": 3, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 3, + "type": "PARA", + "name": "각죌", + "engName": "Footnote" + }, + { + "id": 4, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 4, + "type": "PARA", + "name": "믞죌", + "engName": "Endnote" + }, + { + "id": 5, + "paraPrIDRef": 4, + "charPrIDRef": 4, + "nextStyleIDRef": 5, + "type": "PARA", + "name": "표위", + "engName": "Memo" + }, + { + "id": 6, + "paraPrIDRef": 5, + "charPrIDRef": 0, + "nextStyleIDRef": 6, + "type": "PARA", + "name": "표옆", + "engName": "" + }, + { + "id": 7, + "paraPrIDRef": 7, + "charPrIDRef": 0, + "nextStyleIDRef": 7, + "type": "PARA", + "name": "표낎용", + "engName": "" + }, + { + "id": 8, + "paraPrIDRef": 6, + "charPrIDRef": 5, + "nextStyleIDRef": 8, + "type": "PARA", + "name": "죌)", + "engName": "" + }, + { + "id": 9, + "paraPrIDRef": 9, + "charPrIDRef": 6, + "nextStyleIDRef": 9, + "type": "PARA", + "name": "#큰아읎윘", + "engName": "" + }, + { + "id": 10, + "paraPrIDRef": 14, + "charPrIDRef": 7, + "nextStyleIDRef": 10, + "type": "PARA", + "name": "개요1", + "engName": "" + }, + { + "id": 11, + "paraPrIDRef": 13, + "charPrIDRef": 8, + "nextStyleIDRef": 11, + "type": "PARA", + "name": "xl63", + "engName": "xl63" + } + ], + "numbering": { + "numberings": [ + { + "id": 1, + "start": 0, + "levels": [ + { + "level": 1, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^1." + }, + { + "level": 2, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^2." + }, + { + "level": 3, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^3)" + }, + { + "level": 4, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^4)" + }, + { + "level": 5, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "(^5)" + }, + { + "level": 6, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "(^6)" + }, + { + "level": 7, + "numFormat": "CIRCLED_DIGIT", + "align": "LEFT", + "pattern": "^7" + } + ] + } + ], + "bullets": [ + { + "id": 1, + "char": "-", + "useImage": false + } + ] + }, + "images": [ + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + } + ], + "content_order": [ + { + "index": 0, + "paraPrIDRef": "22", + "styleIDRef": "0", + "type": "paragraph", + "text": "1. (슀마튞섀계팀) SamanPro(V3.0)", + "charPrIDRef": "12", + "runs": [ + { + "charPrIDRef": "12", + "text": "1. (슀마튞섀계팀) SamanPro(V3.0)" + } + ] + }, + { + "index": 1, + "paraPrIDRef": "12", + "styleIDRef": "9", + "type": "image", + "image_idx": 0, + "binaryItemIDRef": "image9", + "text": " 낎용 요앜", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 2, + "paraPrIDRef": "8", + "styleIDRef": "7", + "type": "image", + "image_idx": 1, + "binaryItemIDRef": "image10", + "text": " 반복적 도로 섀계 계산 작업곌 행정의 자동화륌 위한 통합 섀계 플랫폌 구축", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 3, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "AI는 앱 개발 곌정에서 윔드 생성곌 에러 핎결을 위한 방식윌로 활용", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "AI는 앱 개발 곌정에서 윔드 생성곌 에러 핎결을 위한 방식윌로 활용" + } + ] + }, + { + "index": 4, + "paraPrIDRef": "8", + "styleIDRef": "7", + "type": "image", + "image_idx": 2, + "binaryItemIDRef": "image10", + "text": " 죌요 Ʞ능", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 5, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "계산, 왞부 데읎터 확읞(API) 및 제안서 작성 시 활용할 수 있는 프롬프튞 등", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "계산, 왞부 데읎터 확읞(API) 및 제안서 작성 시 활용할 수 있는 프롬프튞 등" + } + ] + }, + { + "index": 6, + "paraPrIDRef": "19", + "styleIDRef": "7", + "type": "paragraph", + "text": " · 포장섀계, 동결심도, 수늬계산, 확폭계산, 펞겜사계산 등 섀계 ꎀ렚 계산 Ʞ능곌 착수 음자와 곌업Ʞ간 입력 시, 쀑공음자 표출되는 쀀공계 볎조 계산 Ʞ능 등", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": " · 포장섀계, 동결심도, 수늬계산, 확폭계산, 펞겜사계산 등 섀계 ꎀ렚 계산 Ʞ능곌 착수 음자와 곌업Ʞ간 입력 시, 쀑공음자 표출되는 쀀공계 볎조 계산 Ʞ능 등" + } + ] + }, + { + "index": 7, + "paraPrIDRef": "20", + "styleIDRef": "7", + "type": "paragraph", + "text": " · 한국은행 API륌 활용하여 (연도별/분Ʞ별) 걎섀투자 GDP 제공", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": " · 한국은행 API륌 활용하여 (연도별/분Ʞ별) 걎섀투자 GDP 제공" + } + ] + }, + { + "index": 8, + "paraPrIDRef": "20", + "styleIDRef": "7", + "type": "paragraph", + "text": " · 제안서 작성 시 AI에게 입력할 발죌처(도로공사, 국토ꎀ늬청, 지자첎)별 Ʞ쎈 프롬프튞*", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": " · 제안서 작성 시 AI에게 입력할 발죌처(도로공사, 국토ꎀ늬청, 지자첎)별 Ʞ쎈 프롬프튞*" + } + ] + }, + { + "index": 9, + "paraPrIDRef": "10", + "styleIDRef": "0", + "type": "paragraph", + "text": " ※ 프롬프튞 구성 : 역학곌 목표, 입력 정의, 산출묌 요구사항, 작업절찚 닚계, 출력 형식 등", + "charPrIDRef": "15", + "runs": [ + { + "charPrIDRef": "15", + "text": " ※ 프롬프튞 구성 : 역학곌 목표, 입력 정의, 산출묌 요구사항, 작업절찚 닚계, 출력 형식 등" + } + ] + }, + { + "index": 10, + "paraPrIDRef": "11", + "styleIDRef": "7", + "type": "paragraph", + "text": "알귌음자 계산Ʞ, 몚니터 끄Ʞ, 자동종료 및 재시작, 계산Ʞ, pc 큎늬너 Ʞ능*", + "charPrIDRef": "16", + "runs": [ + { + "charPrIDRef": "16", + "text": "알귌음자 계산Ʞ, 몚니터 끄Ʞ, 자동종료 및 재시작, 계산Ʞ, pc 큎늬너 Ʞ능" + }, + { + "charPrIDRef": "17", + "text": "*" + } + ] + }, + { + "index": 11, + "paraPrIDRef": "10", + "styleIDRef": "0", + "type": "paragraph", + "text": " ※ GUI Ʞ반의 앱윌로 시간에 맞추얎 자동종료, 재시작, PC 큎늰 등 수행", + "charPrIDRef": "15", + "runs": [ + { + "charPrIDRef": "15", + "text": " ※ GUI Ʞ반의 앱윌로 시간에 맞추얎 자동종료, 재시작, PC 큎늰 등 수행" + } + ] + }, + { + "index": 12, + "paraPrIDRef": "12", + "styleIDRef": "9", + "type": "image", + "image_idx": 3, + "binaryItemIDRef": "image9", + "text": " ꎀ렚 의견", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 13, + "paraPrIDRef": "21", + "styleIDRef": "9", + "type": "table", + "table_idx": 0, + "rowCnt": "3", + "colCnt": "3", + "borderFillIDRef": "3" + }, + { + "index": 14, + "paraPrIDRef": "10", + "styleIDRef": "0", + "type": "paragraph", + "text": "※ 도로배수시섀 섀계 및 유지ꎀ늬지칚, 섀계유량(합늬식(Rational formula), 흐늄핎석(Manning 공식) 등 반영", + "charPrIDRef": "15", + "runs": [ + { + "charPrIDRef": "15", + "text": "※ 도로배수시섀 섀계 및 유지ꎀ늬지칚, 섀계유량(합늬식(Rational formula), 흐늄핎석(Manning 공식) 등 반영" + } + ] + }, + { + "index": 15, + "paraPrIDRef": "17", + "styleIDRef": "7", + "type": "image", + "image_idx": 4, + "binaryItemIDRef": "image10", + "text": " 장점", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 16, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "비개발자가 AI륌 통핎 개발 및 사낎에 공유 (겜영진 92회 포핚 쎝 712회 사용 등)", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "비개발자가 AI륌 통핎 개발 및 사낎에 공유 (겜영진 92회 포핚 쎝 712회 사용 등)" + } + ] + }, + { + "index": 17, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "(제안서 프롬프튞) 질묞-수집-생성 파읎프띌읞까지 첎계적윌로 구축", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "(제안서 프롬프튞) 질묞-수집-생성 파읎프띌읞까지 첎계적윌로 구축" + } + ] + }, + { + "index": 18, + "paraPrIDRef": "17", + "styleIDRef": "7", + "type": "image", + "image_idx": 5, + "binaryItemIDRef": "image10", + "text": " 확읞 필요 지점 ", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 19, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "지칚 개정 시, 계산 로직 또는 Ʞ쀀값 등을 사람읎 확읞, 반영하는 것?", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "지칚 개정 시, 계산 로직 또는 Ʞ쀀값 등을 사람읎 확읞, 반영하는 것?" + } + ] + }, + { + "index": 20, + "paraPrIDRef": "18", + "styleIDRef": "7", + "type": "paragraph", + "text": " → 개정 반영 표쀀화 또는 파읎프띌읞 등을 통하여 욎영 첎계륌 구축하는 것에 대한 ê³ ë €", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": " → 개정 반영 표쀀화 또는 파읎프띌읞 등을 통하여 욎영 첎계륌 구축하는 것에 대한 ê³ ë €" + } + ] + }, + { + "index": 21, + "paraPrIDRef": "17", + "styleIDRef": "7", + "type": "image", + "image_idx": 6, + "binaryItemIDRef": "image10", + "text": " 개선 방향 제안", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 22, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "(제안서 프롬프튞) ① 상용 AI 몚덞의 업데읎튞 상황에 따륞 품질 변동, ② 특정 몚덞에 최적화, ③ 닚음 프롬프튞에 몚든 닚계륌 포핚하여 쀑간 결곌묌의 유싀될 가능성(닚계륌 나누거나 또는 컚텍슀튞륌 반영하는 파읎프띌읞 등 ê³ ë €), ④ 결곌묌 검슝 Ʞ쀀 추가 필요", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "(제안서 프롬프튞) ① 상용 AI 몚덞의 업데읎튞 상황에 따륞 품질 변동, ② 특정 몚덞에 최적화, ③ 닚음 프롬프튞에 몚든 닚계륌 포핚하여 쀑간 결곌묌의 유싀될 가능성(닚계륌 나누거나 또는 컚텍슀튞륌 반영하는 파읎프띌읞 등 ê³ ë €), ④ 결곌묌 검슝 Ʞ쀀 추가 필요" + } + ] + }, + { + "index": 23, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "(수늬 계산 Ʞ쀀 표출) Ʞ쀀곌 버전 사항듀도 핚께 계산Ʞ 낎에서 표출될 필요", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "(수늬 계산 Ʞ쀀 표출) Ʞ쀀곌 버전 사항듀도 핚께 계산Ʞ 낎에서 표출될 필요" + } + ] + }, + { + "index": 24, + "paraPrIDRef": "10", + "styleIDRef": "0", + "type": "paragraph", + "text": " (예) 수늬계산(Box/Pipe) : 도로배수시섀 섀계 및 유지ꎀ늬지칚(2025) 반영 ", + "charPrIDRef": "15", + "runs": [ + { + "charPrIDRef": "15", + "text": " (예) 수늬계산(Box/Pipe) : 도로배수시섀 섀계 및 유지ꎀ늬지칚(2025) 반영 " + } + ] + }, + { + "index": 25, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "(계산 결곌 출력) Ʞ쀀, 입력 변수, 산식, 출력 결곌값 등읎 바로 활용될 수 있도록 한Ꞁ(HWP), 엑셀읎나 특정 템플늿 등윌로 출력을 ê³ ë €", + "charPrIDRef": "13", + "runs": [ + { + "charPrIDRef": "13", + "text": "(계산 결곌 출력) Ʞ쀀, 입력 변수, 산식, 출력 결곌값 등읎 바로 활용될 수 있도록 한Ꞁ(HWP), 엑셀읎나 특정 템플늿 등윌로 출력을 ê³ ë €" + } + ] + }, + { + "index": 26, + "paraPrIDRef": "15", + "styleIDRef": "7", + "type": "paragraph", + "text": "(향후 로드맵) AI êž°ë°˜ 섀계 검토와 BIM 연동 등은 지ꞈ Ʞ술 대비 난읎도가 크게 상승(AI API 적용, 파읎프띌읞 구축 등), 닚계별 검슝곌 구첎적 마음슀톀 수늜읎 필요", + "charPrIDRef": "18", + "runs": [ + { + "charPrIDRef": "18", + "text": "(향후 로드맵) AI êž°ë°˜ 섀계 검토와 BIM 연동 등은 지ꞈ Ʞ술 대비 난읎도가 크게 상승(AI API 적용, 파읎프띌읞 구축 등), 닚계별 검슝곌 구첎적 마음슀톀 수늜읎 필요" + } + ] + } + ] + }, + "css": "", + "fonts": {}, + "colors": { + "background": [ + "#F3F3F3" + ], + "border": [ + "#000000" + ], + "text": [] + }, + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "7": { + "id": 7, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "8": { + "id": 8, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "background": "#F3F3F3", + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none", + "background-color": "#F3F3F3" + } + }, + "9": { + "id": 9, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + } + }, + "tables": [], + "style_summary": {} +} \ No newline at end of file diff --git a/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/template.html b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/template.html new file mode 100644 index 0000000..a959f7d --- /dev/null +++ b/03. Code/geulbeot_8th/templates/user/templates/tpl_1770301063/template.html @@ -0,0 +1,507 @@ + + + + +Template + + + +
                                  + + + +
                                  +

                                  {{SECTION_1_TITLE}}

                                  + +
                                  + {{IMAGE_1}} +

                                  {{IMAGE_1_CAPTION}}

                                  +
                                  + +
                                  + {{IMAGE_2}} +

                                  {{IMAGE_2_CAPTION}}

                                  +
                                  + +

                                  {{PARA_1}}

                                  + +
                                  + {{IMAGE_3}} +

                                  {{IMAGE_3_CAPTION}}

                                  +
                                  + +

                                  {{PARA_2}}

                                  + +

                                  {{PARA_3}}

                                  + +

                                  {{PARA_4}}

                                  + +

                                  {{PARA_5}}

                                  + +

                                  {{PARA_6}}

                                  + +

                                  {{PARA_7_RUN_1}}{{PARA_7_RUN_2}}

                                  + +

                                  {{PARA_8}}

                                  + +
                                  + {{IMAGE_4}} +

                                  {{IMAGE_4_CAPTION}}

                                  +
                                  + + ++ + + + + + + + + + + + + {{TABLE_1_BODY}} + +
                                  {{TABLE_1_H_C1}}{{TABLE_1_H_C2}}{{TABLE_1_H_C3}}
                                  + +

                                  {{PARA_9}}

                                  + +
                                  + {{IMAGE_5}} +

                                  {{IMAGE_5_CAPTION}}

                                  +
                                  + +

                                  {{PARA_10}}

                                  + +

                                  {{PARA_11}}

                                  + +
                                  + {{IMAGE_6}} +

                                  {{IMAGE_6_CAPTION}}

                                  +
                                  + +

                                  {{PARA_12}}

                                  + +

                                  {{PARA_13}}

                                  + +
                                  + {{IMAGE_7}} +

                                  {{IMAGE_7_CAPTION}}

                                  +
                                  + +

                                  {{PARA_14}}

                                  + +

                                  {{PARA_15}}

                                  + +

                                  {{PARA_16}}

                                  + +

                                  {{PARA_17}}

                                  + +

                                  {{PARA_18}}

                                  + +
                                  + + + + +
                                  + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/.env.sample b/03. Code/geulbeot_9th/.env.sample new file mode 100644 index 0000000..b8b7f7e --- /dev/null +++ b/03. Code/geulbeot_9th/.env.sample @@ -0,0 +1,7 @@ +# Ꞁ벗 API Keys +# 읎 파음을 .env로 복사한 ë’€ 싀제 킀값을 입력하섞요 +# cp .env.sample .env + +CLAUDE_API_KEY=여Ʞ에_킀값_입력 +GEMINI_API_KEY=여Ʞ에_킀값_입력 +GPT_API_KEY=여Ʞ에_킀값_입력 diff --git a/03. Code/geulbeot_9th/.gitignore b/03. Code/geulbeot_9th/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/03. Code/geulbeot_9th/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# API Keys - Gitea에 올늬지 않Ʞ! +api_keys.json diff --git a/03. Code/geulbeot_9th/0206용/report_2026-02-05 (10).html b/03. Code/geulbeot_9th/0206용/report_2026-02-05 (10).html new file mode 100644 index 0000000..571ed17 --- /dev/null +++ b/03. Code/geulbeot_9th/0206용/report_2026-02-05 (10).html @@ -0,0 +1,588 @@ + + +Template + + + +
                                  + +
                                  + ++ + + + + + + + + +
                                  공학Ʞ술연구원
                                  BIM연구팀
                                  2025. 2. 5(목)
                                  +
                                  + +
                                  + ++ + + + + + + +
                                  BIM êž°ë°˜ 토목엔지니얎링 연구·교육 재닚법읞 섀늜 발표 계획(안)
                                  +
                                  + + +
                                  + +

                                  재닚법읞 섀늜 개요

                                  +
                                  + +
                                  + +

                                  BIM êž°ë°˜ 토목엔지니얎링 연구·교육의 첎계적 발전곌 전묞 읞력 양성을 위한 재닚법읞 섀늜

                                  +
                                  + +

                                  국토교통부 소ꎀ BIM êž°ë°˜ 토목엔지니얎링 연구·교육 재닚법읞 섀늜의 필요성곌 타당성에 대한 발표

                                  + +
                                  + +

                                  발표 구성(안)

                                  +
                                  + +
                                  + +

                                  제목 : BIM êž°ë°˜ 토목엔지니얎링 연구·교육 재닚법읞 섀늜(안) - 국토교통부 소ꎀ 재닚법읞 섀늜의 필요성곌 타당성 검토 -

                                  +
                                  + +
                                  + +

                                  발표 낎용

                                  +
                                  + + ++ + + + + + + + + + + + + + + +
                                  구분낎용비고
                                  섀늜 배겜섀늜 목적- 현황 및 묞제점 : BIM Ʞ술 활용 확산 필요성
                                  · 토목 분알 BIM 전묞 읞력 부족
                                  · 첎계적 연구·교육 Ʞꎀ 부재
                                  1p
                                  필요성 검토- 국가 정책 연계성
                                  · 디지턞 뉎딜곌 슀마튞 걎섀
                                  · 걎섀산업 혁신 방향
                                  섀늜 방안조직 구성- 재닚법읞 구조
                                  · 읎사회, 연구부, 교육부
                                  · 산학연 협력첎계
                                  2p
                                  욎영 계획- 닚계별 추진 전략
                                  · 섀늜 절찚 및 음정
                                  · 재원 조달 방안
                                  + + + +
                                  + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/0206용/report_2026-02-06.html b/03. Code/geulbeot_9th/0206용/report_2026-02-06.html new file mode 100644 index 0000000..f9ef993 --- /dev/null +++ b/03. Code/geulbeot_9th/0206용/report_2026-02-06.html @@ -0,0 +1,250 @@ + + + BIM êž°ë°˜ 토목엔지니얎링 연구·교육 재닚법읞 섀늜(안) + + + +
                                  + + +
                                  +

                                  BIM êž°ë°˜ 토목엔지니얎링 연구·교육 재닚법읞 섀늜(안)

                                  +
                                  +
                                  + +
                                  +
                                  +
                                  BIM êž°ë°˜ 토목엔지니얎링 Ʞ술의 연구·교육·확산을 위한 독늜적 공익 죌첎로서 재닚법읞 섀늜 필요
                                  +
                                  + +
                                  +
                                  섀늜 배겜 및 타당성
                                  +
                                    +
                                  • 정책 변화: 국토·걎섀 분알 BIM 의묎화, 슀마튞걎섀 확산, 디지턞 êž°ë°˜ 공공발죌 확대
                                  • +
                                  • Ʞ술 격찚: 제도 도입곌 현장 정착 간 시간 격찚, 토목 분알 Ʞ술 축적 한계
                                  • +
                                  • 투자 제앜: Ʞ업의 쀑장Ʞ 연구·읞재 양성·표쀀화 투자 구조적 얎렀움
                                  • +
                                  • 공익성: 공공 읞프띌 사업곌 정책 연계, 표쀀화·교육의 쀑늜적 죌첎 필요
                                  • +
                                  +
                                  + +
                                  +
                                  재닚법읞 형태의 적합성
                                  +
                                    +
                                  • 쀑장Ʞ 지속성: 출연재산 Ʞ반윌로 장Ʞ간 공익 목적 수행 가능
                                  • +
                                  • 욎영 투명성: 읎사회 쀑심 의사결정윌로 공공성곌 투명성 닎볎
                                  • +
                                  • 쀑늜성: 특정 êž°ì—… 읎핎ꎀ계 배제, 산업 전반 표쀀화 êž°ì—¬
                                  • +
                                  • 전묞성: 왞부 전묞가 읎사회 찞여로 연구·교육 전묞성 확볎
                                  • +
                                  +
                                  + +
                                  +
                                  재정 êž°ë°˜ 및 욎영 방안
                                  +
                                    +
                                  • 출연 재산: 비상장죌식 앜 20억 원, 현ꞈ 앜 10억 원 쎝 30억 원 규몚
                                  • +
                                  • 수익사업: 목적사업 연계 BIM 엔지니얎 교육·Ʞ술자묞·용역
                                  • +
                                  • 회계 분늬: 목적사업곌 수익사업 회계 분늬로 투명성 확볎
                                  • +
                                  • 엄격 ꎀ늬: 읎사회 의결 통한 재산 ꎀ늬·욎용·처분 통제
                                  • +
                                  +
                                  + +
                                  +
                                  국토교통부 죌묎ꎀ청의 적정성
                                  +
                                    +
                                  • 사업 특성: 토목·걎섀 BIM Ʞ술 연구·교육·확산 - 소프튞웚얎 산업곌 구분
                                  • +
                                  • 정책 연계: 공공 읞프띌 사업 비쀑, 유지ꎀ늬 닚계 데읎터 활용
                                  • +
                                  • 공공성: 국토교통부 BIM·슀마튞걎섀 정책 현장 정착 지원
                                  • +
                                  • 역할 분늬: êž°ì—… 활동곌 명확히 구분되는 공익 연구·교육 수행
                                  • +
                                  +
                                  + +
                                  +
                                  핵심 결론
                                  +
                                  Ʞ업읎 수행하Ʞ 얎렀욎 쀑장Ʞ 연구·교육을 전닎하고, 국토교통부의 BIM·슀마튞걎섀 정책읎 현장에 정착될 수 있도록 지원하는 독늜적읎고 지속 가능한 공익 연구재닚 섀늜 타당
                                  +
                                  +
                                  + +
                                  - 1 -
                                  +
                                  + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/0206용/report_2026-02-06.hwp b/03. Code/geulbeot_9th/0206용/report_2026-02-06.hwp new file mode 100644 index 0000000..46ac707 Binary files /dev/null and b/03. Code/geulbeot_9th/0206용/report_2026-02-06.hwp differ diff --git a/03. Code/geulbeot_9th/Procfile b/03. Code/geulbeot_9th/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/03. Code/geulbeot_9th/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/03. Code/geulbeot_9th/README.md b/03. Code/geulbeot_9th/README.md new file mode 100644 index 0000000..0a1936f --- /dev/null +++ b/03. Code/geulbeot_9th/README.md @@ -0,0 +1,389 @@ +# Ꞁ벗 (Geulbeot) v9.0 + +**표 맀칭 안정화 + 읞띌읞 아읎윘 감지 + 프론튞 왞부 ì°žì¡°** + +닀양한 형식의 자료(PDF·HWP·읎믞지·Excel 등)륌 입력하멎, AI가 RAG 파읎프띌읞윌로 분석한 ë’€ +선택한 묞서 유형(Ʞ획서·볎고서·발표자료 등)에 맞는 표쀀 HTML 묞서륌 자동 생성합니닀. +생성된 묞서는 웹 펞집Ʞ에서 수정하고, HTML / PDF / HWP로 출력합니닀. + +v9에서는 v8의 묞서 유형 등록 시슀템을 안정화했습니닀. +template.html 조늜 시 표 맀칭을 순찚 컀서 방식윌로 개선하고, +HWPX 읞띌읞 아읎윘(treatAsChar)을 감지하여 텍슀튞와 합치는 처늬륌 추가했습니닀. +프론튞엔드는 CSS·JS륌 왞부 파음 찞조로 전환하Ʞ 시작했습니닀. + +--- + +## 🏗 아킀텍처 (Architecture) + +### 핵심 흐멄 + +``` +자료 입력 (파음/폮더) + │ + â–Œ +작성 방식 선택 ─── 형식만 변겜 / 낎용 재구성 / 신규 작성 + │ + â–Œ +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 + - HWPX 템플늿 ꎀ늬: 추출·저장·교첎·삭제 + - HWP 변환: 하읎람늬드 방식 + - PDF 변환: WeasyPrint êž°ë°˜ + +### 2. Frontend (순수 JavaScript) + +- **Features**: + - 웹 WYSIWYG 펞집Ʞ — 생성된 묞서 직접 수정 + - 작성 방식 선택 탭: 형식만 변겜 / 낎용 재구성 / 신규 작성 + - 묞서 유형 선택 UI: Ʞ볞 3종 + 사용자 등록 유형 동적 표시 + - 템플늿 ꎀ늬 UI: 사읎드바 목록·선택·삭제, 요소별 첎크박슀 + - HTML / PDF / HWP 닀욎로드 + - CSS·JS 왞부 파음 ì°žì¡° 전환 시작 (v9) + +### 3. 변환 엔진 (Converters) + +- **RAG 파읎프띌읞**: 9닚계 — 파음 형식 통음 → 텍슀튞·읎믞지 추출 → 도메읞 분석 → 의믞 닚위 청킹 → RAG 임베딩 → 윔퍌슀 구축 → FAISS 읞덱싱 → 윘텐잠 생성 → HTML 조늜 +- **분량 자동 판당**: 5,000자 Ʞ쀀 +- **HWP 변환 (하읎람늬드)**: HTML 분석 → pyhwpx 변환 → HWPX 슀타음 죌입 → 표 ì—Ž 너비 수정 + +### 4. HWPX 추출 도구 12종 + +`handlers/tools/` — HWPX XML에서 윔드 êž°ë°˜ 추출: page_setup, font, char_style, para_style, border_fill, table, header_footer, section, style_def, numbering, image, content_order + +### 5. 묞서 유형 분석·등록 + +HWPX 업로드 → DocTemplateAnalyzer (12종 도구 추출) → SemanticMapper (의믞 판별) → StyleGenerator (CSS 생성) → ContentAnalyzer (placeholder 분석) → DocTypeAnalyzer (AI 맥띜) → TemplateManager (template.html 조늜) → CustomDocType (묞서 생성) + +### 6. 죌요 시나늬였 (Core Scenarios) + +1. **Ʞ획서 생성**: RAG 분석 후 Claude API가 구조 추출 → 배치 → Ꞁ벗 표쀀 HTML 생성 +2. **볎고서 생성**: RAG 파읎프띌읞 → Gemini API가 닀페읎지 HTML 볎고서 생성 +3. **사용자 정의 묞서 생성**: 등록된 유형의 template.html + content_prompt.json êž°ë°˜, 사용자 입력 정늬·재구성 +4. **묞서 유형 등록**: HWPX 업로드 → 자동 분석 → config.json + template.html + semantic_map.json + style.json 저장 +5. **AI 펞집**: 웹 펞집Ʞ에서 전첎·부분 수정 +6. **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 +``` + +#### 전첎 워크플로우 (v9 시점) + +```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 + + A(["📂 자료 입력"]):::startEnd + + 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["📋 템플늿 + 시맚틱 ë§µ\nstyle.json\nsemantic_map.json\ncontent_prompt.json"]:::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 --> W + W --> W1 & W2 & W3 + W1 & W2 & W3 --> 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 +``` + +--- + +## 🔄 v8 → v9 변겜사항 + +| 영역 | v8 | v9 | +|------|------|------| +| 표 맀칭 | table_idx êž°ë°˜ (였프셋 였류 가능) | **순찚 컀서 방식** — table_idx 의졎 제거, title_table 명시 제왞 | +| 읞띌읞 아읎윘 | 읎믞지로 처늬 | **treatAsChar=1 감지** → 텍슀튞와 합쳐 paragraph로 처늬 | +| 프론튞 구조 | CSS·JS 읞띌읞 | **왞부 파음 ì°žì¡° 시작** — editor.css link + editor.js script 태귞 추가 | +| template_manager | v5.3 | v5.4 — 순찚 컀서 + exclude_indices 개선 | +| content_order | 읎믞지만 분류 | + `is_inline_icon` 필드, paragraph 합칚 ë¶„êž° | + +--- + +## 🗺 상태 및 로드맵 (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 v5.4, content_order 개선, 독늜 저장 (🔧 Ʞ볞 구현 · 현재 버전) +- **Phase 7**: UI 고도화 — 작성 방식·묞서 유형·템플늿 ꎀ늬 UI (🔧 Ʞ볞 구현) +- **Phase 8**: 백엔드 재구조화 + 배포 — 팚킀지 정늬, API í‚€ 공통화, 로깅, Docker (예정) + +--- + +## 🚀 시작하Ʞ (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-v9.git +cd geulbeot-v9 + +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 접속 +``` + +--- + +## 📂 프로젝튞 구조 + +``` +geulbeot_9th/ +├── app.py # Flask 웹 서버 — API 띌우팅 +├── api_config.py # .env 환겜변수 로더 +│ +├── domain/hwpx/ # 도메읞 지식 +│ ├── hwpx_domain_guide.md # HWPX 명섞서 (§1~§11) +│ └── hwpx_utils.py # 닚위 변환 +│ +├── handlers/ # 비슈니슀 로직 +│ ├── common.py # Claude API 혞출 +│ ├── briefing/ # Ʞ획서 처늬 +│ ├── report/ # 볎고서 처늬 +│ ├── template/ # 템플늿 Ʞ볞 ꎀ늬 +│ ├── doc_type_analyzer.py # 묞서 유형 AI 분석 +│ ├── doc_template_analyzer.py # HWPX → 12종 도구 추출 +│ ├── semantic_mapper.py # 요소 의믞 판별 +│ ├── style_generator.py # 추출값 → CSS 생성 +│ ├── content_analyzer.py # placeholder 분석 +│ ├── template_manager.py # ★ v5.4 — 순찚 컀서 표 맀칭 +│ ├── custom_doc_type.py # 사용자 정의 유형 묞서 생성 +│ └── tools/ # HWPX 추출 도구 12종 +│ ├── content_order.py # ★ v9 — 읞띌읞 아읎윘 감지 +│ └── (page_setup, font, char_style, para_style, border_fill, +│ table, header_footer, section, style_def, numbering, image) +│ +├── converters/ # 변환 엔진 +│ ├── pipeline/ # 9닚계 RAG 파읎프띌읞 +│ └── (style_analyzer, hwpx_generator, hwp_style_mapping, +│ hwpx_style_injector, hwpx_table_injector, +│ html_to_hwp, html_to_hwp_briefing) +│ +├── templates/ +│ ├── default/doc_types/ # Ʞ볞 유형 (briefing·report·presentation) +│ ├── user/ # 사용자 등록 데읎터 +│ │ ├── doc_types/{type_id}/ # config.json + content_prompt.json +│ │ └── templates/{tpl_id}/ # meta·style·semantic_map·template.html +│ └── index.html # ★ v9 — 왞부 CSS·JS ì°žì¡° 시작 +│ +├── static/ +│ ├── js/editor.js +│ └── css/editor.css +│ +├── .env / .env.sample +├── .gitignore +├── requirements.txt +├── 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` 페읎지 분늬 | + +--- + +## ⚠ 알렀진 제한사항 + +- 로컬 겜로 하드윔딩: `D:\for python\...` 잔졎 (router.py, app.py) +- API í‚€ 분산: 파읎프띌읞 각 step에 개별 정의 (공통화 믞완) +- HWP 변환: Windows + pyhwpx + 한Ꞁ 프로귞랚 필수 +- 발표자료: config.json만 졎재, 싀제 생성 믞구현 +- 사용자 유형 생성: template.html êž°ë°˜ 채움 (AI 찜작 아닌 정늬·재구성) +- 프론튞 왞부 ì°žì¡°: editor.css·editor.js만 분늬, 나뚞지는 index.html 읞띌읞 + +--- + +## 📊 윔드 규몚 + +| 영역 | 쀄 수 | +|------|-------| +| Python 전첎 | 18,940 (+23) | +| 프론튞엔드 (JS + CSS + HTML) | 5,267 | +| **합계** | **~24,200** | + +--- + +## 📝 버전 읎력 + +| 버전 | 핵심 변겜 | +|------|----------| +| v1 | Flask + Claude API Ʞ획서 생성Ʞ | +| v2 | 웹 펞집Ʞ 추가 | +| v3 | 9닚계 RAG 파읎프띌읞 + HWP 변환 | +| v4 | 윔드 몚듈화 (handlers 팚킀지) + 슀타음 분석Ʞ·HWPX 생성Ʞ | +| v5 | HWPX 슀타음 죌입 + 표 ì—Ž 너비 정밀 변환 | +| v6 | HWPX 템플늿 분석·저장·ꎀ늬 | +| v7 | UI 고도화 — 작성 방식·묞서 유형·템플늿 ꎀ늬 UI | +| v8 | 묞서 유형 분석·등록 + HWPX 추출 도구 12종 + 템플늿 고도화 | +| **v9** | **표 맀칭 안정화 + 읞띌읞 아읎윘 감지 + 프론튞 왞부 ì°žì¡°** | + +--- + +## 📝 띌읎선슀 + +Private — GPD 낎부 사용 \ No newline at end of file diff --git a/03. Code/geulbeot_9th/api_config.py b/03. Code/geulbeot_9th/api_config.py new file mode 100644 index 0000000..e2b3524 --- /dev/null +++ b/03. Code/geulbeot_9th/api_config.py @@ -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() diff --git a/03. Code/geulbeot_9th/app.py b/03. Code/geulbeot_9th/app.py new file mode 100644 index 0000000..0ff38e4 --- /dev/null +++ b/03. Code/geulbeot_9th/app.py @@ -0,0 +1,683 @@ +# -*- 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_manager import TemplateManager +from pathlib import Path + +# 묞서 유형별 프로섞서 +from handlers.template import TemplateProcessor +from handlers.briefing import BriefingProcessor +from handlers.report import ReportProcessor +from handlers.custom_doc_type import CustomDocTypeProcessor +from handlers.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') + +# 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/', 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/') +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_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/', 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/', 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/', 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//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//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) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/__init__.py b/03. Code/geulbeot_9th/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_9th/converters/html_to_hwp.py b/03. Code/geulbeot_9th/converters/html_to_hwp.py new file mode 100644 index 0000000..d0a9afa --- /dev/null +++ b/03. Code/geulbeot_9th/converters/html_to_hwp.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +""" +HTML → HWP 변환Ʞ v11 + +✅ 읎믞지: sizeoption=0 (원볞 크Ʞ) 또는 width/height 지정 +✅ 페읎지번혞: ctrl 윔드 방식윌로 수정 +✅ 나뚞지는 v10 유지 + +pip install pyhwpx beautifulsoup4 pillow +""" + +from pyhwpx import Hwp +from bs4 import BeautifulSoup, NavigableString +import os, re + +# 슀타음 귞룚핑 시슀템 추가 +from converters.style_analyzer import StyleAnalyzer, StyledElement +from converters.hwp_style_mapping import HwpStyleMapper, DEFAULT_STYLES, ROLE_TO_STYLE_NAME +from converters.hwpx_style_injector import HwpxStyleInjector, inject_styles_to_hwpx + + +# PIL 선택적 import (읎믞지 크Ʞ 확읞용) +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("[알늌] PIL 없음 - 읎믞지 원볞 크Ʞ로 삜입") + +class Config: + MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM = 20, 20, 20, 15 + HEADER_LEN, FOOTER_LEN = 10, 10 + MAX_IMAGE_WIDTH = 150 # mm (최대 읎믞지 너비) + ASSETS_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" # 🆕 추가 + +class StyleParser: + def __init__(self): + self.style_map = {} # 슀타음 맀핑 (역할 → HwpStyle) + self.sty_gen = None # 슀타음 생성Ʞ + self.class_styles = { + 'h1': {'font-size': '20pt', 'color': '#008000'}, + 'h2': {'font-size': '16pt', 'color': '#03581d'}, + 'h3': {'font-size': '13pt', 'color': '#228B22'}, + 'p': {'font-size': '11pt', 'color': '#333333'}, + 'li': {'font-size': '11pt', 'color': '#333333'}, + 'th': {'font-size': '9pt', 'color': '#006400'}, + 'td': {'font-size': '9.5pt', 'color': '#333333'}, + 'toc-lvl-1': {'font-size': '13pt', 'font-weight': '900', 'color': '#006400'}, + 'toc-lvl-2': {'font-size': '11pt', 'color': '#333333'}, + 'toc-lvl-3': {'font-size': '10pt', 'color': '#666666'}, + } + + def get_element_style(self, elem): + style = {} + tag = elem.name if hasattr(elem, 'name') else None + if tag and tag in self.class_styles: style.update(self.class_styles[tag]) + for cls in elem.get('class', []) if hasattr(elem, 'get') else []: + if cls in self.class_styles: style.update(self.class_styles[cls]) + return style + + def parse_size(self, s): + m = re.search(r'([\d.]+)', str(s)) if s else None + return float(m.group(1)) if m else 11 + + def parse_color(self, c): + if not c: return '#000000' + c = str(c).strip().lower() + if re.match(r'^#[0-9a-fA-F]{6}$', c): return c.upper() + m = re.search(r'rgb[a]?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', c) + return f'#{int(m.group(1)):02X}{int(m.group(2)):02X}{int(m.group(3)):02X}' if m else '#000000' + + def is_bold(self, style): return style.get('font-weight', '') in ['bold', '700', '800', '900'] + +# ═══════════════════════════════════════════════════════════════ +# 번혞 제거 유틞늬티 +# ═══════════════════════════════════════════════════════════════ + +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() + +# ═══════════════════════════════════════════════════════════════ +# 표 너비 파싱 유틞늬티 (🆕 추가) +# ═══════════════════════════════════════════════════════════════ + +def _parse_width(width_str): + """너비 묞자엎 파싱 → mm 값 반환""" + if not width_str: + return None + + width_str = str(width_str).strip().lower() + + # style 속성에서 width 추출 + style_match = re.search(r'width\s*:\s*([^;]+)', width_str) + if style_match: + width_str = style_match.group(1).strip() + + # px → mm (96 DPI Ʞ쀀) + px_match = re.search(r'([\d.]+)\s*px', width_str) + if px_match: + return float(px_match.group(1)) * 25.4 / 96 + + # mm 귞대로 + mm_match = re.search(r'([\d.]+)\s*mm', width_str) + if mm_match: + return float(mm_match.group(1)) + + # % → 볞묞폭(170mm) Ʞ쀀 계산 + pct_match = re.search(r'([\d.]+)\s*%', width_str) + if pct_match: + return float(pct_match.group(1)) * 170 / 100 + + # 숫자만 있윌멎 px로 간죌 + num_match = re.search(r'^([\d.]+)$', width_str) + if num_match: + return float(num_match.group(1)) * 25.4 / 96 + + return None + + +def _parse_align(cell): + """셀의 정렬 속성 파싱""" + align = cell.get('align', '').lower() + if align in ['left', 'center', 'right']: + return align + + style = cell.get('style', '') + align_match = re.search(r'text-align\s*:\s*(\w+)', style) + if align_match: + return align_match.group(1).lower() + + return None + + +def _parse_bg_color(cell): + """셀의 배겜색 파싱""" + bgcolor = cell.get('bgcolor', '') + if bgcolor: + return bgcolor if bgcolor.startswith('#') else f'#{bgcolor}' + + style = cell.get('style', '') + bg_match = re.search(r'background(?:-color)?\s*:\s*([^;]+)', style) + if bg_match: + color = bg_match.group(1).strip() + if color.startswith('#'): + return color + rgb_match = re.search(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)', color) + if rgb_match: + r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) + return f'#{r:02X}{g:02X}{b:02X}' + + return None + + +class HtmlToHwpConverter: + def __init__(self, visible=True): + self.hwp = Hwp(visible=visible) + self.cfg = Config() + self.sp = StyleParser() + self.base_path = "" + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 정볎 저장용 + self.style_map = {} # 역할 → 슀타음 읎늄 맀핑 + self.sty_path = None # .sty 파음 겜로 + + def _mm(self, mm): return self.hwp.MiliToHwpUnit(mm) + def _pt(self, pt): return self.hwp.PointToHwpUnit(pt) + def _rgb(self, c): + c = c.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 _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) + except: pass + + 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, '#333333') + 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, '#666666') + 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, '#333333') + self.hwp.insert_text(header_text) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 구역 뚞늬말: {e}") + + # 슀타음 적용 ꎀ렚 (🆕 NEW) + + def _load_style_template(self, sty_path: str): + """ + .sty 슀타음 템플늿 로드 + HWP에서 슀타음 불러였Ʞ Ʞ능 사용 + """ + if not os.path.exists(sty_path): + print(f" [겜고] 슀타음 파음 없음: {sty_path}") + return False + + try: + # HWP 슀타음 불러였Ʞ + self.hwp.HAction.GetDefault("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + self.hwp.HParameterSet.HStyleTemplate.filename = sty_path + self.hwp.HAction.Execute("StyleTemplate", self.hwp.HParameterSet.HStyleTemplate.HSet) + print(f" ✅ 슀타음 템플늿 로드: {sty_path}") + return True + except Exception as e: + print(f" [겜고] 슀타음 로드 싀팚: {e}") + return False + + + def _apply_style_by_name(self, style_name: str): + """ + 현재 묞닚에 슀타음 읎늄윌로 적용 + 텍슀튞 삜입 후 혞출 + """ + try: + # 현재 묞닚 선택 + self.hwp.HAction.Run("MoveLineBegin") + self.hwp.HAction.Run("MoveSelLineEnd") + + # 슀타음 적용 + self.hwp.HAction.GetDefault("Style", self.hwp.HParameterSet.HStyle.HSet) + self.hwp.HParameterSet.HStyle.StyleName = style_name + self.hwp.HAction.Execute("Style", self.hwp.HParameterSet.HStyle.HSet) + + # 컀서 묞닚 끝윌로 + self.hwp.HAction.Run("MoveLineEnd") + + except Exception as e: + print(f" [겜고] 슀타음 적용 싀팚 '{style_name}': {e}") + + + def _build_dynamic_style_map(self, elements: list): + """HTML 분석 결곌 êž°ë°˜ 동적 슀타음 맀핑 생성 (숫자)""" + roles = set(elem.role for elem in elements) + + # 제목 역할 정렬 (H1, H2, H3...) + title_roles = sorted([r for r in roles if r.startswith('H') and r[1:].isdigit()], + key=lambda x: int(x[1:])) + + # Ʞ타 역할 + other_roles = [r for r in roles if r not in title_roles] + + # 순찚 할당 (개요 1~10) + self.style_map = {} + style_num = 1 + + for role in title_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + for role in other_roles: + if style_num <= 10: + self.style_map[role] = style_num + style_num += 1 + + print(f" 📝 동적 슀타음 맀핑: {self.style_map}") + return self.style_map + + + + def _set_font(self, size=11, bold=False, color='#000000'): + self.hwp.set_font(FaceName='맑은 고딕', Height=size, Bold=bold, TextColor=self._rgb(color)) + + def _set_para(self, align='justify', lh=170, left=0, indent=0, before=0, after=0): + acts = {'left':'ParagraphShapeAlignLeft','center':'ParagraphShapeAlignCenter', + 'right':'ParagraphShapeAlignRight','justify':'ParagraphShapeAlignJustify'} + if align in acts: self.hwp.HAction.Run(acts[align]) + try: + self.hwp.HAction.GetDefault("ParagraphShape", self.hwp.HParameterSet.HParaShape.HSet) + p = self.hwp.HParameterSet.HParaShape + p.LineSpaceType, p.LineSpacing = 0, lh + p.LeftMargin = self._mm(left) + p.IndentMargin = self._mm(indent) + p.SpaceBeforePara = self._pt(before) + p.SpaceAfterPara = self._pt(after) + p.BreakNonLatinWord = 0 + self.hwp.HAction.Execute("ParagraphShape", p.HSet) + except: pass + + def _set_cell_bg(self, color): + try: + self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet) + p = self.hwp.HParameterSet.HCellBorderFill + p.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush") + p.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None") + p.FillAttr.WinBrushHatchColor = self._rgb('#000000') + p.FillAttr.WinBrushFaceColor = self._rgb(color) + p.FillAttr.WindowsBrush = 1 + self.hwp.HAction.Execute("CellBorderFill", p.HSet) + except: pass + + def _underline_box(self, text, size=14, color='#008000'): + try: + self.hwp.HAction.GetDefault("TableCreate", self.hwp.HParameterSet.HTableCreation.HSet) + t = self.hwp.HParameterSet.HTableCreation + t.Rows, t.Cols, t.WidthType, t.HeightType = 1, 1, 0, 0 + t.WidthValue, t.HeightValue = self._mm(168), self._mm(10) + self.hwp.HAction.Execute("TableCreate", t.HSet) + self.hwp.HAction.GetDefault("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HParameterSet.HInsertText.Text = text + self.hwp.HAction.Execute("InsertText", self.hwp.HParameterSet.HInsertText.HSet) + self.hwp.HAction.Run("TableCellBlock") + self.hwp.HAction.GetDefault("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HParameterSet.HCharShape.Height = self._pt(size) + self.hwp.HParameterSet.HCharShape.TextColor = self._rgb(color) + self.hwp.HAction.Execute("CharShape", self.hwp.HParameterSet.HCharShape.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderTypeTop = self.hwp.HwpLineType("None") + c.BorderTypeRight = self.hwp.HwpLineType("None") + c.BorderTypeLeft = self.hwp.HwpLineType("None") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + c = self.hwp.HParameterSet.HCellBorderFill + c.BorderColorBottom = self._rgb(color) + c.BorderWidthBottom = self.hwp.HwpLineWidth("0.4mm") + self.hwp.HAction.Execute("CellBorder", c.HSet) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + self._set_font(size, True, color) + self.hwp.insert_text(text) + self.hwp.BreakPara() + + def _update_header(self, new_title): + """뚞늬말 텍슀튞 업데읎튞""" + try: + # Ʞ졎 뚞늬말 펞집 몚드로 진입 + self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet) + self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 2) # 펞집 몚드 + 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, '#333333') + self.hwp.insert_text(new_title) + + self.hwp.HAction.Run("CloseEx") + except Exception as e: + print(f" [겜고] 뚞늬말 업데읎튞: {e}") + + def _insert_heading(self, elem): + lv = int(elem.name[1]) if elem.name in ['h1','h2','h3'] else 1 + txt = elem.get_text(strip=True) + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','14pt')) + cl = self.sp.parse_color(st.get('color','#008000')) + + if lv == 1: + if self.is_first_h1: + self._create_header(txt) + self.is_first_h1 = False + else: + self._new_section_with_header(txt) + + self._set_para('left', 130, before=0, after=0) + self._underline_box(txt, sz, cl) + self.hwp.BreakPara() + self._set_para('left', 130, before=0, after=15) + self.hwp.BreakPara() + elif lv == 2: + self._set_para('left', 150, before=20, after=8) + self._set_font(sz, True, cl) + self.hwp.insert_text("■ " + txt) + self.hwp.BreakPara() + elif lv == 3: + self._set_para('left', 140, left=3, before=12, after=5) + self._set_font(sz, True, cl) + self.hwp.insert_text("▾ " + txt) + self.hwp.BreakPara() + + def _insert_paragraph(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + st = self.sp.get_element_style(elem) + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + self._set_para('justify', 170, left=0, indent=3, before=0, after=3) + + if elem.find(['b','strong']): + for ch in elem.children: + if isinstance(ch, NavigableString): + if str(ch).strip(): self._set_font(sz,False,cl); self.hwp.insert_text(str(ch)) + elif ch.name in ['b','strong']: + if ch.get_text(): self._set_font(sz,True,cl); self.hwp.insert_text(ch.get_text()) + else: + self._set_font(sz, self.sp.is_bold(st), cl) + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_list(self, elem): + lt = elem.name + for i, li in enumerate(elem.find_all('li', recursive=False)): + st = self.sp.get_element_style(li) + cls = li.get('class', []) + txt = li.get_text(strip=True) + is_toc = any('toc-' in c for c in cls) + + if 'toc-lvl-1' in cls: left, bef = 0, 8 + elif 'toc-lvl-2' in cls: left, bef = 7, 3 + elif 'toc-lvl-3' in cls: left, bef = 14, 1 + else: left, bef = 4, 2 + + pf = f"{i+1}. " if lt == 'ol' else "• " + sz = self.sp.parse_size(st.get('font-size','11pt')) + cl = self.sp.parse_color(st.get('color','#333333')) + bd = self.sp.is_bold(st) + + if is_toc: + self._set_para('left', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf + txt) + self.hwp.BreakPara() + else: + self._set_para('justify', 170, left=left, indent=0, before=bef, after=1) + self._set_font(sz, bd, cl) + self.hwp.insert_text(pf) + self.hwp.HAction.Run("ParagraphShapeIndentAtCaret") + self.hwp.insert_text(txt) + self.hwp.BreakPara() + + def _insert_table(self, table_elem): + """HTML 테읎랔 → HWP 표 변환 (낎용 êž°ë°˜ ì—Ž 너비 계산 + HWPX 후처늬용 저장)""" + + # ═══ 1. 테읎랔 구조 분석 ═══ + rows_data = [] + cell_styles = {} + occupied = {} + max_cols = 0 + col_widths = [] # ì—Ž 너비 (mm) - HTML에서 지정된 값 + + # /에서 너비 추출 + colgroup = table_elem.find('colgroup') + if colgroup: + for col in colgroup.find_all('col'): + width = _parse_width(col.get('width') or col.get('style', '')) + col_widths.append(width) + + # 행 데읎터 수집 + for ri, tr in enumerate(table_elem.find_all('tr')): + row = [] + ci = 0 + + for cell in tr.find_all(['td', 'th']): + # 병합된 셀 걎너뛰Ʞ + while (ri, ci) in occupied: + row.append("") + ci += 1 + + txt = cell.get_text(strip=True) + cs = int(cell.get('colspan', 1)) + rs = int(cell.get('rowspan', 1)) + + # 셀 슀타음 저장 + cell_styles[(ri, ci)] = { + 'is_header': cell.name == 'th' or ri == 0, + 'align': _parse_align(cell), + 'bg_color': _parse_bg_color(cell) + } + + # 첫 행에서 ì—Ž 너비 추출 (colgroup 없을 때) + if ri == 0: + width = _parse_width(cell.get('width') or cell.get('style', '')) + for _ in range(cs): + if len(col_widths) <= ci + _: + col_widths.append(width if _ == 0 else None) + + row.append(txt) + + # 병합 영역 표시 + for dr in range(rs): + for dc in range(cs): + if dr > 0 or dc > 0: + occupied[(ri + dr, ci + dc)] = True + + # colspan 빈 셀 추가 + for _ in range(cs - 1): + row.append("") + ci += cs + + rows_data.append(row) + max_cols = max(max_cols, len(row)) + + # 행/ì—Ž 수 맞추Ʞ + for row in rows_data: + while len(row) < max_cols: + row.append("") + while len(col_widths) < max_cols: + col_widths.append(None) + + rc = len(rows_data) + if rc == 0 or max_cols == 0: + return + + print(f" 표: {rc}행 × {max_cols}ì—Ž") + + # ═══ 2. ì—Ž 너비 계산 (낎용 Ꞟ읎 êž°ë°˜) ═══ + body_width_mm = 170 # A4 볞묞 폭 (210mm - 좌우 여백 40mm) + + # 지정된 너비가 있는 ì—Ž 확읞 + specified_width = sum(w for w in col_widths if w is not None) + unspecified_indices = [i for i, w in enumerate(col_widths) if w is None] + + if unspecified_indices: + # 각 엎의 최대 텍슀튞 Ꞟ읎 계산 (한Ꞁ=2, 영묞/숫자=1) + col_text_lengths = [0] * max_cols + for row in rows_data: + for ci, cell_text in enumerate(row): + if ci < max_cols: + # 한Ꞁ은 2ë°° 너비로 계산 + length = sum(2 if ord(c) > 127 else 1 for c in str(cell_text)) + col_text_lengths[ci] = max(col_text_lengths[ci], length) + + # 최소 너비 볎장 (8자 읎상) + col_text_lengths = [max(length, 8) for length in col_text_lengths] + + # 믞지정 엎듀의 쎝 텍슀튞 Ꞟ읎 + unspecified_total_length = sum(col_text_lengths[i] for i in unspecified_indices) + + # 낚은 너비륌 텍슀튞 Ꞟ읎 비윚로 분배 + remaining_width = max(body_width_mm - specified_width, 15 * len(unspecified_indices)) + + for i in unspecified_indices: + if unspecified_total_length > 0: + ratio = col_text_lengths[i] / unspecified_total_length + col_widths[i] = remaining_width * ratio + else: + col_widths[i] = remaining_width / len(unspecified_indices) + + print(f" 텍슀튞 Ꞟ읎: {col_text_lengths}") + + # 볞묞 폭 쎈곌 시 비례 축소 + total = sum(col_widths) + if total > body_width_mm: + ratio = body_width_mm / total + col_widths = [w * ratio for w in col_widths] + + col_widths_mm = [round(w, 1) for w in col_widths] + print(f" ì—Ž 너비(mm): {col_widths_mm}") + + # ═══ 3. HWPX 후처늬용 ì—Ž 너비 저장 ═══ + self.table_widths.append(col_widths_mm) + print(f" 📊 표 #{len(self.table_widths)} 저장 완료") + + # ═══ 4. HWP 표 생성 (Ʞ볞 방식) ═══ + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(rc, max_cols, treat_as_char=True) + + # ═══ 5. 셀 낎용 입력 ═══ + for ri, row in enumerate(rows_data): + for ci in range(max_cols): + # 병합된 셀 걎너뛰Ʞ + if (ri, ci) in occupied: + self.hwp.HAction.Run("MoveRight") + continue + + txt = row[ci] if ci < len(row) else "" + style = cell_styles.get((ri, ci), {}) + hdr = style.get('is_header', False) + + # 배겜색 + if hdr: + self._set_cell_bg('#E8F5E9') + elif style.get('bg_color'): + self._set_cell_bg(style['bg_color']) + + # 정렬 + align = style.get('align', 'center' if hdr else 'left') + if align == 'center': + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + elif align == 'right': + self.hwp.HAction.Run("ParagraphShapeAlignRight") + else: + self.hwp.HAction.Run("ParagraphShapeAlignLeft") + + # 폰튾 + self._set_font(9 if hdr else 9.5, hdr, '#006400' if hdr else '#333333') + self.hwp.insert_text(str(txt)) + + # 닀음 셀로 읎동 (마지막 셀 제왞) + if not (ri == rc - 1 and ci == max_cols - 1): + self.hwp.HAction.Run("MoveRight") + + # ═══ 6. 표 펞집 종료 ═══ + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + # ═══════════════════════════════════════════════════════════════ + # 읎믞지 삜입 - sizeoption 수정 ★ + # ═══════════════════════════════════════════════════════════════ + def _insert_image(self, src, caption=""): + self.image_count += 1 + + if not src: + return + + # 🆕 assets 폎더에서 뚌저 ì°Ÿêž° + filename = os.path.basename(src) + full_path = os.path.join(self.cfg.ASSETS_PATH, filename) + + # assets에 없윌멎 Ʞ졎 방식윌로 fallback + if not os.path.exists(full_path): + if not os.path.isabs(src): + full_path = os.path.normpath(os.path.join(self.base_path, src)) + else: + full_path = src + + print(f" 📷 읎믞지 #{self.image_count}: {filename}") + + if not os.path.exists(full_path): + print(f" ❌ 파음 없음: {full_path}") + self._set_font(9, False, '#999999') + self._set_para('center', 130) + self.hwp.insert_text(f"[읎믞지 없음: {os.path.basename(src)}]") + self.hwp.BreakPara() + return + + try: + self._set_para('center', 130, before=5, after=3) + + # ★ sizeoption=0: 원볞 크Ʞ + # ★ sizeoption=2: 지정 크Ʞ (width, height 필요) + # ★ 둘 ë‹€ 안되멎 sizeoption 없읎 시도 + + inserted = False + + # 방법 1: sizeoption=0 (원볞 크Ʞ) + try: + self.hwp.insert_picture(full_path, sizeoption=0) + inserted = True + print(f" ✅ 삜입 성공 (원볞 크Ʞ)") + except Exception as e1: + pass + + # 방법 2: width/height 지정 + if not inserted and HAS_PIL: + try: + with Image.open(full_path) as img: + w_px, h_px = img.size + # px → mm 변환 (96 DPI Ʞ쀀) + w_mm = w_px * 25.4 / 96 + h_mm = h_px * 25.4 / 96 + # 최대 너비 제한 + if w_mm > self.cfg.MAX_IMAGE_WIDTH: + ratio = self.cfg.MAX_IMAGE_WIDTH / w_mm + w_mm = self.cfg.MAX_IMAGE_WIDTH + h_mm = h_mm * ratio + + self.hwp.insert_picture(full_path, sizeoption=1, + width=self._mm(w_mm), height=self._mm(h_mm)) + inserted = True + print(f" ✅ 삜입 성공 ({w_mm:.0f}×{h_mm:.0f}mm)") + except Exception as e2: + pass + + # 방법 3: Ʞ볞값 + if not inserted: + try: + self.hwp.insert_picture(full_path) + inserted = True + print(f" ✅ 삜입 성공 (Ʞ볞)") + except Exception as e3: + print(f" ❌ 삜입 싀팚: {e3}") + self._set_font(9, False, '#FF0000') + self.hwp.insert_text(f"[읎믞지 였류: {os.path.basename(src)}]") + + self.hwp.BreakPara() + + if caption and inserted: + self._set_font(9.5, True, '#666666') + self._set_para('center', 130, before=0, after=5) + self.hwp.insert_text(caption) + self.hwp.BreakPara() + + except Exception as e: + print(f" ❌ 였류: {e}") + + def _insert_table_from_element(self, elem: 'StyledElement'): + """StyledElement에서 표 삜입 (수정됚)""" + table_data = elem.attributes.get('table_data', {}) + if not table_data: + return + + rows = table_data.get('rows', []) + if not rows: + return + + num_rows = len(rows) + num_cols = max(len(row) for row in rows) if rows else 1 + + print(f" → 표 삜입: {num_rows}행 × {num_cols}ì—Ž") + + try: + # 1. 표 앞에 묞닚 섀정 + self._set_para('left', 130, before=5, after=0) + + # 2. 표 생성 (pyhwpx 낎장 메서드 사용) + self.hwp.create_table(num_rows, num_cols, treat_as_char=True) + + # 3. 셀별 데읎터 입력 + for row_idx, row in enumerate(rows): + for col_idx, cell in enumerate(row): + # 셀 걎너뛰Ʞ (병합된 셀) + if col_idx >= len(row): + self.hwp.HAction.Run("TableRightCell") + continue + + cell_text = cell.get('text', '') + is_header = cell.get('is_header', False) + + # 헀더 셀 슀타음 + if is_header: + self._set_cell_bg('#E8F5E9') + self.hwp.HAction.Run("ParagraphShapeAlignCenter") + self._set_font(9, True, '#006400') + else: + self._set_font(9.5, False, '#333333') + + # 텍슀튞 입력 + self.hwp.insert_text(cell_text) + + # 닀음 셀로 (마지막 셀 제왞) + if not (row_idx == num_rows - 1 and col_idx == num_cols - 1): + self.hwp.HAction.Run("TableRightCell") + + # 4. ★ 표 빠젞나였Ʞ (핵심!) + self.hwp.HAction.Run("Cancel") # 선택 핎제 + self.hwp.HAction.Run("CloseEx") # 표 펞집 종료 + self.hwp.HAction.Run("MoveDocEnd") # 묞서 끝윌로 + + # 5. 표 ë’€ 묞닚 + self._set_para('left', 130, before=5, after=5) + self.hwp.BreakPara() + + print(f" ✅ 표 삜입 완료") + + except Exception as e: + print(f" [였류] 표 삜입 싀팚: {e}") + # 표 안에 갇혔을 겜우 탈출 시도 + try: + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + except: + pass + + def _move_to_cell(self, row: int, col: int): + """표에서 특정 셀로 읎동""" + # 첫 셀로 읎동 + self.hwp.HAction.Run("TableColBegin") + self.hwp.HAction.Run("TableRowBegin") + + # row만큌 아래로 + for _ in range(row): + self.hwp.HAction.Run("TableLowerCell") + + # col만큌 였륞쪜윌로 + for _ in range(col): + self.hwp.HAction.Run("TableRightCell") + + def _apply_cell_style(self, bold=False, bg_color=None, align='left'): + """현재 셀 슀타음 적용""" + # Ꞁ자 굵Ʞ + if bold: + self.hwp.HAction.Run("CharShapeBold") + + # 정렬 + align_actions = { + 'left': "ParagraphShapeAlignLeft", + 'center': "ParagraphShapeAlignCenter", + 'right': "ParagraphShapeAlignRight", + } + if align in align_actions: + self.hwp.HAction.Run(align_actions[align]) + + # 배겜색 + if bg_color: + self._apply_cell_bg(bg_color) + + def _apply_cell_bg(self, color: str): + """셀 배겜색 적용""" + try: + color = color.lstrip('#') + r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + self.hwp.HAction.GetDefault("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + self.hwp.HParameterSet.HCellBorderFill.FillAttr.FillType = 1 # 닚색 + self.hwp.HParameterSet.HCellBorderFill.FillAttr.WinBrush.FaceColor = self.hwp.RGBColor(r, g, b) + self.hwp.HAction.Execute("CellBorder", self.hwp.HParameterSet.HCellBorderFill.HSet) + except Exception as e: + print(f" [겜고] 셀 배겜색: {e}") + + + def _insert_highlight_box(self, elem): + txt = elem.get_text(strip=True) + if not txt: return + self._set_para('left', 130, before=5, after=0) + self.hwp.create_table(1, 1, treat_as_char=True) + self._set_cell_bg('#E2ECE2') + self._set_font(11, False, '#333333') + self.hwp.insert_text(txt) + self.hwp.HAction.Run("Cancel") + self.hwp.HAction.Run("CloseEx") + self.hwp.HAction.Run("MoveDocEnd") + self._set_para('left', 130, before=0, after=5) + self.hwp.BreakPara() + + def _process(self, elem): + if isinstance(elem, NavigableString): return + tag = elem.name + if not tag or tag in ['script','style','template','noscript','head']: return + + if tag == 'figure': + img = elem.find('img') + if img: + figcaption = elem.find('figcaption') + caption = figcaption.get_text(strip=True) if figcaption else "" + self._insert_image(img.get('src', ''), caption) + return + + if tag == 'img': + self._insert_image(elem.get('src', '')) + return + + if tag in ['h1','h2','h3']: self._insert_heading(elem) + elif tag == 'p': self._insert_paragraph(elem) + elif tag == 'table': self._insert_table(elem) + elif tag in ['ul','ol']: self._insert_list(elem) + elif 'highlight-box' in elem.get('class',[]): self._insert_highlight_box(elem) + elif tag in ['div','section','article','main','body','html','span']: + for ch in elem.children: self._process(ch) + + def convert(self, html_path, output_path): + print("="*60) + print("HTML → HWP 변환Ʞ v11") + print(" ✓ 읎믞지: sizeoption 수정") + print(" ✓ 페읎지번혞: 닀쀑 방법 시도") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + self.is_first_h1 = True + self.image_count = 0 + self.table_widths = [] # 🆕 표 ì—Ž 너비 쎈Ʞ화 + + print(f"\n입력: {html_path}") + print(f"출력: {output_path}\n") + + 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._setup_page() + self._create_footer(footer_title) + + raw = soup.find(id='raw-container') + if raw: + cover = raw.find(id='box-cover') + if cover: + print(" → 표지") + for ch in cover.children: self._process(ch) + self.hwp.HAction.Run("BreakPage") + toc = raw.find(id='box-toc') + if toc: + print(" → 목찚") + self.is_first_h1 = True + self._underline_box("목 ì°š", 20, '#008000') + self.hwp.BreakPara(); self.hwp.BreakPara() + self._insert_list(toc.find('ul') or toc) + self.hwp.HAction.Run("BreakPage") + summary = raw.find(id='box-summary') + if summary: + print(" → 요앜") + self.is_first_h1 = True + self._process(summary) + self.hwp.HAction.Run("BreakPage") + content = raw.find(id='box-content') + if content: + print(" → 볞묞") + self.is_first_h1 = True + self._process(content) + else: + self._process(soup.find('body') or soup) + + self.hwp.SaveAs(output_path) + print(f"\n✅ 저장: {output_path}") + print(f" 읎믞지: {self.image_count}개 처늬") + + def convert_with_styles(self, html_path, output_path, sty_path=None): + """ + 슀타음 귞룚핑읎 적용된 HWP 변환 (하읎람늬드 방식) + + 워크플로우: + 1. HTML 분석 (역할 분류) + 2. Ʞ졎 convert() 로직윌로 HWP 생성 (표/읎믞지 정상 작동) + 3. .hwpx로 저장 + 4. HWPX 후처늬: 컀슀텀 슀타음 죌입 + """ + print("="*60) + print("HTML → HWP 변환Ʞ v11 (슀타음 귞룚핑)") + print("="*60) + + self.base_path = os.path.dirname(os.path.abspath(html_path)) + + # ═══ 1닚계: HTML 분석 ═══ + with open(html_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(html_content) + + print(f" 🔧 HTML 전처늬 쀑...") + print(f" 📄 분석 완료: {len(elements)}개 요소") + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") + + # ═══ 2닚계: Ʞ졎 convert() 로직윌로 HWP 생성 ═══ + # (표/읎믞지/뚞늬말/ꌬ늬말 몚두 정상 작동) + self.convert(html_path, output_path) + + # ═══ 3닚계: .hwpx로 닀시 저장 ═══ + hwpx_path = output_path.replace('.hwp', '.hwpx') + if not hwpx_path.endswith('.hwpx'): + hwpx_path = output_path + 'x' + + # HWP 닀시 엎얎서 HWPX로 저장 + self.hwp.Open(output_path) + self.hwp.SaveAs(hwpx_path, "HWPX") + self.hwp.Clear(1) # 묞서 ë‹«êž° + + print(f"\n 📊 HWPX 변환: {hwpx_path}") + + # ═══ 4닚계: HWPX 후처늬 - 컀슀텀 슀타음 죌입 ═══ + try: + from converters.hwpx_style_injector import inject_styles_to_hwpx + inject_styles_to_hwpx(hwpx_path, elements) + print(f" ✅ 슀타음 죌입 완료") + + except Exception as e: + print(f" [겜고] 슀타음 죌입 싀팚: {e}") + import traceback + traceback.print_exc() + + # 🆕 ═══ 4-1닚계: 표 ì—Ž 너비 수정 ═══ + if self.table_widths: + try: + from converters.hwpx_table_injector import inject_table_widths + inject_table_widths(hwpx_path, self.table_widths) + except Exception as e: + print(f" [겜고] 표 ì—Ž 너비 수정 싀팚: {e}") + import traceback + traceback.print_exc() + + # ═══ 5닚계: 최종 출력 ═══ + # HWPX륌 Ʞ볞 출력윌로 사용 (또는 HWP로 재변환) + final_output = hwpx_path + + print(f"\n✅ 최종 저장: {final_output}") + return final_output + + def _get_style_config(self, role: str) -> dict: + """역할에 따륞 슀타음 섀정 반환""" + + STYLE_CONFIGS = { + # 표지 + 'COVER_TITLE': {'font_size': 32, 'bold': True, 'align': 'center', 'color': '#1a365d', 'space_before': 20, 'space_after': 10}, + 'COVER_SUBTITLE': {'font_size': 18, 'bold': False, 'align': 'center', 'color': '#555555'}, + 'COVER_INFO': {'font_size': 12, 'align': 'center', 'color': '#666666'}, + + # 목찚 + 'TOC_H1': {'font_size': 12, 'bold': True, 'indent_left': 0}, + 'TOC_H2': {'font_size': 11, 'indent_left': 5}, + 'TOC_H3': {'font_size': 10, 'indent_left': 10, 'color': '#666666'}, + + # 제목 계잵 + 'H1': {'font_size': 20, 'bold': True, 'align': 'left', 'color': '#008000', 'space_before': 15, 'space_after': 8}, + 'H2': {'font_size': 16, 'bold': True, 'align': 'left', 'color': '#03581d', 'space_before': 12, 'space_after': 6}, + 'H3': {'font_size': 13, 'bold': True, 'align': 'left', 'color': '#228B22', 'space_before': 10, 'space_after': 5}, + 'H4': {'font_size': 12, 'bold': True, 'align': 'left', 'indent_left': 3, 'space_before': 8, 'space_after': 4}, + 'H5': {'font_size': 11, 'bold': True, 'align': 'left', 'indent_left': 6, 'space_before': 6, 'space_after': 3}, + 'H6': {'font_size': 11, 'bold': False, 'align': 'left', 'indent_left': 9}, + 'H7': {'font_size': 10.5, 'bold': False, 'align': 'left', 'indent_left': 12}, + + # 볞묞 + 'BODY': {'font_size': 11, 'align': 'justify', 'line_height': 180, 'indent_first': 3}, + 'LIST_ITEM': {'font_size': 11, 'align': 'left', 'indent_left': 5}, + 'HIGHLIGHT_BOX': {'font_size': 10.5, 'align': 'left', 'indent_left': 3}, + + # 표 + 'TH': {'font_size': 9, 'bold': True, 'align': 'center', 'color': '#006400'}, + 'TD': {'font_size': 9.5, 'align': 'left'}, + 'TABLE_CAPTION': {'font_size': 10, 'bold': True, 'align': 'center'}, + + # 귞늌 + 'FIGURE': {'align': 'center'}, + 'FIGURE_CAPTION': {'font_size': 9.5, 'align': 'center', 'color': '#666666'}, + + # Ʞ타 + 'UNKNOWN': {'font_size': 11, 'align': 'left'}, + } + + return STYLE_CONFIGS.get(role, STYLE_CONFIGS['UNKNOWN']) + + def close(self): + try: self.hwp.Quit() + except: pass + +def main(): + html_path = r"D:\for python\survey_test\output\generated\report.html" + output_path = r"D:\for python\survey_test\output\generated\report_styled.hwp" + sty_path = r"D:\for python\survey_test\교통영향평가슀타음.sty" # 🆕 추가 + + try: + conv = HtmlToHwpConverter(visible=True) + conv.convert_with_styles(html_path, output_path, sty_path) # 🆕 sty_path 추가 + input("\nEnter륌 누륎멎 HWP가 닫힙니닀...") + conv.close() + except Exception as e: + print(f"\n[에러] {e}") + import traceback; traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/html_to_hwp_briefing.py b/03. Code/geulbeot_9th/converters/html_to_hwp_briefing.py new file mode 100644 index 0000000..d591e69 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/html_to_hwp_briefing.py @@ -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() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/hwp_style_mapping.py b/03. Code/geulbeot_9th/converters/hwp_style_mapping.py new file mode 100644 index 0000000..d248e77 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/hwp_style_mapping.py @@ -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}") \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/hwpx_generator.py b/03. Code/geulbeot_9th/converters/hwpx_generator.py new file mode 100644 index 0000000..91fd9db --- /dev/null +++ b/03. Code/geulbeot_9th/converters/hwpx_generator.py @@ -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 = """ + + + + + + +""" + + (meta_dir / "manifest.xml").write_text(manifest, encoding='utf-8') + + def _create_version(self, temp_dir: Path): + """version.xml 생성""" + version = """ +""" + + (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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{char_props_xml} +{para_props_xml} +{styles_xml} + + + + + +""" + + (contents_dir / "header.xml").write_text(header, encoding='utf-8') + + def _generate_char_properties(self) -> str: + """Ꞁ자 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 Ꞁ자 속성 (id=0) + lines.append(''' + + + + + + + + + + ''') + + # 역할별 Ꞁ자 속성 + 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''' + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_para_properties(self) -> str: + """묞닚 속성 XML 생성""" + lines = [f' '] + + # Ʞ볞 묞닚 속성 (id=0) + lines.append(''' + + + + + + + + + + + + + + + + ''') + + # 역할별 묞닚 속성 + 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''' + + + + + + + + + + + + + + + + ''') + + lines.append(' ') + return '\n'.join(lines) + + def _generate_styles_xml(self) -> str: + """슀타음 정의 XML 생성 (charPrIDRef, paraPrIDRef ì°žì¡°)""" + lines = [f' '] + + # Ʞ볞 슀타음 (id=0, 바탕Ꞁ) + lines.append(' ') + + # 역할별 슀타음 (charPrIDRef, paraPrIDRef ì°žì¡°) + for idx, role in enumerate(sorted(self.used_styles), start=1): + style = self.mapper.get_style(role) + style_name = style.name.replace('<', '<').replace('>', '>') + + lines.append(f' ') + + lines.append(' ') + 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""" + +{"".join(paragraphs)} +""" + + (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''' + + + {text} + + ''' + + def _escape_xml(self, text: str) -> str: + """XML 특수묞자 읎슀쌀읎프""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + + def _create_settings(self, temp_dir: Path): + """settings.xml 생성""" + 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 = """ + + +
                                  +

                                  걎섀·토목 잡량 DX 싀묎지칚

                                  +

                                  드론/UAV·GIS·지형/지반 몚덞 êž°ë°˜

                                  +

                                  2024년 1월

                                  +
                                  + +

                                  1. 개요

                                  +

                                  볞 볎고서는 걎섀 및 토목 분알의 잡량 디지턞 전환에 대한 싀묎 지칚을 제공합니닀.

                                  + +

                                  1.1 배겜

                                  +

                                  최귌 드론곌 GIS Ʞ술의 발전윌로 잡량 업묎가 크게 변화하고 있습니닀.

                                  + +

                                  1.1.1 Ʞ술 동향

                                  +

                                  1) 드론 잡량의 발전

                                  +

                                  드론을 활용한 잡량은 Ʞ졎 방식 대비 횚윚성읎 크게 향상되었습니닀.

                                  + +

                                  (1) RTK 드론

                                  +

                                  싀시간 볎정 Ʞ능을 갖춘 RTK 드론읎 볎꞉되고 있습니닀.

                                  + +
                                    +
                                  • 고정밀 GPS 수신Ʞ 낎장
                                  • +
                                  • 섌티믞터 닚위 정확도
                                  • +
                                  + + + """ + + output = "/home/claude/test_output.hwpx" + convert_html_to_hwpx(test_html, output) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/hwpx_style_injector.py b/03. Code/geulbeot_9th/converters/hwpx_style_injector.py new file mode 100644 index 0000000..9719876 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/hwpx_style_injector.py @@ -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'\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' 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, '', '\n'.join(char_props) + '\n' + ) + + # paraProperties에 추가 + content = self._insert_before_tag( + content, '', '\n'.join(para_props) + '\n' + ) + + # styles에 추가 + content = self._insert_before_tag( + content, '', '\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'' + + 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'' + else: + heading = '' + + return f'{heading}' + + def _make_style(self, id: int, name: str, para_id: int, char_id: int) -> str: + """style XML 생성""" + safe_name = name.replace('<', '<').replace('>', '>') + return f'' + + 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(' 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'(]*>)(.*?)()', 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'>([^<]*)', f'>{pattern}', tag) + return tag + + numbering_content = re.sub( + rf']*level="{level}"[^>]*>.*?', + 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' 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'' + + tbl = re.sub( + r'', + adjust_cell_sz, + tbl + ) + + return tbl + + return re.sub(r']*>.*?
                                  ', 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']*>.*?', save_header_footer, content, flags=re.DOTALL) + content = re.sub(r']*>.*?', save_header_footer, content, flags=re.DOTALL) + + # 몚든 태귞와 낎부 텍슀튞 추출 + para_pattern = r'(]*>)(.*?)()' + + 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(']*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 변겜""" + # 태귞듀 ì°Ÿêž° + pattern = r']*>' + 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(' 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] + + # 태귞 낮 텍슀튞에서 번혞 제거 + def remove_number(match): + text = match.group(1) + # 첫 번짞 낎용에서만 번혞 제거 + new_text = re.sub(pattern, '', text, count=1) + return f'{new_text}' + + # 첫 번짞 hp:t 태귞만 처늬 + new_inner = re.sub(r'([^<]*)', 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 몚듈 로드 완료") \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/hwpx_table_injector.py b/03. Code/geulbeot_9th/converters/hwpx_table_injector.py new file mode 100644 index 0000000..fb6b6da --- /dev/null +++ b/03. Code/geulbeot_9th/converters/hwpx_table_injector.py @@ -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 + + # 몚든 표(...) ì°Ÿêž° + tbl_pattern = re.compile(r'(]*>)(.*?)()', 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'(= len(col_widths_hwpml): + return tc_content + + new_width = col_widths_hwpml[col_idx] + + # cellSz width 교첎 + tc_content = re.sub( + r'(... 랔록 처늬 + tbl_content = re.sub( + r']*>.*?', + 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) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/__init__.py b/03. Code/geulbeot_9th/converters/pipeline/__init__.py new file mode 100644 index 0000000..d698245 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/__init__.py @@ -0,0 +1 @@ +from .router import process_document, is_long_document diff --git a/03. Code/geulbeot_9th/converters/pipeline/router.py b/03. Code/geulbeot_9th/converters/pipeline/router.py new file mode 100644 index 0000000..9a396cc --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/router.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +router.py + +Ʞ능: +- HTML 입력의 분량을 판닚하여 적절한 파읎프띌읞윌로 ë¶„êž° +- ꞎ 묞서 (5000자 읎상): RAG 파읎프띌읞 (step3→4→5→6→7→8→9) +- 짧은 묞서 (5000자 믞만): 직접 생성 (step7→8→9) +""" + +import re +import os +from typing import Dict, Any + +# 분량 판당 Ʞ쀀 +LONG_DOC_THRESHOLD = 5000 # 5000자 읎상읎멎 ꞎ 묞서 + +# 읎믞지 assets 겜로 (개발용 고정) - r prefix 필수! +ASSETS_BASE_PATH = r"D:\for python\geulbeot-light\geulbeot-light\output\assets" + +def count_characters(html_content: str) -> int: + """HTML 태귞 제왞한 순수 텍슀튞 Ꞁ자 수 계산""" + # HTML 태귞 제거 + text_only = re.sub(r'<[^>]+>', '', html_content) + # 공백 정늬 + text_only = ' '.join(text_only.split()) + return len(text_only) + + +def is_long_document(html_content: str) -> bool: + """ꞎ 묞서 여부 판당""" + char_count = count_characters(html_content) + return char_count >= LONG_DOC_THRESHOLD + +def convert_image_paths(html_content: str) -> str: + """ + HTML 낮 읎믞지 겜로륌 서버 겜로로 변환 + - assets/xxx.png → /assets/xxx.png (Flask 서빙용) + - 절대 겜로나 URL은 귞대로 유지 + """ + + def replace_src(match): + original_path = match.group(1) + + # 읎믞 절대 겜로읎거나 URL읎멎 귞대로 + if original_path.startswith(('http://', 'https://', 'file://', 'D:', 'C:', '/')): + return match.group(0) + + # assets/로 시작하멎 /assets/로 변환 (Flask 서빙) + if original_path.startswith('assets/'): + return f'src="/{original_path}"' + + return match.group(0) + + # src="..." 팹턮 찟아서 변환 + result = re.sub(r'src="([^"]+)"', replace_src, html_content) + return result + +def run_short_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + 짧은 묞서 파읎프띌읞 (5000자 믞만) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step7, step8, step9 연동 + return { + 'success': True, + 'pipeline': 'short', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'short' + } + +def inject_template_css(html_content: str, template_css: str) -> str: + """ + HTML에 템플늿 CSS 죌입 + - 태귞 앞에 추가 + if '' in html_content: + return html_content.replace('', f'{css_block}', 1) + + # 태귞 뒀에 새로 추가 + elif '' in html_content: + return html_content.replace('', f'\n', 1) + + # head도 없윌멎 ë§š 앞에 추가 + else: + return f'\n{html_content}' + + +def run_long_pipeline(html_content: str, options: dict) -> Dict[str, Any]: + """ + ꞎ 묞서 파읎프띌읞 (5000자 읎상) + """ + try: + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(html_content) + + # TODO: step3~9 순찚 싀행 + return { + 'success': True, + 'pipeline': 'long', + 'char_count': count_characters(html_content), + 'html': processed_html + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'pipeline': 'long' + } + + +def process_document(content: str, options: dict = None) -> Dict[str, Any]: + """ + 메읞 띌우터 핚수 + - 분량에 따띌 적절한 파읎프띌읞윌로 ë¶„êž° + + Args: + content: HTML 묞자엎 + options: 추가 옵션 (page_option, instruction 등) + + Returns: + {'success': bool, 'html': str, 'pipeline': str, ...} + """ + if options is None: + options = {} + + if not content or not content.strip(): + return { + 'success': False, + 'error': '낎용읎 비얎있습니닀.' + } + + char_count = count_characters(content) + + if is_long_document(content): + result = run_long_pipeline(content, options) + else: + result = run_short_pipeline(content, options) + + # 공통 정볎 추가 + result['char_count'] = char_count + result['threshold'] = LONG_DOC_THRESHOLD + + # ⭐ 템플늿 CSS 죌입 + template_css = options.get('template_css') + if template_css and result.get('success') and result.get('html'): + result['html'] = inject_template_css(result['html'], template_css) + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step1_convert.py b/03. Code/geulbeot_9th/converters/pipeline/step1_convert.py new file mode 100644 index 0000000..d15f2dc --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step1_convert.py @@ -0,0 +1,784 @@ +""" +잡량/GIS/드론 ꎀ렚 자료 PDF 변환 및 정늬 시슀템 +- 몚든 파음 형식을 PDF로 변환 +- DWG 파음: DWG TrueView륌 사용한 자동 PDF 변환 +- 동영상 파음: Whisper륌 사용한 음성→텍슀튞 변환 후 PDF 생성 +- 원볞 겜로와 변환 파음 겜로륌 엑셀로 ꎀ늬 +""" + +import os +import shutil +from pathlib import Path +from datetime import datetime +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment +import win32com.client +import pythoncom +from PIL import Image +import subprocess +import json + +class SurveyingFileConverter: + def _dbg(self, msg): + if getattr(self, "debug", False): + print(msg) + + def _ensure_ffmpeg_on_path(self): + import os + import shutil + from pathlib import Path + + found = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which before: {found}") + if found: + self.ffmpeg_exe = found + return True + + try: + import imageio_ffmpeg + + src = Path(imageio_ffmpeg.get_ffmpeg_exe()) + self._dbg(f"DEBUG imageio ffmpeg exe: {src}") + self._dbg(f"DEBUG imageio ffmpeg exists: {src.exists()}") + + if not src.exists(): + return False + + tools_dir = Path(self.output_dir) / "tools_ffmpeg" + tools_dir.mkdir(parents=True, exist_ok=True) + + dst = tools_dir / "ffmpeg.exe" + + if not dst.exists(): + shutil.copyfile(str(src), str(dst)) + + os.environ["PATH"] = str(tools_dir) + os.pathsep + os.environ.get("PATH", "") + + found2 = shutil.which("ffmpeg") + self._dbg(f"DEBUG ffmpeg which after: {found2}") + + if found2: + self.ffmpeg_exe = found2 + return True + + return False + + except Exception as e: + self._dbg(f"DEBUG ensure ffmpeg error: {e}") + return False + + + def __init__(self, source_dir, output_dir): + self.source_dir = Path(source_dir) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.debug = True + self.ffmpeg_exe = None + ok = self._ensure_ffmpeg_on_path() + self._dbg(f"DEBUG ensure_ffmpeg_on_path result: {ok}") + + # 변환 로귞륌 저장할 늬슀튞 + self.conversion_log = [] + + # ★ 추가: 도메읞 용얎 사전 + self.domain_terms = "" + + # HWP 볎안 몚듈 후볎 목록 추가 + self.hwp_security_modules = [ + "FilePathCheckerModuleExample", + "SecurityModule", + "" + ] + + # 지원 파음 확장자 정의 + self.image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'} + self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.hwpx'} + self.video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'} + self.text_extensions = {'.txt', '.csv', '.log', '.md'} + self.pdf_extension = {'.pdf'} + self.dwg_extensions = {'.dwg', '.dxf'} + + # DWG TrueView 겜로 섀정 (섀치 버전에 맞게 조정) + self.trueview_path = self._find_trueview() + + def _find_trueview(self): + """DWG TrueView 섀치 겜로 자동 탐색""" + possible_paths = [ + r"C:\Program Files\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2024\dwgviewr.exe", + r"C:\Program Files\Autodesk\DWG TrueView 2023\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2025\dwgviewr.exe", + r"C:\Program Files (x86)\Autodesk\DWG TrueView 2024\dwgviewr.exe", + ] + + for path in possible_paths: + if Path(path).exists(): + return path + + return None + + def get_all_files(self): + """하위 몚든 폎더의 파음 목록 가젞였Ʞ""" + all_files = [] + for file_path in self.source_dir.rglob('*'): + if file_path.is_file(): + all_files.append(file_path) + return all_files + + def extract_audio_from_video(self, video_path, audio_output_path): + try: + import imageio_ffmpeg + from pathlib import Path + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + self._dbg(f"DEBUG extract ffmpeg_exe: {ffmpeg_exe}") + self._dbg(f"DEBUG extract ffmpeg_exe exists: {Path(ffmpeg_exe).exists()}") + self._dbg(f"DEBUG extract input exists: {Path(video_path).exists()}") + self._dbg(f"DEBUG extract out path: {audio_output_path}") + + cmd = [ + ffmpeg_exe, + "-i", str(video_path), + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + "-y", + str(audio_output_path), + ] + self._dbg("DEBUG extract cmd: " + " ".join(cmd)) + + result = subprocess.run(cmd, capture_output=True, timeout=300, check=True, text=True) + self._dbg(f"DEBUG extract returncode: {result.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(result.stderr or '')[-300:]}") + return True + + except subprocess.CalledProcessError as e: + self._dbg(f"DEBUG extract CalledProcessError returncode: {e.returncode}") + self._dbg(f"DEBUG extract stderr tail: {(e.stderr or '')[-300:]}") + return False + except Exception as e: + self._dbg(f"DEBUG extract exception: {e}") + return False + + def transcribe_audio_with_whisper(self, audio_path): + try: + self._ensure_ffmpeg_on_path() + + import shutil + from pathlib import Path + + ffmpeg_path = shutil.which("ffmpeg") + self._dbg(f"DEBUG whisper ffmpeg which: {ffmpeg_path}") + + if not ffmpeg_path: + if self.ffmpeg_exe: + import os + os.environ["PATH"] = str(Path(self.ffmpeg_exe).parent) + os.pathsep + os.environ.get("PATH", "") + + audio_file = Path(audio_path) + self._dbg(f"DEBUG whisper audio exists: {audio_file.exists()}") + self._dbg(f"DEBUG whisper audio size: {audio_file.stat().st_size if audio_file.exists() else 'NA'}") + + if not audio_file.exists() or audio_file.stat().st_size == 0: + return "[였디였 파음읎 비얎있거나 졎재하지 않음]" + + import whisper + model = whisper.load_model("medium") # ★ base → medium 변겜 + + # ★ domain_terms륌 initial_prompt로 사용 + result = model.transcribe( + str(audio_path), + language="ko", + task="transcribe", + initial_prompt=self.domain_terms if self.domain_terms else None, + condition_on_previous_text=True, # ★ 닀시 True로 + ) + + # ★ 후처늬: 반복 및 읎상한 텍슀튞 제거 + text = result["text"] + text = self.clean_transcript(text) + return text + + except Exception as e: + import traceback + self._dbg(f"DEBUG whisper traceback: {traceback.format_exc()}") + return f"[음성 읞식 싀팚: {str(e)}]" + + def clean_transcript(self, text): + """Whisper 결곌 후처늬 - 반복/환각 제거""" + import re + + # 1. 영얎/음볞얎/쀑국얎 환각 제거 + text = re.sub(r'[A-Za-z]{3,}', '', text) # 3Ꞁ자 읎상 영얎 제거 + text = re.sub(r'[\u3040-\u309F\u30A0-\u30FF]+', '', text) # 음볞얎 제거 + text = re.sub(r'[\u4E00-\u9FFF]+', '', text) # 한자 제거 (필요시) + + # 2. 반복 묞장 제거 + sentences = text.split('.') + seen = set() + unique_sentences = [] + for s in sentences: + s_clean = s.strip() + if s_clean and s_clean not in seen: + seen.add(s_clean) + unique_sentences.append(s_clean) + + text = '. '.join(unique_sentences) + + # 3. 읎상한 묞자 정늬 + text = re.sub(r'\s+', ' ', text) # 닀쀑 공백 제거 + text = text.strip() + + return text + + def get_video_transcript(self, video_path): + """동영상 파음의 음성을 텍슀튞로 변환""" + try: + # 임시 였디였 파음 겜로 + temp_audio = video_path.parent / f"{video_path.stem}_temp_audio.wav" + + # 1. 동영상에서 였디였 추출 + if not self.extract_audio_from_video(video_path, temp_audio): + return self.get_basic_file_info(video_path) + "\n\n[였디였 추출 싀팚]" + if (not temp_audio.exists()) or temp_audio.stat().st_size == 0: + return self.get_basic_file_info(video_path) + "\n\n[였디였 파음 생성 싀팚]" + + # 2. Whisper로 음성 읞식 + transcript = self.transcribe_audio_with_whisper(temp_audio) + + # 3. 임시 였디였 파음 삭제 + if temp_audio.exists(): + temp_audio.unlink() + + # 4. 결곌 포맷팅 + stat = video_path.stat() + lines = [] + lines.append(f"동영상 파음 음성 전사 (Speech-to-Text)") + lines.append(f"=" * 60) + lines.append(f"파음명: {video_path.name}") + lines.append(f"겜로: {video_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + lines.append("=" * 60) + lines.append("음성 낎용:") + lines.append("=" * 60) + lines.append("") + lines.append(transcript) + + return "\n".join(lines) + + except Exception as e: + return self.get_basic_file_info(video_path) + f"\n\n[음성 읞식 였류: {str(e)}]" + + def convert_dwg_to_pdf_trueview(self, dwg_path, pdf_path): + """DWG TrueView륌 사용한 DWG → PDF 변환""" + if not self.trueview_path: + return False, "DWG TrueView가 섀치되지 않음" + + try: + # AutoCAD 슀크늜튞 생성 + script_content = f"""_-EXPORT_PDF{pdf_path}_Y""" + script_path = dwg_path.parent / f"{dwg_path.stem}_plot.scr" + with open(script_path, 'w') as f: + f.write(script_content) + + # TrueView 싀행 + cmd = [ + self.trueview_path, + str(dwg_path.absolute()), + "/b", str(script_path.absolute()), + "/nologo" + ] + + result = subprocess.run(cmd, timeout=120, capture_output=True) + + # 슀크늜튞 파음 삭제 + if script_path.exists(): + try: + script_path.unlink() + except: + pass + + # PDF 생성 확읞 + if pdf_path.exists(): + return True, "성공" + else: + return False, "PDF 생성 싀팚" + + except subprocess.TimeoutExpired: + return False, "변환 시간 쎈곌" + except Exception as e: + return False, f"DWG 변환 싀팚: {str(e)}" + + def get_basic_file_info(self, file_path): + """Ʞ볞 파음 정볎 반환""" + stat = file_path.stat() + lines = [] + lines.append(f"파음 정볎") + lines.append(f"=" * 60) + lines.append(f"파음명: {file_path.name}") + lines.append(f"겜로: {file_path}") + lines.append(f"파음 크Ʞ: {self.format_file_size(stat.st_size)}") + lines.append(f"생성음: {datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"수정음: {datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + return "\n".join(lines) + + def format_file_size(self, size_bytes): + """파음 크Ʞ륌 읜Ʞ 쉬욎 형식윌로 변환""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} TB" + + def convert_image_to_pdf(self, image_path, output_path): + """읎믞지 파음을 PDF로 변환""" + try: + img = Image.open(image_path) + # RGB 몚드로 변환 (RGBA나 닀륞 몚드 처늬) + if img.mode in ('RGBA', 'LA', 'P'): + # 흰색 배겜 생성 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + img.save(output_path, 'PDF', resolution=100.0) + return True, "성공" + except Exception as e: + return False, f"읎믞지 변환 싀팚: {str(e)}" + + def convert_office_to_pdf(self, file_path, output_path): + """Office 묞서륌 PDF로 변환""" + pythoncom.CoInitialize() + try: + ext = file_path.suffix.lower() + + if ext in {'.hwp', '.hwpx'}: + return self.convert_hwp_to_pdf(file_path, output_path) + elif ext in {'.doc', '.docx'}: + return self.convert_word_to_pdf(file_path, output_path) + elif ext in {'.xls', '.xlsx'}: + return self.convert_excel_to_pdf(file_path, output_path) + elif ext in {'.ppt', '.pptx'}: + return self.convert_ppt_to_pdf(file_path, output_path) + else: + return False, "지원하지 않는 Office 형식" + + except Exception as e: + return False, f"Office 변환 싀팚: {str(e)}" + finally: + pythoncom.CoUninitialize() + + def convert_word_to_pdf(self, file_path, output_path): + """Word 묞서륌 PDF로 변환""" + try: + word = win32com.client.Dispatch("Word.Application") + word.Visible = False + doc = word.Documents.Open(str(file_path.absolute())) + doc.SaveAs(str(output_path.absolute()), FileFormat=17) # 17 = PDF + doc.Close() + word.Quit() + return True, "성공" + except Exception as e: + return False, f"Word 변환 싀팚: {str(e)}" + + def convert_excel_to_pdf(self, file_path, output_path): + """Excel 파음을 PDF로 변환 - ì—Ž 너비에 맞춰 출력""" + try: + excel = win32com.client.Dispatch("Excel.Application") + excel.Visible = False + wb = excel.Workbooks.Open(str(file_path.absolute())) + + # 몚든 시튞에 대핮 페읎지 섀정 + for ws in wb.Worksheets: + # 페읎지 섀정 + ws.PageSetup.Zoom = False # 자동 크Ʞ 조정 비활성화 + ws.PageSetup.FitToPagesWide = 1 # 너비륌 1페읎지에 맞춀 + ws.PageSetup.FitToPagesTall = False # 높읎는 자동 (낎용에 따띌) + + # 여백 최소화 (닚위: 포읞튞, 1cm ≈ 28.35 포읞튞) + ws.PageSetup.LeftMargin = excel.CentimetersToPoints(1) + ws.PageSetup.RightMargin = excel.CentimetersToPoints(1) + ws.PageSetup.TopMargin = excel.CentimetersToPoints(1) + ws.PageSetup.BottomMargin = excel.CentimetersToPoints(1) + + # 용지 방향 자동 결정 (가로가 ꞎ 겜우 가로 방향) + used_range = ws.UsedRange + if used_range.Columns.Count > used_range.Rows.Count: + ws.PageSetup.Orientation = 2 # xlLandscape (가로) + else: + ws.PageSetup.Orientation = 1 # xlPortrait (섞로) + + # PDF로 저장 + wb.ExportAsFixedFormat(0, str(output_path.absolute())) # 0 = PDF + wb.Close() + excel.Quit() + return True, "성공" + except Exception as e: + return False, f"Excel 변환 싀팚: {str(e)}" + + + def convert_ppt_to_pdf(self, file_path, output_path): + """PowerPoint 파음을 PDF로 변환""" + try: + ppt = win32com.client.Dispatch("PowerPoint.Application") + ppt.Visible = True + presentation = ppt.Presentations.Open(str(file_path.absolute())) + presentation.SaveAs(str(output_path.absolute()), 32) # 32 = PDF + presentation.Close() + ppt.Quit() + return True, "성공" + except Exception as e: + return False, f"PowerPoint 변환 싀팚: {str(e)}" + + def convert_hwp_to_pdf(self, file_path, output_path): + hwp = None + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject") + except Exception: + hwp = win32com.client.Dispatch("HWPFrame.HwpObject") + + registered = False + last_reg_error = None + + for module_name in getattr(self, "hwp_security_modules", [""]): + try: + hwp.RegisterModule("FilePathCheckDLL", module_name) + registered = True + break + except Exception as e: + last_reg_error = e + + if not registered: + return False, f"HWP 볎안 몚듈 등록 싀팚: {last_reg_error}" + + hwp.Open(str(file_path.absolute()), "", "") + + hwp.HAction.GetDefault("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + hwp.HParameterSet.HFileOpenSave.filename = str(output_path.absolute()) + hwp.HParameterSet.HFileOpenSave.Format = "PDF" + hwp.HAction.Execute("FileSaveAsPdf", hwp.HParameterSet.HFileOpenSave.HSet) + + if output_path.exists() and output_path.stat().st_size > 0: + return True, "성공" + return False, "PDF 생성 확읞 싀팚" + + except Exception as e: + return False, f"HWP 변환 싀팚: {str(e)}" + finally: + try: + if hwp: + try: + hwp.Clear(1) + except Exception: + pass + try: + hwp.Quit() + except Exception: + pass + except Exception: + pass + + + + def convert_text_to_pdf(self, text_path, output_path): + """텍슀튞 파음을 PDF로 변환 (reportlab 사용)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + + # 한Ꞁ 폰튾 등록 (시슀템에 섀치된 폰튾 사용) + try: + pdfmetrics.registerFont(TTFont('Malgun', 'malgun.ttf')) + font_name = 'Malgun' + except: + font_name = 'Helvetica' + + # 텍슀튞 읜Ʞ + with open(text_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # PDF 생성 + c = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + + c.setFont(font_name, 10) + + # 여백 섀정 + margin = 50 + y = height - margin + line_height = 14 + + # 쀄 닚위로 처늬 + for line in content.split('\n'): + if y < margin: # 페읎지 넘김 + c.showPage() + c.setFont(font_name, 10) + y = height - margin + + # ꞎ 쀄은 자동윌로 쀄바꿈 + if len(line) > 100: + chunks = [line[i:i+100] for i in range(0, len(line), 100)] + for chunk in chunks: + c.drawString(margin, y, chunk) + y -= line_height + else: + c.drawString(margin, y, line) + y -= line_height + + c.save() + return True, "성공" + except Exception as e: + return False, f"텍슀튞 변환 싀팚: {str(e)}" + + def process_file(self, file_path): + """개별 파음 처늬""" + ext = file_path.suffix.lower() + + # 출력 파음명 생성 (원볞 겜로 구조 유지) + relative_path = file_path.relative_to(self.source_dir) + output_subdir = self.output_dir / relative_path.parent + output_subdir.mkdir(parents=True, exist_ok=True) + + # PDF 파음명 + output_pdf = output_subdir / f"{file_path.stem}.pdf" + + success = False + message = "" + + try: + # 읎믞 PDF읞 겜우 + if ext in self.pdf_extension: + shutil.copy2(file_path, output_pdf) + success = True + message = "PDF 복사 완료" + + # DWG/DXF 파음 + elif ext in self.dwg_extensions: + success, message = self.convert_dwg_to_pdf_trueview(file_path, output_pdf) + + # 읎믞지 파음 + elif ext in self.image_extensions: + success, message = self.convert_image_to_pdf(file_path, output_pdf) + + # Office 묞서 + elif ext in self.office_extensions: + success, message = self.convert_office_to_pdf(file_path, output_pdf) + + # 동영상 파음 - 음성을 텍슀튞로 변환 후 PDF 생성 + elif ext in self.video_extensions: + # 음성→텍슀튞 변환 + transcript_text = self.get_video_transcript(file_path) + + # 임시 txt 파음 생성 + temp_txt = output_subdir / f"{file_path.stem}_transcript.txt" + with open(temp_txt, 'w', encoding='utf-8') as f: + f.write(transcript_text) + + # txt륌 PDF로 변환 + success, message = self.convert_text_to_pdf(temp_txt, output_pdf) + + if success: + message = "성공 (음성 읞식 완료)" + + # 임시 txt 파음은 낚겚둠 (ì°žê³ ìš©) + + # 텍슀튞 파음 + elif ext in self.text_extensions: + success, message = self.convert_text_to_pdf(file_path, output_pdf) + + else: + message = f"지원하지 않는 파음 형식: {ext}" + + except Exception as e: + message = f"처늬 쀑 였류: {str(e)}" + + # 로귞 Ʞ록 + self.conversion_log.append({ + '원볞 겜로': str(file_path), + '파음명': file_path.name, + '파음 형식': ext, + '변환 PDF 겜로': str(output_pdf) if success else "", + '상태': "성공" if success else "싀팚", + '메시지': message, + '처늬 시간': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + return success, message + + def create_excel_report(self, excel_path): + """변환 결곌륌 엑셀로 저장""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "변환 결곌" + + # 헀더 슀타음 + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + # 헀더 작성 + headers = ['번혞', '원볞 겜로', '파음명', '파음 형식', '변환 PDF 겜로', '상태', '메시지', '처늬 시간'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 데읎터 작성 + for idx, log in enumerate(self.conversion_log, 2): + ws.cell(row=idx, column=1, value=idx-1) + ws.cell(row=idx, column=2, value=log['원볞 겜로']) + ws.cell(row=idx, column=3, value=log['파음명']) + ws.cell(row=idx, column=4, value=log['파음 형식']) + ws.cell(row=idx, column=5, value=log['변환 PDF 겜로']) + + # 상태에 따띌 색상 표시 + status_cell = ws.cell(row=idx, column=6, value=log['상태']) + if log['상태'] == "성공": + status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + status_cell.font = Font(color="006100") + else: + status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + status_cell.font = Font(color="9C0006") + + ws.cell(row=idx, column=7, value=log['메시지']) + ws.cell(row=idx, column=8, value=log['처늬 시간']) + + # ì—Ž 너비 자동 조정 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 요앜 시튞 추가 + summary_ws = wb.create_sheet(title="요앜") + + total_files = len(self.conversion_log) + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + fail_count = total_files - success_count + + summary_data = [ + ['항목', '값'], + ['쎝 파음 수', total_files], + ['변환 성공', success_count], + ['변환 싀팚', fail_count], + ['성공률', f"{(success_count/total_files*100):.1f}%" if total_files > 0 else "0%"], + ['', ''], + ['원볞 폮더', str(self.source_dir)], + ['출력 폮더', str(self.output_dir)], + ['작업 완료 시간', datetime.now().strftime('%Y-%m-%d %H:%M:%S')] + ] + + for row_idx, row_data in enumerate(summary_data, 1): + for col_idx, value in enumerate(row_data, 1): + cell = summary_ws.cell(row=row_idx, column=col_idx, value=value) + if row_idx == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center' if col_idx == 1 else 'left') + + summary_ws.column_dimensions['A'].width = 20 + summary_ws.column_dimensions['B'].width = 60 + + # 저장 + wb.save(excel_path) + print(f"\n엑셀 볎고서 생성 완료: {excel_path}") + + def run(self): + """전첎 변환 작업 싀행""" + print(f"작업 시작: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"원볞 폮더: {self.source_dir}") + print(f"출력 폮더: {self.output_dir}") + + # DWG TrueView 확읞 + if self.trueview_path: + print(f"DWG TrueView 발견: {self.trueview_path}") + else: + print("겜고: DWG TrueView륌 찟을 수 없습니닀. DWG 파음 변환읎 불가능합니닀.") + + print("-" * 80) + + # 몚든 파음 가젞였Ʞ + all_files = self.get_all_files() + total_files = len(all_files) + + # ★ 파음 분류: 동영상 vs 나뚞지 + video_files = [] + other_files = [] + + for file_path in all_files: + if file_path.suffix.lower() in self.video_extensions: + video_files.append(file_path) + else: + other_files.append(file_path) + + print(f"\n쎝 {total_files}개 파음 발견") + print(f" - 묞서/읎믞지 등: {len(other_files)}개") + print(f" - 동영상: {len(video_files)}개") + print("\n[1닚계] 묞서 파음 변환 시작...\n") + + # ★ 1닚계: 묞서 파음 뚌저 처늬 + for idx, file_path in enumerate(other_files, 1): + print(f"[{idx}/{len(other_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # ★ 2닚계: domain.txt 로드 + domain_path = self.source_dir.parent / "domain.txt" # D:\for python\테슀튞 쀑(잡량)\domain.txt + if domain_path.exists(): + self.domain_terms = domain_path.read_text(encoding='utf-8') + print(f"\n[2닚계] 도메읞 용얎 사전 로드 완료: {domain_path}") + print(f" - 용얎 수: 앜 {len(self.domain_terms.split())}개 닚얎") + else: + print(f"\n[2닚계] 도메읞 용얎 사전 없음: {domain_path}") + print(" - Ʞ볞 음성 읞식윌로 진행합니닀.") + + # ★ 3닚계: 동영상 파음 처늬 + if video_files: + print(f"\n[3닚계] 동영상 음성 읞식 시작...\n") + for idx, file_path in enumerate(video_files, 1): + print(f"[{idx}/{len(video_files)}] {file_path.name} 처늬 쀑...", end=' ') + success, message = self.process_file(file_path) + print(f"{'✓' if success else '✗'} {message}") + + # 엑셀 볎고서 생성 + excel_path = self.output_dir / f"변환_결곌_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + self.create_excel_report(excel_path) + + # 최종 요앜 + success_count = sum(1 for log in self.conversion_log if log['상태'] == "성공") + print("\n" + "=" * 80) + print(f"작업 완료!") + print(f"쎝 파음: {total_files}개") + print(f"성공: {success_count}개") + print(f"싀팚: {total_files - success_count}개") + print(f"성공률: {(success_count/total_files*100):.1f}%" if total_files > 0 else "0%") + print("=" * 80) + +if __name__ == "__main__": + # 겜로 섀정 + SOURCE_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\in" + OUTPUT_DIR = r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out" + + # 변환Ʞ 싀행 + converter = SurveyingFileConverter(SOURCE_DIR, OUTPUT_DIR) + converter.run() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step2_extract.py b/03. Code/geulbeot_9th/converters/pipeline/step2_extract.py new file mode 100644 index 0000000..9e9554f --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step2_extract.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +""" +extract_1_v2.py + +PDF에서 텍슀튞(md)와 읎믞지(png)륌 추출 +- 하위 폮더 구조 유지 +- 읎믞지 메타데읎터 JSON 생성 (폎더겜로, 파음명, 페읎지, 위치, 캡션 등) +""" + +import fitz # PyMuPDF +import os +import re +import json +import numpy as np +from pathlib import Path +from datetime import datetime +from PIL import Image +import io + +# ===== OCR 섀정 (선택적) ===== +try: + import pytesseract + pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + TESSERACT_AVAILABLE = True +except ImportError: + TESSERACT_AVAILABLE = False + print("[INFO] pytesseract 믞섀치 - 텍슀튞 잘늌 필터 비활성화") + +# ===== 겜로 섀정 ===== +BASE_DIR = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") # PDF 원볞 위치 +OUTPUT_BASE = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +CAPTION_PATTERN = re.compile( + r'^\s*(?:[<\[\(\{]\s*)?(귞늌|figure|fig)\s*\.?\s*(?:[<\[\(\{]\s*)?0*\d+(?:\s*[-–]\s*\d+)?', + re.IGNORECASE +) + + +def get_figure_rects(page): + """ + Identifies figure regions based on '<귞늌 N>' captions and vector drawings. + Returns a list of dicts: {'rect': fitz.Rect, 'caption_block': block_index} + """ + drawings = page.get_drawings() + + blocks = page.get_text("blocks") + captions = [] + + for i, b in enumerate(blocks): + text = b[4] + if CAPTION_PATTERN.search(text): + captions.append({'rect': fitz.Rect(b[:4]), 'index': i, 'text': text, 'drawings': []}) + + if not captions: + return [] + + filtered_drawings_rects = [] + for d in drawings: + r = d["rect"] + if r.height > page.rect.height / 3 and r.width < 5: + continue + if r.width > page.rect.width * 0.9: + continue + filtered_drawings_rects.append(r) + + page_area = page.rect.get_area() + img_rects = [] + for b in page.get_text("dict")["blocks"]: + if b.get("type") == 1: + ir = fitz.Rect(b["bbox"]) + if ir.get_area() < page_area * 0.01: + continue + img_rects.append(ir) + + remaining_drawings = filtered_drawings_rects + img_rects + caption_clusters = {cap['index']: [cap['rect']] for cap in captions} + + def is_text_between(r1, r2, text_blocks): + if r1.intersects(r2): + return False + union = r1 | r2 + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + text_content = b[4] + if len(text_content.strip()) < 20: + continue + if not b_rect.intersects(union): + continue + if b_rect.intersects(r1) or b_rect.intersects(r2): + continue + return True + return False + + changed = True + while changed: + changed = False + to_remove = [] + + for d_rect in remaining_drawings: + best_cluster_key = None + min_dist = float('inf') + + for cap_index, cluster_rects in caption_clusters.items(): + for r in cluster_rects: + dist = 0 + if d_rect.intersects(r): + dist = 0 + else: + x_dist = 0 + if d_rect.x1 < r.x0: x_dist = r.x0 - d_rect.x1 + elif d_rect.x0 > r.x1: x_dist = d_rect.x0 - r.x1 + + y_dist = 0 + if d_rect.y1 < r.y0: y_dist = r.y0 - d_rect.y1 + elif d_rect.y0 > r.y1: y_dist = d_rect.y0 - r.y1 + + if x_dist < 150 and y_dist < 150: + dist = max(x_dist, y_dist) + 0.1 + else: + dist = float('inf') + + if dist < min_dist: + if not is_text_between(r, d_rect, blocks): + min_dist = dist + best_cluster_key = cap_index + + if min_dist == 0: + break + + if best_cluster_key is not None and min_dist < 150: + caption_clusters[best_cluster_key].append(d_rect) + to_remove.append(d_rect) + changed = True + + for r in to_remove: + remaining_drawings.remove(r) + + figure_regions = [] + + for cap in captions: + cluster_rects = caption_clusters[cap['index']] + content_rects = cluster_rects[1:] + + if not content_rects: + continue + + union_rect = content_rects[0] + for r in content_rects[1:]: + union_rect = union_rect | r + + union_rect.x0 = max(0, union_rect.x0 - 5) + union_rect.x1 = min(page.rect.width, union_rect.x1 + 5) + union_rect.y0 = max(0, union_rect.y0 - 5) + union_rect.y1 = min(page.rect.height, union_rect.y1 + 5) + + cap_rect = cap['rect'] + + if cap_rect.y0 + cap_rect.height/2 < union_rect.y0 + union_rect.height/2: + if union_rect.y0 < cap_rect.y1: union_rect.y0 = cap_rect.y1 + 2 + else: + if union_rect.y1 > cap_rect.y0: union_rect.y1 = cap_rect.y0 - 2 + + area = union_rect.get_area() + page_area = page.rect.get_area() + + if area < page_area * 0.01: + continue + + if union_rect.height < 20 and union_rect.width > page.rect.width * 0.6: + continue + if union_rect.width < 20 and union_rect.height > page.rect.height * 0.6: + continue + + text_blocks = page.get_text("blocks") + text_count = 0 + + for b in text_blocks: + b_rect = fitz.Rect(b[:4]) + if not b_rect.intersects(union_rect): + continue + text = b[4].strip() + if len(text) < 5: + continue + text_count += 1 + + if text_count < 0: + continue + + figure_regions.append({ + 'rect': union_rect, + 'caption_index': cap['index'], + 'caption_rect': cap['rect'], + 'caption_text': cap['text'].strip() # ★ 캡션 텍슀튞 저장 + }) + + return figure_regions + + +def pixmap_metrics(pix): + arr = np.frombuffer(pix.samples, dtype=np.uint8) + c = 4 if pix.alpha else 3 + arr = arr.reshape(pix.height, pix.width, c)[:, :, :3] + gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.uint8) + white = gray > 245 + nonwhite_ratio = float(1.0 - white.mean()) + gx = np.abs(np.diff(gray.astype(np.int16), axis=1)) + gy = np.abs(np.diff(gray.astype(np.int16), axis=0)) + edge = (gx[:-1, :] + gy[:, :-1]) > 40 + edge_ratio = float(edge.mean()) + var = float(gray.var()) + return nonwhite_ratio, edge_ratio, var + + +def keep_figure(pix): + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + if nonwhite_ratio < 0.004: + return False, nonwhite_ratio, edge_ratio, var + if nonwhite_ratio < 0.012 and edge_ratio < 0.004 and var < 20: + return False, nonwhite_ratio, edge_ratio, var + return True, nonwhite_ratio, edge_ratio, var + + +# ===== 추가 읎믞지 필터 핚수듀 (v2.1) ===== + +def pix_to_pil(pix): + """PyMuPDF Pixmap을 PIL Image로 변환""" + img_data = pix.tobytes("png") + return Image.open(io.BytesIO(img_data)) + + +def has_cut_text_at_boundary(pix, margin=5): + """ + 읎믞지 겜계에서 텍슀튞가 잘렞는지 감지 + - 읎믞지 테두늬 귌처에 텍슀튞 박슀가 있윌멎 잘늰 것윌로 판당 + + Args: + pix: PyMuPDF Pixmap + margin: 겜계로부터의 여유 픜셀 (Ʞ볞 5px) + + Returns: + bool: 텍슀튞가 잘렞윌멎 True + """ + if not TESSERACT_AVAILABLE: + return False # OCR 없윌멎 필터 비활성화 + + try: + img = pix_to_pil(pix) + width, height = img.size + + # OCR로 텍슀튞 위치 추출 + data = pytesseract.image_to_data(img, lang='kor+eng', output_type=pytesseract.Output.DICT) + + for i, text in enumerate(data['text']): + text = str(text).strip() + if len(text) < 2: # 너묎 짧은 텍슀튞는 묎시 + continue + + x = data['left'][i] + y = data['top'][i] + w = data['width'][i] + h = data['height'][i] + + # 텍슀튞가 읎믞지 겜계에 너묎 가까우멎 = 잘늰 것 + # 왌쪜 겜계 + if x <= margin: + return True + # 였륞쪜 겜계 + if x + w >= width - margin: + return True + # 상닚 겜계 (헀더 제왞륌 위핎 좀 더 여유) + if y <= margin and h < height * 0.3: + return True + # 하당 겜계 + if y + h >= height - margin: + return True + + return False + + except Exception as e: + # OCR 싀팚 시 필터 통곌 (읎믞지 유지) + return False + + +def is_decorative_background(pix, edge_threshold=0.02, color_var_threshold=500): + """ + 배겜 팹턮 + 텍슀튞만 있는 장식용 읎믞지읞지 감지 + - 엣지가 적고 (복잡한 도표/사진읎 아님) + - 색상 닀양성읎 낮윌멎 (닚순 귞띌데읎션 배겜) + + Args: + pix: PyMuPDF Pixmap + edge_threshold: 엣지 비윚 임계값 (Ʞ볞 0.02 = 2%) + color_var_threshold: 색상 분산 임계값 + + Returns: + bool: 장식용 배겜읎멎 True + """ + try: + nonwhite_ratio, edge_ratio, var = pixmap_metrics(pix) + + # 엣지가 거의 없고 (닚순한 읎믞지) + # 색상 분산도 낮윌멎 (배겜 팹턮) + if edge_ratio < edge_threshold and var < color_var_threshold: + # 추가 확읞: 텍슀튞만 있는지 OCR로 첎크 + if TESSERACT_AVAILABLE: + try: + img = pix_to_pil(pix) + text = pytesseract.image_to_string(img, lang='kor+eng').strip() + + # 텍슀튞가 있고, 읎믞지가 닚순하멎 = 텍슀튞 배겜 + if len(text) > 3 and edge_ratio < 0.015: + return True + except: + pass + + return True + + return False + + except Exception: + return False + + +def is_header_footer_region(rect, page_rect, height_threshold=0.12): + """ + 헀더/푾터 영역에 있는 읎믞지읞지 감지 + - 페읎지 상닚 12% 또는 하당 12%에 위치 + - 높읎가 낮은 strip 형태 + + Args: + rect: 읎믞지 영역 (fitz.Rect) + page_rect: 페읎지 전첎 영역 (fitz.Rect) + height_threshold: 헀더/푾터 영역 비윚 (Ʞ볞 12%) + + Returns: + bool: 헀더/푾터 영역읎멎 True + """ + page_height = page_rect.height + img_height = rect.height + + # 상닚 영역 첎크 + if rect.y0 < page_height * height_threshold: + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 헀더 + if img_height < page_height * 0.15: + return True + + # 하당 영역 첎크 + if rect.y1 > page_height * (1 - height_threshold): + # 높읎가 페읎지의 15% 믞만읞 strip읎멎 푾터 + if img_height < page_height * 0.15: + return True + + return False + + +def should_filter_image(pix, rect, page_rect): + """ + 읎믞지륌 필터링핎알 하는지 종합 판당 + + Args: + pix: PyMuPDF Pixmap + rect: 읎믞지 영역 + page_rect: 페읎지 전첎 영역 + + Returns: + tuple: (필터링 여부, 필터링 사유) + """ + # 1. 헀더/푾터 영역 첎크 + if is_header_footer_region(rect, page_rect): + return True, "header_footer" + + # 2. 텍슀튞 잘늌 첎크 + if has_cut_text_at_boundary(pix): + return True, "cut_text" + + # 3. 장식용 배겜 첎크 + if is_decorative_background(pix): + return True, "decorative_background" + + return False, None + + +def extract_pdf_content(pdf_path, output_md_path, img_dir, metadata): + """ + PDF 낎용 추출 + + Args: + pdf_path: PDF 파음 겜로 + output_md_path: 출력 MD 파음 겜로 + img_dir: 읎믞지 저장 폮더 + metadata: 메타데읎터 딕셔너늬 (폮더 겜로, 파음명 등) + + Returns: + image_metadata_list: 추출된 읎믞지듀의 메타데읎터 늬슀튞 + """ + os.makedirs(img_dir, exist_ok=True) + + image_metadata_list = [] # ★ 읎믞지 메타데읎터 수집 + + doc = fitz.open(pdf_path) + total_pages = len(doc) + + with open(output_md_path, "w", encoding="utf-8") as md_file: + # ★ 메타데읎터 헀더 추가 + md_file.write(f"---\n") + md_file.write(f"source_pdf: {metadata['pdf_name']}\n") + md_file.write(f"source_folder: {metadata['relative_folder']}\n") + md_file.write(f"total_pages: {total_pages}\n") + md_file.write(f"extracted_at: {datetime.now().isoformat()}\n") + md_file.write(f"---\n\n") + md_file.write(f"# {metadata['pdf_name']}\n\n") + + for page_num, page in enumerate(doc): + md_file.write(f"\n## Page {page_num + 1}\n\n") + img_rel_dir = os.path.basename(img_dir) + + figure_regions = get_figure_rects(page) + + kept_figures = [] + for i, fig in enumerate(figure_regions): + rect = fig['rect'] + pix_preview = page.get_pixmap(clip=rect, dpi=100, colorspace=fitz.csRGB) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap(clip=rect, dpi=150, colorspace=fitz.csRGB) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_fig{len(kept_figures):02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + fig['img_path'] = os.path.join(img_rel_dir, img_name).replace("\\", "/") + fig['img_name'] = img_name + kept_figures.append(fig) + + # ★ 읎믞지 메타데읎터 수집 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "figure", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": fig.get('caption_text', ''), + "rect": { + "x0": round(rect.x0, 2), + "y0": round(rect.y0, 2), + "x1": round(rect.x1, 2), + "y1": round(rect.y1, 2) + } + }) + + figure_regions = kept_figures + + caption_present = any( + CAPTION_PATTERN.search((tb[4] or "")) for tb in page.get_text("blocks") + ) + uncaptioned_idx = 0 + + items = [] + + def inside_any_figure(block_rect, figures): + for fig in figures: + intersect = block_rect & fig["rect"] + if intersect.get_area() > 0.5 * block_rect.get_area(): + return True + return False + + def is_full_width_rect(r, page_rect): + return r.width >= page_rect.width * 0.78 + + def figure_anchor_rect(fig, page_rect): + cap = fig["caption_rect"] + rect = fig["rect"] + if cap.y0 >= rect.y0: + y = max(0.0, cap.y0 - 0.02) + else: + y = min(page_rect.height - 0.02, cap.y1 + 0.02) + return fitz.Rect(cap.x0, y, cap.x1, y + 0.02) + + for fig in figure_regions: + anchor = figure_anchor_rect(fig, page.rect) + md = ( + f"\n![{fig.get('caption_text', 'Figure')}]({fig['img_path']})\n" + f"*{fig.get('caption_text', '')}*\n\n" + ) + items.append({ + "kind": "figure", + "rect": anchor, + "kind_order": 0, + "md": md, + }) + + raw_blocks = page.get_text("dict")["blocks"] + + for block in raw_blocks: + block_rect = fitz.Rect(block["bbox"]) + + if block.get("type") == 0: + if inside_any_figure(block_rect, figure_regions): + continue + items.append({ + "kind": "text", + "rect": block_rect, + "kind_order": 2, + "block": block, + }) + continue + + if block.get("type") == 1: + if inside_any_figure(block_rect, figure_regions): + continue + if caption_present: + continue + + page_area = page.rect.get_area() + if block_rect.get_area() < page_area * 0.005: + continue + + ratio = block_rect.width / max(1.0, block_rect.height) + if ratio < 0.25 or ratio > 4.0: + continue + + pix_preview = page.get_pixmap( + clip=block_rect, dpi=80, colorspace=fitz.csRGB + ) + ok, nonwhite_ratio, edge_ratio, var = keep_figure(pix_preview) + if not ok: + continue + + pix = page.get_pixmap( + clip=block_rect, dpi=150, colorspace=fitz.csRGB + ) + + # ★ 추가 필터 적용 (v2.1) + should_filter, filter_reason = should_filter_image(pix, block_rect, page.rect) + if should_filter: + continue + + img_name = f"p{page_num + 1:03d}_photo{uncaptioned_idx:02d}.png" + img_path = os.path.join(img_dir, img_name) + pix.save(img_path) + + rel = os.path.join(img_rel_dir, img_name).replace("\\", "/") + r = block_rect + md = ( + f'\n![Photo]({rel})\n' + f'*Page {page_num + 1} Photo*\n\n' + ) + + items.append({ + "kind": "raster", + "rect": block_rect, + "kind_order": 1, + "md": md, + }) + + # ★ 캡션 없는 읎믞지 메타데읎터 + image_metadata_list.append({ + "image_file": img_name, + "image_path": str(Path(img_dir) / img_name), + "type": "photo", + "source_pdf": metadata['pdf_name'], + "source_folder": metadata['relative_folder'], + "full_path": metadata['full_path'], + "page": page_num + 1, + "total_pages": total_pages, + "caption": "", + "rect": { + "x0": round(r.x0, 2), + "y0": round(r.y0, 2), + "x1": round(r.x1, 2), + "y1": round(r.y1, 2) + } + }) + + uncaptioned_idx += 1 + continue + + # 읜Ʞ 순서 정렬 + text_items = [it for it in items if it["kind"] == "text"] + page_w = page.rect.width + mid = page_w / 2.0 + + candidates = [] + for it in text_items: + r = it["rect"] + if is_full_width_rect(r, page.rect): + continue + if r.width < page_w * 0.2: + continue + candidates.append(it) + + left = [it for it in candidates if it["rect"].x0 < mid * 0.95] + right = [it for it in candidates if it["rect"].x0 > mid * 1.05] + two_cols = len(left) >= 3 and len(right) >= 3 + + col_y0 = None + col_y1 = None + seps = [] + + if two_cols and left and right: + col_y0 = min( + min(it["rect"].y0 for it in left), + min(it["rect"].y0 for it in right), + ) + col_y1 = max( + max(it["rect"].y1 for it in left), + max(it["rect"].y1 for it in right), + ) + for it in text_items: + r = it["rect"] + if col_y0 < r.y0 < col_y1 and is_full_width_rect(r, page.rect): + seps.append(r.y0) + seps = sorted(set(seps)) + + def seg_index(y0, separators): + if not separators: + return 0 + n = 0 + for s in separators: + if y0 >= s: + n += 1 + else: + break + return n + + def order_key(it): + r = it["rect"] + if not two_cols: + return (r.y0, r.x0, it["kind_order"]) + if col_y0 is not None and r.y1 <= col_y0: + return (0, r.y0, r.x0, it["kind_order"]) + if col_y1 is not None and r.y0 >= col_y1: + return (2, r.y0, r.x0, it["kind_order"]) + seg = seg_index(r.y0, seps) + if is_full_width_rect(r, page.rect): + col = 2 + else: + col = 0 if r.x0 < mid else 1 + return (1, seg, col, r.y0, r.x0, it["kind_order"]) + + items.sort(key=order_key) + + for it in items: + if it["kind"] in ("figure", "raster"): + md_file.write(it["md"]) + continue + + block = it["block"] + for line in block.get("lines", []): + for span in line.get("spans", []): + md_file.write(span.get("text", "") + " ") + md_file.write("\n") + md_file.write("\n") + + doc.close() + return image_metadata_list + + +def process_all_pdfs(): + """ + BASE_DIR 하위의 몚든 PDF륌 재귀적윌로 처늬 + 폮더 구조륌 유지하멎서 OUTPUT_BASE에 저장 + """ + # 출력 폮더 생성 + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # 전첎 읎믞지 메타데읎터 수집 + all_image_metadata = [] + + # 처늬 통계 + stats = { + "total_pdfs": 0, + "success": 0, + "failed": 0, + "total_images": 0 + } + + # 싀팚 로귞 + failed_files = [] + + print(f"=" * 60) + print(f"PDF 추출 시작") + print(f"원볞 폮더: {BASE_DIR}") + print(f"출력 폮더: {OUTPUT_BASE}") + print(f"=" * 60) + + # 몚든 PDF 파음 ì°Ÿêž° + pdf_files = list(BASE_DIR.rglob("*.pdf")) + stats["total_pdfs"] = len(pdf_files) + + print(f"\n쎝 {len(pdf_files)}개 PDF 발견\n") + + for idx, pdf_path in enumerate(pdf_files, 1): + try: + # 상대 겜로 계산 + relative_path = pdf_path.relative_to(BASE_DIR) + relative_folder = str(relative_path.parent) + if relative_folder == ".": + relative_folder = "" + + pdf_name = pdf_path.name + pdf_stem = pdf_path.stem + + # 출력 겜로 섀정 (폮더 구조 유지) + output_folder = OUTPUT_BASE / relative_path.parent + output_folder.mkdir(parents=True, exist_ok=True) + + output_md = output_folder / f"{pdf_stem}.md" + img_folder = output_folder / f"{pdf_stem}_img" + + # 메타데읎터 쀀비 + metadata = { + "pdf_name": pdf_name, + "pdf_stem": pdf_stem, + "relative_folder": relative_folder, + "full_path": str(relative_path), + } + + print(f"[{idx}/{len(pdf_files)}] {relative_path}") + + # PDF 처늬 + image_metas = extract_pdf_content( + str(pdf_path), + str(output_md), + str(img_folder), + metadata + ) + + all_image_metadata.extend(image_metas) + stats["success"] += 1 + stats["total_images"] += len(image_metas) + + print(f" ✓ 완료 (읎믞지 {len(image_metas)}개)") + + except Exception as e: + stats["failed"] += 1 + failed_files.append({ + "file": str(pdf_path), + "error": str(e) + }) + print(f" ✗ 싀팚: {e}") + + # 전첎 읎믞지 메타데읎터 저장 + meta_output_path = OUTPUT_BASE / "image_metadata.json" + with open(meta_output_path, "w", encoding="utf-8") as f: + json.dump(all_image_metadata, f, ensure_ascii=False, indent=2) + + # 처늬 요앜 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "source_dir": str(BASE_DIR), + "output_dir": str(OUTPUT_BASE), + "statistics": stats, + "failed_files": failed_files + } + + summary_path = OUTPUT_BASE / "extraction_summary.json" + with open(summary_path, "w", encoding="utf-8") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + + # 결곌 출력 + print(f"\n" + "=" * 60) + print(f"추출 완료!") + print(f"=" * 60) + print(f"쎝 PDF: {stats['total_pdfs']}개") + print(f"성공: {stats['success']}개") + print(f"싀팚: {stats['failed']}개") + print(f"추출된 읎믞지: {stats['total_images']}개") + print(f"\n읎믞지 메타데읎터: {meta_output_path}") + print(f"처늬 요앜: {summary_path}") + + if failed_files: + print(f"\n싀팚한 파음:") + for f in failed_files: + print(f" - {f['file']}: {f['error']}") + + +if __name__ == "__main__": + process_all_pdfs() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step3_domain.py b/03. Code/geulbeot_9th/converters/pipeline/step3_domain.py new file mode 100644 index 0000000..29a5547 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step3_domain.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +""" +domain_prompt.py + +Ʞ능: +- D:\\test\\report 아래의 pdf/xlsx/png/txt/md 파음듀의 + 파음명곌 낎용 음부륌 샘플링한닀. +- 읎 샘플을 Ʞ반윌로, 묞서 묶음의 분알/업묎 맥띜을 파악하고 + "너는 ~~ 분알의 전묞가읎닀. 나는 ~~륌 하고 ì‹¶ë‹€..." 형식의 + 도메읞 전용 시슀템 프롬프튞륌 자동 생성한닀. +- 결곌는 output/context/domain_prompt.txt 로 저장된닀. + +읎 domain_prompt.txt 낎용은 읎후 몚든 GPT 혞출(system role)에 공통윌로 붙여 사용할 수 있닀. +""" + +import os +import sys +import json +from pathlib import Path + +import pdfplumber +import fitz # PyMuPDF +from PIL import Image +import pytesseract +import pandas as pd +from openai import OpenAI +import pytesseract +from api_config import API_KEYS +pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조만 유지, 킀는 마슀터가 직접 입력) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== OCR 섀정 ===== +OCR_LANG = "kor+eng" + +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__"} + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "domain_prompt_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def safe_rel(p: Path) -> str: + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def ocr_image(img_path: Path) -> str: + try: + return pytesseract.image_to_string(Image.open(img_path), lang=OCR_LANG).strip() + except Exception as e: + log(f"[WARN] OCR 싀팚: {safe_rel(img_path)} | {e}") + return "" + + +def sample_from_pdf(p: Path, max_chars: int = 1000) -> str: + texts = [] + try: + with pdfplumber.open(str(p)) as pdf: + # 앞쪜 몇 페읎지만 샘플링 + for page in pdf.pages[:3]: + t = page.extract_text() or "" + if t: + texts.append(t) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] PDF 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_xlsx(p: Path, max_chars: int = 1000) -> str: + texts = [f"[파음명] {p.name}"] + try: + xls = pd.ExcelFile(str(p)) + for sheet_name in xls.sheet_names[:3]: + try: + df = xls.parse(sheet_name) + except Exception as e: + log(f"[WARN] 시튞 로딩 싀팚: {safe_rel(p)} | {sheet_name} | {e}") + continue + texts.append(f"\n[시튞] {sheet_name}") + texts.append("컬럌: " + ", ".join(map(str, df.columns))) + head = df.head(5) + texts.append(head.to_string(index=False)) + if sum(len(x) for x in texts) >= max_chars: + break + except Exception as e: + log(f"[WARN] XLSX 샘플 추출 싀팚: {safe_rel(p)} | {e}") + joined = "\n".join(texts) + return joined[:max_chars] + + +def sample_from_text_file(p: Path, max_chars: int = 1000) -> str: + try: + t = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + t = p.read_text(encoding="cp949", errors="ignore") + return t[:max_chars] + + +def gather_file_samples( + max_files_per_type: int = 100, + max_total_samples: int = 300, + max_chars_per_sample: int = 1000, +): + + file_names = [] + samples = [] + + count_pdf = 0 + count_xlsx = 0 + count_img = 0 + count_txt = 0 + + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + cur_dir = Path(root) + + for fname in files: + fpath = cur_dir / fname + ext = fpath.suffix.lower() + + # 파음명은 전첎 ë‹€ 몚윌되, 샘플 추출은 제한 + file_names.append(safe_rel(fpath)) + + if len(samples) >= max_total_samples: + continue + + try: + if ext == ".pdf" and count_pdf < max_files_per_type: + s = sample_from_pdf(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[PDF] {safe_rel(fpath)}\n{s}") + count_pdf += 1 + continue + + if ext in {".xlsx", ".xls"} and count_xlsx < max_files_per_type: + s = sample_from_xlsx(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[XLSX] {safe_rel(fpath)}\n{s}") + count_xlsx += 1 + continue + + if ext in {".png", ".jpg", ".jpeg"} and count_img < max_files_per_type: + s = ocr_image(fpath) + if s.strip(): + samples.append(f"[IMG] {safe_rel(fpath)}\n{s[:max_chars_per_sample]}") + count_img += 1 + continue + + if ext in {".txt", ".md"} and count_txt < max_files_per_type: + s = sample_from_text_file(fpath, max_chars=max_chars_per_sample) + if s.strip(): + samples.append(f"[TEXT] {safe_rel(fpath)}\n{s}") + count_txt += 1 + continue + + except Exception as e: + log(f"[WARN] 샘플 추출 싀팚: {safe_rel(fpath)} | {e}") + continue + + return file_names, samples + + +def build_domain_prompt(): + """ + 파음명 + 낎용 샘플을 GPT에게 넘겚 + '너는 ~~ 분알의 전묞가읎닀...' 형태의 시슀템 프롬프튞륌 생성한닀. + """ + log("도메읞 프롬프튞 생성을 위한 샘플 수집 쀑...") + file_names, samples = gather_file_samples() + + if not file_names and not samples: + log("파음 샘플읎 없얎 도메읞 프롬프튞륌 생성할 수 없습니닀.") + sys.exit(1) + + file_names_text = "\n".join(file_names[:80]) + sample_text = "\n\n".join(samples[:30]) + + prompt = f""" +닀음은 한 Ʞ업의 '읎슈 늬포튞 및 시슀템 ꎀ렚 자료'로 추정되는 파음듀의 목록곌, +각 파음에서 음부 추출한 낎용 샘플읎닀. + +[파음명 목록] +{file_names_text} + +[낎용 샘플] +{sample_text} + +위 자료륌 바탕윌로 닀음을 수행하띌. + +1) 읎 묞서 묶음읎 ì–Žë–€ 산업, 업묎, 분알에 대한 것읞지, + 핵심 킀워드륌 포핚핎 2~3쀄 정도로 섀명하띌. + +2) 읎후, 읎 묞서듀을 닀룚는 AI에게 사용할 "프롬프튞 뚞늬말"을 작성하띌. + 읎 뚞늬말은 몚든 후속 프롬프튞 앞에 항상 붙음 예정읎며, + 닀음 조걎을 만족핎알 한닀. + + - 첫 묞닚: "너는 ~~ 분알의 전묞가읎닀." 형식윌로, 읎 묞서 묶음의 분알와 역할을 정의한닀. + - 두 번짞 묞닚 읎후: "나는 ~~을 하고 ì‹¶ë‹€.", "우늬는 ~~ 의 묞제륌 분석하고 개선방안을 찟고자 한닀." 등 + 사용자가 AI에게 요구하는 전반적 목적곌 ꎀ점을 정늬한닀. + - 쎝 5~7쀄 정도의 한국얎 묞장윌로 작성한닀. + - 읎후에 붙을 프롬프튞(청킹, 요앜, RAG, 볎고서 작성 등)와 자연슀럜게 연결될 수 있도록, + 역할(role), 목적, Ʞ쀀(추잡 ꞈ지, 사싀 êž°ë°˜, 귌거 명시 등)을 몚두 포핚한닀. + +출력 형식: +- 섀명곌 뚞늬말을 한 번에 출력하되, + 별도의 마크닀욎 없읎 순수 텍슀튞로만 작성하띌. +- 읎 출력 전첎륌 domain_prompt.txt에 귞대로 저장할 것읎닀. +""" + + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + { + "role": "system", + "content": "너는 묞서 묶음의 분알륌 식별하고, 귞에 맞는 AI 시슀템 프롬프튞와 컚텍슀튞륌 섀계하는 컚섀턎튞읎닀." + }, + { + "role": "user", + "content": prompt + } + ], + ) + + content = (resp.choices[0].message.content or "").strip() + out_path = CONTEXT_DIR / "domain_prompt.txt" + out_path.write_text(content, encoding="utf-8") + + log(f"도메읞 프롬프튞 생성 완료: {out_path}") + return content + + +def main(): + log("=== 도메읞 프롬프튞 생성 시작 ===") + out_path = CONTEXT_DIR / "domain_prompt.txt" + if out_path.exists(): + log(f"읎믞 domain_prompt.txt가 졎재합니닀: {out_path}") + log("Ʞ졎 파음을 사용하렀멎 종료하고, 재생성읎 필요하멎 파음을 삭제한 ë’€ 닀시 싀행하십시였.") + else: + build_domain_prompt() + log("=== 도메읞 프롬프튞 작업 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_9th/converters/pipeline/step4_chunk.py b/03. Code/geulbeot_9th/converters/pipeline/step4_chunk.py new file mode 100644 index 0000000..b1309cf --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step4_chunk.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +chunk_and_summary_v2.py + +Ʞ능: +- 정늬쀑 폮더 아래의 .md 파음듀을 대상윌로 + 1) domain_prompt.txt êž°ë°˜ GPT 의믞 청킹 + 2) 청크별 요앜 생성 + 3) 청크 낮 읎믞지 ì°žì¡° 볎졎 + 4) JSON 저장 (원묞+청크+요앜+읎믞지) + 5) RAG용 *_chunks.json 저장 + +전제: +- extract_1_v2.py 싀행 후 .md 파음듀읎 졎재할 것 +- step1_domainprompt.py 싀행 후 domain_prompt.txt가 졎재할 것 +""" + +import os +import sys +import json +import re +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 + +TEXT_DIR = OUTPUT_ROOT / "text" +JSON_DIR = OUTPUT_ROOT / "json" +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [TEXT_DIR, JSON_DIR, RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 슀킵할 폮더 ===== +SKIP_DIR_NAMES = {"System Volume Information", "$RECYCLE.BIN", ".git", "__pycache__", "output"} + +# ===== 읎믞지 ì°žì¡° 팹턮 ===== +IMAGE_PATTERN = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "chunk_and_summary_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log(f"domain_prompt.txt가 없습니닀: {p}") + log("뚌저 step1_domainprompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def safe_rel(p: Path) -> str: + """DATA_ROOT Ʞ쀀 상대 겜로 반환""" + try: + return str(p.relative_to(DATA_ROOT)) + except Exception: + return str(p) + + +def extract_text_md(p: Path) -> str: + """마크닀욎 파음 텍슀튞 읜Ʞ""" + try: + return p.read_text(encoding="utf-8", errors="ignore") + except Exception: + return p.read_text(encoding="cp949", errors="ignore") + + +def find_images_in_text(text: str) -> list: + """텍슀튞에서 읎믞지 ì°žì¡° ì°Ÿêž°""" + matches = IMAGE_PATTERN.findall(text) + return [{"alt": m[0], "path": m[1]} for m in matches] + + +def semantic_chunk(domain_prompt: str, text: str, source_name: str): + """GPT êž°ë°˜ 의믞 청킹""" + if not text.strip(): + return [] + + # 텍슀튞가 너묎 짧윌멎 귞냥 하나의 청크로 + if len(text) < 500: + return [{ + "title": "전첎 낎용", + "keywords": "", + "content": text + }] + + user_prompt = f""" +아래 묞서륌 의믞 닚위(묞닚/항목/섹션 등)로 분늬하고, +각 청크는 title / keywords / content 륌 포핚한 JSON 배엎로 출력하띌. + +규칙: +1. 추잡 ꞈ지, 묞서 낎용 Ʞ반윌로만 분늬 +2. 읎믞지 ì°žì¡°(![...](...))는 ꎀ렚 텍슀튞와 같은 청크에 포핚 +3. 각 청크는 최소 100자 읎상 +4. keywords는 쉌표로 구분된 핵심 킀워드 3~5개 + +묞서: +{text[:12000]} + +JSON ë°°ì—Žë§Œ 출력하띌. 닀륞 섀명 없읎. +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 의믞 êž°ë°˜ 청킹 전묞가읎닀. JSON ë°°ì—Žë§Œ 출력한닀."}, + {"role": "user", "content": user_prompt}, + ], + ) + data = resp.choices[0].message.content.strip() + + # JSON 파싱 시도 + # ```json ... ``` 형식 처늬 + if "```json" in data: + data = data.split("```json")[1].split("```")[0].strip() + elif "```" in data: + data = data.split("```")[1].split("```")[0].strip() + + if data.startswith("["): + return json.loads(data) + + except json.JSONDecodeError as e: + log(f"[WARN] JSON 파싱 싀팚 ({source_name}): {e}") + except Exception as e: + log(f"[WARN] semantic_chunk API 싀팚 ({source_name}): {e}") + + # fallback: 페읎지/섹션 êž°ë°˜ 분늬 + log(f"[INFO] Fallback 청킹 적용: {source_name}") + return fallback_chunk(text) + + +def fallback_chunk(text: str) -> list: + """GPT 싀팚 시 대첎 청킹 (페읎지/섹션 êž°ë°˜)""" + chunks = [] + + # 페읎지 구분자로 분늬 시도 + if "## Page " in text: + pages = re.split(r'\n## Page \d+\n', text) + for i, page_content in enumerate(pages): + if page_content.strip(): + chunks.append({ + "title": f"Page {i+1}", + "keywords": "", + "content": page_content.strip() + }) + else: + # 빈 쀄 2개 읎상윌로 분늬 + sections = re.split(r'\n{3,}', text) + for i, section in enumerate(sections): + if section.strip() and len(section.strip()) > 50: + chunks.append({ + "title": f"섹션 {i+1}", + "keywords": "", + "content": section.strip() + }) + + # 청크가 없윌멎 전첎륌 하나로 + if not chunks: + chunks.append({ + "title": "전첎 낎용", + "keywords": "", + "content": text.strip() + }) + + return chunks + + +def summary_chunk(domain_prompt: str, text: str, limit: int = 300) -> str: + """청크 요앜 생성""" + if not text.strip(): + return "" + + # 읎믞지 ì°žì¡° 제거 후 요앜 (텍슀튞만) + text_only = IMAGE_PATTERN.sub('', text).strip() + + if len(text_only) < 100: + return text_only + + prompt = f""" +아래 텍슀튞륌 {limit}자 읎낎로 사싀 Ʞ반윌로 요앜하띌. +추잡 ꞈ지, 고유명사와 수치는 볎졎. + +{text_only[:8000]} +""" + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 사싀만 요앜하는 전묞가읎닀."}, + {"role": "user", "content": prompt}, + ], + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[WARN] summary 싀팚: {e}") + return text_only[:limit] + + +def save_chunk_files(src: Path, text: str, domain_prompt: str) -> int: + """ + 의믞 청킹 → 요앜 → JSON 저장 + + Returns: + 생성된 청크 수 + """ + stem = src.stem + folder_ctx = safe_rel(src.parent) + + # 원묞 저장 + (TEXT_DIR / f"{stem}_text.txt").write_text(text, encoding="utf-8", errors="ignore") + + # 의믞 청킹 + chunks = semantic_chunk(domain_prompt, text, src.name) + + if not chunks: + log(f"[WARN] 청크 없음: {src.name}") + return 0 + + rag_items = [] + + for idx, ch in enumerate(chunks, start=1): + content = ch.get("content", "") + + # 요앜 생성 + summ = summary_chunk(domain_prompt, content, 300) + + # 읎 청크에 포핚된 읎믞지 ì°Ÿêž° + images_in_chunk = find_images_in_text(content) + + rag_items.append({ + "source": src.name, + "source_path": safe_rel(src), + "chunk": idx, + "total_chunks": len(chunks), + "title": ch.get("title", ""), + "keywords": ch.get("keywords", ""), + "text": content, + "summary": summ, + "folder_context": folder_ctx, + "images": images_in_chunk, + "has_images": len(images_in_chunk) > 0 + }) + + # JSON 저장 + (JSON_DIR / f"{stem}.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # RAG용 JSON 저장 + (RAG_DIR / f"{stem}_chunks.json").write_text( + json.dumps(rag_items, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + return len(chunks) + + +def main(): + log("=" * 60) + log("청킹/요앜 파읎프띌읞 시작") + log(f"데읎터 폮더: {DATA_ROOT}") + log(f"출력 폮더: {OUTPUT_ROOT}") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 통계 + stats = {"docs": 0, "chunks": 0, "images": 0, "errors": 0} + + # .md 파음 ì°Ÿêž° + md_files = [] + for root, dirs, files in os.walk(DATA_ROOT): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES and not d.startswith(".")] + for fname in files: + if fname.lower().endswith(".md"): + md_files.append(Path(root) / fname) + + log(f"\n쎝 {len(md_files)}개 .md 파음 발견\n") + + for idx, fpath in enumerate(md_files, 1): + try: + rel_path = safe_rel(fpath) + log(f"[{idx}/{len(md_files)}] {rel_path}") + + # 텍슀튞 읜Ʞ + text = extract_text_md(fpath) + + if not text.strip(): + log(f" ⚠ 빈 파음, 슀킵") + continue + + # 읎믞지 개수 확읞 + images = find_images_in_text(text) + stats["images"] += len(images) + + # 청킹 및 저장 + chunk_count = save_chunk_files(fpath, text, domain_prompt) + + stats["docs"] += 1 + stats["chunks"] += chunk_count + + log(f" ✓ {chunk_count}개 청크, {len(images)}개 읎믞지") + + except Exception as e: + stats["errors"] += 1 + log(f" ✗ 였류: {e}") + + # 전첎 통계 저장 + summary = { + "processed_at": datetime.now().isoformat(), + "data_root": str(DATA_ROOT), + "output_root": str(OUTPUT_ROOT), + "statistics": stats + } + + (LOG_DIR / "chunk_summary_stats.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + # 결곌 출력 + log("\n" + "=" * 60) + log("청킹/요앜 완료!") + log("=" * 60) + log(f"처늬된 묞서: {stats['docs']}개") + log(f"생성된 청크: {stats['chunks']}개") + log(f"포핚된 읎믞지: {stats['images']}개") + log(f"였류: {stats['errors']}개") + log(f"\n결곌 저장 위치:") + log(f" - 원묞: {TEXT_DIR}") + log(f" - JSON: {JSON_DIR}") + log(f" - RAG: {RAG_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step5_rag.py b/03. Code/geulbeot_9th/converters/pipeline/step5_rag.py new file mode 100644 index 0000000..0525082 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step5_rag.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +build_rag.py + +Ʞ능: +- chunk_and_summary.py 에서 생성된 output/rag/*_chunks.json 파음듀을 읜얎서 + text + summary 륌 임베딩(text-embedding-3-small)한닀. +- FAISS IndexFlatIP 읞덱슀륌 구축하여 + output/rag/faiss.index, meta.json, vectors.npy 륌 생성한닀. +""" + +import os +import sys +import json +from pathlib import Path + +import numpy as np +import faiss +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" +EMBED_MODEL = "text-embedding-3-small" + +client = OpenAI(api_key=OPENAI_API_KEY) + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "build_rag_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + + +def embed_texts(texts): + if not texts: + return np.zeros((0, 1536), dtype="float32") + embs = [] + B = 96 + for i in range(0, len(texts), B): + batch = texts[i:i+B] + resp = client.embeddings.create(model=EMBED_MODEL, input=batch) + for d in resp.data: + embs.append(np.array(d.embedding, dtype="float32")) + return np.vstack(embs) + + +def _build_embed_input(u: dict) -> str: + """ + text + summary 륌 합쳐 임베딩 입력을 만든닀. + - text, summary 쀑 없는 것은 생략 + - 공백 정늬 + - 최대 Ꞟ읎 제한 + """ + sum_ = (u.get("summary") or "").strip() + txt = (u.get("text") or "").strip() + + if txt and sum_: + merged = txt + "\n\n요앜: " + sum_[:1000] + else: + merged = txt or sum_ + + merged = " ".join(merged.split()) + if not merged: + return "" + if len(merged) > 4000: + merged = merged[:4000] + return merged + + +def build_faiss_index(): + docs = [] + metas = [] + + rag_files = list(RAG_DIR.glob("*_chunks.json")) + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + embed_input = _build_embed_input(u) + if not embed_input: + continue + if len(embed_input) < 40: + continue + docs.append(embed_input) + metas.append({ + "source": u.get("source", ""), + "chunk": int(u.get("chunk", 0)), + "folder_context": u.get("folder_context", "") + }) + + if not docs: + log("임베딩할 텍슀튞가 없습니닀.") + sys.exit(1) + + log(f"임베딩 대상 텍슀튞 수: {len(docs)}") + + E = embed_texts(docs) + if E.shape[0] != len(docs): + log(f"[WARN] 임베딩 수 불음치: E={E.shape[0]}, docs={len(docs)}") + + faiss.normalize_L2(E) + index = faiss.IndexFlatIP(E.shape[1]) + index.add(E) + + np.save(str(RAG_DIR / "vectors.npy"), E) + (RAG_DIR / "meta.json").write_text( + json.dumps(metas, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + faiss.write_index(index, str(RAG_DIR / "faiss.index")) + + log(f"FAISS 읞덱슀 구축 완료: 벡터 수={len(metas)}") + + +def main(): + log("=== FAISS RAG 읞덱슀 구축 시작 ===") + build_faiss_index() + log("=== FAISS RAG 읞덱슀 구축 종료 ===") + + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_9th/converters/pipeline/step6_corpus.py b/03. Code/geulbeot_9th/converters/pipeline/step6_corpus.py new file mode 100644 index 0000000..4a3cb3e --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step6_corpus.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +""" +make_corpus_v2.py + +Ʞ능: +- output/rag/*_chunks.json 에서 몚든 청크의 summary륌 몚아 +- AI가 CEL 목적(교육+자사솔룚션 홍볎)에 맞게 압축 정늬 +- 쀑복은 빈도 표시, 희귀하지만 쀑요한 걎 [핵심] 표시 +- 결곌륌 output/context/corpus.txt 로 저장 + +전제: +- chunk_and_summary.py 싀행 후 *_chunks.json 듀읎 졎재핎알 한닀. +- domain_prompt.txt가 졎재핎알 한닀. +""" + +import os +import sys +import json +from pathlib import Path +from datetime import datetime + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +RAG_DIR = OUTPUT_ROOT / "rag" +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [RAG_DIR, CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 압축 섀정 ===== +BATCH_SIZE = 80 # 한 번에 처늬할 요앜 개수 +MAX_CHARS_PER_BATCH = 3000 # 배치당 압축 결곌 Ꞁ자수 +MAX_FINAL_CHARS = 8000 # 최종 corpus Ꞁ자수 + + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_corpus_log.txt").open("a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") + + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 step1을 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_all_summaries() -> list: + """몚든 청크의 summary + 출처 정볎 수집""" + summaries = [] + rag_files = sorted(RAG_DIR.glob("*_chunks.json")) + + if not rag_files: + log("RAG 파음(*_chunks.json)읎 없습니닀. 뚌저 chunk_and_summary.py륌 싀행핎알 합니닀.") + sys.exit(1) + + for f in rag_files: + try: + units = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + except Exception as e: + log(f"[WARN] RAG 파음 읜Ʞ 싀팚: {f.name} | {e}") + continue + + for u in units: + summ = (u.get("summary") or "").strip() + source = (u.get("source") or "").strip() + keywords = (u.get("keywords") or "") + + if summ: + # 출처와 킀워드 포핚 + entry = f"[{source}] {summ}" + if keywords: + entry += f" (킀워드: {keywords})" + summaries.append(entry) + + return summaries + + +def compress_batch(domain_prompt: str, batch: list, batch_num: int, total_batches: int) -> str: + """배치 닚위로 요앜듀을 AI가 압축""" + + batch_text = "\n".join([f"{i+1}. {s}" for i, s in enumerate(batch)]) + + prompt = f""" +아래는 묞서에서 추출한 요앜 {len(batch)}개읎닀. (배치 {batch_num}/{total_batches}) + +[요앜 목록] +{batch_text} + +닀음 Ʞ쀀윌로 읎 요앜듀을 압축 정늬하띌: + +1) 쀑복/유사 낎용: 하나로 통합하되, 여러 묞서에서 얞꞉되멎 "(N회 얞꞉)" 표시 +2) domain_prompt에 명시된 핵심 솔룚션/시슀템: 반드시 볎졎하고 [솔룚션] 표시 +3) domain_prompt의 목적에 쀑요한 낎용 우선 볎졎: + - 핎당 분알의 Ʞ쎈 개념 + - Ʞ졎 방식의 한계점곌 묞제점 + - 새로욎 Ʞ술/방식의 장점 +4) 닚순 나엎/절찚만 있는 낎용: 곌감히 축앜 +5) 희귀하지만 핵심적읞 읞사읎튞: [핵심] 표시 + +출력 형식: +- 죌제별로 귞룹핑 +- 각 항목은 1~2묞장윌로 간결하게 +- 전첎 {MAX_CHARS_PER_BATCH}자 읎낎 +- 마크닀욎 없읎 순수 텍슀튞로 +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 묞서 요앜을 죌제별로 압축 정늬하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + result = resp.choices[0].message.content.strip() + log(f" 배치 {batch_num}/{total_batches} 압축 완료 ({len(result)}자)") + return result + except Exception as e: + log(f"[ERROR] 배치 {batch_num} 압축 싀팚: {e}") + # 싀팚 시 원볞 음부 반환 + return "\n".join(batch[:10]) + + +def merge_compressed_parts(domain_prompt: str, parts: list) -> str: + """배치별 압축 결곌륌 최종 통합""" + + if len(parts) == 1: + return parts[0] + + all_parts = "\n\n---\n\n".join([f"[파튾 {i+1}]\n{p}" for i, p in enumerate(parts)]) + + prompt = f""" +아래는 대량의 묞서 요앜을 배치별로 압축한 결곌읎닀. +읎것을 최종 corpus로 통합하띌. + +[배치별 압축 결곌] +{all_parts} + +통합 Ʞ쀀: +1) 파튾 간 쀑복 낎용 제거 및 통합 +2) domain_prompt에 명시된 목적곌 흐늄에 맞게 재구성 +3) [솔룚션], [핵심], (N회 얞꞉) 표시는 유지 +4) 전첎 {MAX_FINAL_CHARS}자 읎낎 + +출력: 죌제별로 정늬된 최종 corpus (마크닀욎 없읎) +""" + + try: + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[ + {"role": "system", "content": domain_prompt + "\n\n너는 CEL 교육 윘텐잠 Ʞ획을 위한 corpus륌 섀계하는 전묞가읎닀."}, + {"role": "user", "content": prompt} + ] + ) + return resp.choices[0].message.content.strip() + except Exception as e: + log(f"[ERROR] 최종 통합 싀팚: {e}") + return "\n\n".join(parts) + + +def main(): + log("=" * 60) + log("corpus 생성 시작 (AI 압축 버전)") + log("=" * 60) + + # 도메읞 프롬프튞 로드 + domain_prompt = load_domain_prompt() + log(f"도메읞 프롬프튞 로드 완료 ({len(domain_prompt)}자)") + + # 몚든 요앜 수집 + summaries = load_all_summaries() + if not summaries: + log("summary가 없습니닀. corpus륌 생성할 수 없습니닀.") + sys.exit(1) + + log(f"원볞 요앜 수집 완료: {len(summaries)}개") + + # 원볞 저장 (백업) + raw_corpus = "\n".join(summaries) + raw_path = CONTEXT_DIR / "corpus_raw.txt" + raw_path.write_text(raw_corpus, encoding="utf-8") + log(f"원볞 corpus 백업: {raw_path} ({len(raw_corpus)}자)") + + # 배치별 압축 + total_batches = (len(summaries) + BATCH_SIZE - 1) // BATCH_SIZE + log(f"\n배치 압축 시작 ({BATCH_SIZE}개씩, 쎝 {total_batches}배치)") + + compressed_parts = [] + for i in range(0, len(summaries), BATCH_SIZE): + batch = summaries[i:i+BATCH_SIZE] + batch_num = (i // BATCH_SIZE) + 1 + + compressed = compress_batch(domain_prompt, batch, batch_num, total_batches) + compressed_parts.append(compressed) + + # 최종 통합 + log(f"\n최종 통합 시작 ({len(compressed_parts)}개 파튾)") + final_corpus = merge_compressed_parts(domain_prompt, compressed_parts) + + # 저장 + out_path = CONTEXT_DIR / "corpus.txt" + out_path.write_text(final_corpus, encoding="utf-8") + + # 통계 + log("\n" + "=" * 60) + log("corpus 생성 완료!") + log("=" * 60) + log(f"원볞 요앜: {len(summaries)}개 ({len(raw_corpus)}자)") + log(f"압축 corpus: {len(final_corpus)}자") + log(f"압축률: {100 - (len(final_corpus) / len(raw_corpus) * 100):.1f}%") + log(f"\n저장 위치:") + log(f" - 원볞: {raw_path}") + log(f" - 압축: {out_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step7_index.py b/03. Code/geulbeot_9th/converters/pipeline/step7_index.py new file mode 100644 index 0000000..4f40baf --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step7_index.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +""" +make_outline.py + +Ʞ능: +- output_context/context/domain_prompt.txt +- output_context/context/corpus.txt +을 Ʞ반윌로 목찚륌 생성하고, + +1) outline_issue_report.txt 저장 +2) outline_issue_report.html 저장 (테슀튞.html 레읎아웃 êž°ë°˜ 표 형태) +""" + +import os +import sys +import re +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple + +from openai import OpenAI +from api_config import API_KEYS + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" + +for d in [CONTEXT_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# ===== OpenAI 섀정 (구조 유지) ===== +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +GPT_MODEL = "gpt-5-2025-08-07" + +client = OpenAI(api_key=OPENAI_API_KEY) + +# ===== 목찚 파싱용 정규식 볎완 (5분할 대응) ===== +RE_KEYWORDS = re.compile(r"(#\S+)") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") + +def log(msg: str): + print(msg, flush=True) + with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f: + f.write(msg + "\n") + +def load_domain_prompt() -> str: + p = CONTEXT_DIR / "domain_prompt.txt" + if not p.exists(): + log("domain_prompt.txt가 없습니닀. 뚌저 domain_prompt.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + +def load_corpus() -> str: + p = CONTEXT_DIR / "corpus.txt" + if not p.exists(): + log("corpus.txt가 없습니닀. 뚌저 make_corpus.py륌 싀행핎알 합니닀.") + sys.exit(1) + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +# Ʞ졎 RE_L1, RE_L2는 유지하고 아래 두 개륌 추가/교첎합니닀. +RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") +RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + +def generate_outline(domain_prompt: str, corpus: str) -> str: + sys_msg = { + "role": "system", + "content": ( + domain_prompt + "\n\n" + "너는 걎섀/잡량 DX Ʞ술 볎고서의 구조륌 섀계하는 시니얎 Ʞ술사읎닀. " + "죌얎진 corpus륌 분석하여, 싀묎자가 슉시 활용 가능한 고밀도 지칚서 목찚륌 섀계하띌." + ), + } + + user_msg = { + "role": "user", + "content": f""" +아래 [corpus]륌 바탕윌로 볎고서 제목곌 전략적 목찚륌 섀계하띌. + +[corpus] +{corpus} + +요구 사항: +1) 첫 쀄에 볎고서 제목 1개륌 작성하띌. +2) ê·ž 아래 목찚륌 번혞 êž°ë°˜ 계잡 구조로 작성하띌. + - 대목찚: 1. / 2. / 3. ... + - 쀑목찚: 1.1 / 1.2 / ... + - 소목찚: 1.1.1 / 1.1.2 / ... +3) **수량 제앜 (쀑요)**: + - 대목찚(1.)는 5~8개로 구성하띌. + - **쀑목찚(1.1) 하나당 소목찚(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사읎로 구성하띌.** (절대 1개만 만듀지 말 것) + - 소목찚(1.1.1) 하나당 '핵심죌제(ꌭ지)'는 반드시 2개에서 3개 사읎로 구성하띌. + +[소목찚 작성 형식] +1.1.1 소목찚 제목 + - 핵심죌제 1 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + - 핵심죌제 2 | #킀워드 | [유형] | 집필가읎드(데읎터/표 구성 지칚) + +5) [유형] 분류 가읎드: + - [비교형]: Ʞ졎 vs DX 방식의 비교표(Table)가 필수적읞 겜우 + - [Ʞ술형]: RMSE, GSD, 쀑복도 등 정밀 수치와 사양 섀명읎 핵심읞 겜우 + - [절찚형]: 닚계별 워크플로 및 첎크늬슀튞가 쀑심읞 겜우 + - [읞사읎튞형]: 한계점 분석 및 전묞가 제얞(☞)읎 쀑심읞 겜우 +6) 집필가읎드는 50자 낎왞로, "ì–Žë–€ 데읎터륌 검색핎서 ì–Žë–€ 표륌 귞렀띌"와 같읎 구첎적윌로 지시하띌. +7) 대목찚는 최대 8개 읎낎로 구성하띌. +""" + } + resp = client.chat.completions.create( + model=GPT_MODEL, + messages=[sys_msg, user_msg], + ) + return (resp.choices[0].message.content or "").strip() + + + +def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] + + title = lines[0].strip() # 첫 쀄은 볎고서 제목 + rows = [] + current_section = None # 현재 처늬 쀑읞 소목찚(1.1.1)륌 추적 + + for ln in lines[1:]: + raw = ln.strip() + + # 1. 소목찚 헀더(1.1.1 제목) 발견 시 + m3_head = RE_L3_HEAD.match(raw) + if m3_head: + num, s_title = m3_head.groups() + current_section = { + "depth": 3, + "num": num, + "title": s_title, + "sub_topics": [] # 여Ʞ에 아래 쀄의 ꌭ지듀을 닎을 예정 + } + rows.append(current_section) + continue + + # 2. 섞부 ꌭ지(- 죌제 | #킀워드 | [유형] | 가읎드) 발견 시 + m_topic = RE_L3_TOPIC.match(raw) + if m_topic and current_section: + t_title, kws_raw, t_type, guide = m_topic.groups() + # 킀워드 추출 (#킀워드 형태) + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] + + # 현재 소목찚(current_section)의 늬슀튞에 추가 + current_section["sub_topics"].append({ + "topic_title": t_title, + "keywords": kws, + "type": t_type, + "guide": guide + }) + continue + + # 3. 대목찚(1.) 처늬 + m1 = RE_L1.match(raw) + if m1: + rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + # 4. 쀑목찚(1.1) 처늬 + m2 = RE_L2.match(raw) + if m2: + rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) + current_section = None # 소목찚 구간 종료 + continue + + return title, rows + +def html_escape(s: str) -> str: + s = s or "" + return (s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'")) + +def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]: + """ + A4 1장에 표가 Ꞟ얎지멎 넘치므로, 닚순 행 개수로 페읎지 분할한닀. + """ + out = [] + cur = [] + for r in rows: + cur.append(r) + if len(cur) >= max_rows_per_page: + out.append(cur) + cur = [] + if cur: + out.append(cur) + return out + +def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html의 table 슀타음을 귞대로 쓰는 전제의 표 HTML + """ + head = """ + + + + + + + + + + + """ + + body_parts = [] + for r in rows: + depth = r["depth"] + num = html_escape(r["num"]) + title = html_escape(r["title"]) + kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k]) + kw = html_escape(kw) + + if depth == 1: + body_parts.append( + f""" + + + + + + + """ + ) + elif depth == 2: + body_parts.append( + f""" + + + + + + + """ + ) + else: + body_parts.append( + f""" + + + + + + + """ + ) + + tail = """ + +
                                  구분번혞제목킀워드
                                  대목찚{num}{title}
                                  쀑목찚{num}{title}
                                  소목찚{num}{title}{kw}
                                  + """ + return head + "\n".join(body_parts) + tail + +def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: + """ + 테슀튞.html 레읎아웃 구조륌 귞대로 따띌 A4 시튞 형태로 HTML 생성 + """ + css = r""" + @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + + :root { + --primary-blue: #3057B9; + --gray-light: #F2F2F2; + --gray-medium: #E6E6E6; + --gray-dark: #666666; + --border-light: #DDDDDD; + --text-black: #000000; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + } + + body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.35; + display: flex; + justify-content: center; + padding: 10px 0; + } + + .sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + margin-bottom: 12px; + } + + @media print { + body { background: none; padding: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 15px; + font-size: 8.5pt; + color: var(--gray-dark); + } + + .header-title { + font-size: 24pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1.5px; + color: #111; + } + + .title-divider { + height: 4px; + background-color: var(--primary-blue); + width: 100%; + margin-bottom: 20px; + } + + .lead-box { + background-color: var(--gray-light); + padding: 18px 20px; + margin-bottom: 5px; + border-radius: 2px; + text-align: center; + } + + .lead-box div { + font-size: 13pt; + font-weight: 700; + color: var(--primary-blue); + letter-spacing: -0.5px; + } + + .lead-notes { + font-size: 8.5pt; + color: #777; + margin-bottom: 20px; + padding-left: 5px; + text-align: right; + } + + .body-content { flex: 1; } + + .section { margin-bottom: 22px; } + + .section-title { + font-size: 13pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: #111; + } + + .section-title::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: #999; + margin-right: 10px; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 9.5pt; + border-top: 1.5px solid #333; + } + + th { + background-color: var(--gray-medium); + font-weight: 700; + padding: 10px; + border: 1px solid var(--border-light); + } + + td { + padding: 10px; + border: 1px solid var(--border-light); + vertical-align: middle; + } + + .group-cell { + background-color: #F9F9F9; + font-weight: 700; + width: 16%; + text-align: center; + color: var(--primary-blue); + white-space: nowrap; + } + + .page-footer { + margin-top: 15px; + padding-top: 10px; + display: flex; + justify-content: space-between; + font-size: 8.5pt; + color: var(--gray-dark); + border-top: 1px solid #EEE; + } + + .footer-page { flex: 1; text-align: center; } + """ + + pages = chunk_rows(rows, max_rows_per_page=26) + + html_pages = [] + total_pages = len(pages) if pages else 1 + for i, page_rows in enumerate(pages, start=1): + table_html = build_outline_table_html(page_rows) + + html_pages.append(f""" +
                                  + + +
                                  +

                                  {html_escape(report_title)}

                                  +
                                  +
                                  + +
                                  +
                                  +
                                  확정 목찚 표 형태 정늬볞
                                  +
                                  +
                                  목찚는 outline_issue_report.txt륌 Ʞ반윌로 표로 재구성됚
                                  + +
                                  +
                                  목찚
                                  + {table_html} +
                                  +
                                  + +
                                  + + + +
                                  +
                                  + """) + + return f""" + + + + {html_escape(report_title)} - Outline + + + + {''.join(html_pages)} + + +""" + +def main(): + log("=== 목찚 생성 시작 ===") + domain_prompt = load_domain_prompt() + corpus = load_corpus() + + outline = generate_outline(domain_prompt, corpus) + + # TXT 저장 유지 + out_txt = CONTEXT_DIR / "outline_issue_report.txt" + out_txt.write_text(outline, encoding="utf-8") + log(f"목찚 TXT 저장 완료: {out_txt}") + + # HTML 추가 저장 + title, rows = parse_outline(outline) + out_html = CONTEXT_DIR / "outline_issue_report.html" + out_html.write_text(build_outline_html(title, rows), encoding="utf-8") + log(f"목찚 HTML 저장 완료: {out_html}") + + log("=== 목찚 생성 종료 ===") + +if __name__ == "__main__": + main() diff --git a/03. Code/geulbeot_9th/converters/pipeline/step8_content.py b/03. Code/geulbeot_9th/converters/pipeline/step8_content.py new file mode 100644 index 0000000..4330251 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step8_content.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +""" +step8_generate_report_gemini.py + +Ʞ능 +- 확정 목찚(outline_issue_report.txt)륌 읜얎 섹션(소목찚) 목록을 만든닀. +- 섹션별로 RAG에서 귌거 청크륌 검색한닀(FAISS 있윌멎 FAISS, 없윌멎 킀워드 êž°ë°˜). +- 섹션별 볞묞 쎈안을 생성한닀(낎부 귌거 우선, 원묞 볎졎 원칙). +- 섹션별 읎믞지 후볎륌 맀핑하고, md에는 읎믞지 자늬표시자륌 삜입한닀. +- 산출묌 2개륌 만든닀. + 1) report_draft.md + 2) report_sections.json + +변겜사항 (OpenAI → Gemini) +- google.genai 띌읎람러늬 사용 +- 자윚성 통제: temperature=0.3, thinking_budget=0 +- 원묞 볎졎 원칙 강화 +- 소목찚별 쀑복 방지 로직 추가 +- ★ 읎믞지 assets 복사 로직 추가 +""" + +import os +import re +import json +import shutil # ★ 추가: 읎믞지 복사용 +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +try: + import faiss # type: ignore +except Exception: + faiss = None + +# ===== 하읎람늬드 API 섀정 ===== +# 검색/임베딩: OpenAI (Ʞ졎 FAISS 읞덱슀 혾환) +# 볞묞 작성: Gemini (Ꞁ쓰Ʞ 품질) + +from google import genai +from google.genai import types +from openai import OpenAI +from api_config import API_KEYS + +# OpenAI (임베딩/검색용) +OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '') +EMBED_MODEL = "text-embedding-3-small" +openai_client = OpenAI(api_key=OPENAI_API_KEY) + +# Gemini (볞묞 작성용) +GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '') +GEMINI_MODEL = "gemini-3-pro-preview" +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +# ===== 겜로 섀정 ===== +DATA_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out") +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +CONTEXT_DIR = OUTPUT_ROOT / "context" +LOG_DIR = OUTPUT_ROOT / "logs" +RAG_DIR = OUTPUT_ROOT / "rag" +GEN_DIR = OUTPUT_ROOT / "generated" + +# ★ 추가: 읎믞지 assets 겜로 +ASSETS_DIR = GEN_DIR / "assets" +IMAGES_ROOT = DATA_ROOT / "images" # 추출된 읎믞지 원볞 위치 + +for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# 파음명 +OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt" +DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt" + +# 선택 파음(있윌멎 사용) +FAISS_INDEX_PATH = RAG_DIR / "faiss.index" +FAISS_META_PATH = RAG_DIR / "meta.json" +FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy" + +# 읎믞지 메타(있윌멎 캡션 볎강) +IMAGE_META_PATH = DATA_ROOT / "image_metadata.json" + +# 출력 파음 +REPORT_MD_PATH = GEN_DIR / "report_draft.md" +REPORT_JSON_PATH = GEN_DIR / "report_sections.json" + +# 섀정값 +TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10")) +MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3")) +MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900")) + +# 팹턮 +RE_TITLE_LINE = re.compile(r"^\s*(.+?)\s*$") +RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") +RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") +RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$") +RE_KEYWORDS = re.compile(r"(#\S+)") + +RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") + + +def log(msg: str): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +@dataclass +class SubTopic: + title: str + keywords: List[str] + type: str + guide: str + + +@dataclass +class OutlineItem: + number: str + title: str + depth: int + sub_topics: List[SubTopic] = field(default_factory=list) + + +def read_text(p: Path) -> str: + return p.read_text(encoding="utf-8", errors="ignore").strip() + + +def load_domain_prompt() -> str: + if not DOMAIN_PROMPT_PATH.exists(): + raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}") + return read_text(DOMAIN_PROMPT_PATH) + + +def load_outline() -> Tuple[str, List[OutlineItem]]: + if not OUTLINE_PATH.exists(): + raise RuntimeError("목찚 파음읎 없습니닀.") + raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines() + if not raw: + return "", [] + + report_title = raw[0].strip() + items: List[OutlineItem] = [] + current_l3 = None + + # ꌭ지 파싱용 정규식 + re_l3_head = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") + re_l3_topic = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$") + + for ln in raw[1:]: + line = ln.strip() + if not line: + continue + + m3h = re_l3_head.match(line) + if m3h: + current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3) + items.append(current_l3) + continue + + m3t = re_l3_topic.match(line) + if m3t and current_l3: + kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))] + current_l3.sub_topics.append(SubTopic( + title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4) + )) + continue + + m2 = RE_L2.match(line) + if m2: + items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2)) + current_l3 = None + continue + m1 = RE_L1.match(line) + if m1: + items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1)) + current_l3 = None + continue + + return report_title, items + + +def load_image_metadata() -> Dict[str, Dict[str, Any]]: + """image_metadata.json읎 있윌멎 image_file Ʞ쀀윌로 맵을 만든닀.""" + if not IMAGE_META_PATH.exists(): + return {} + try: + data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore")) + out: Dict[str, Dict[str, Any]] = {} + for it in data: + fn = (it.get("image_file") or "").strip() + if fn: + out[fn] = it + return out + except Exception as e: + log(f"[WARN] image_metadata.json 로드 싀팚: {e}") + return {} + + +def iter_rag_items() -> List[Dict[str, Any]]: + """rag 폎더의 *_chunks.json 몚두 로드""" + items: List[Dict[str, Any]] = [] + files = sorted(RAG_DIR.glob("*_chunks.json")) + if not files: + raise RuntimeError(f"rag 폎더에 *_chunks.json 없음: {RAG_DIR}") + + for f in files: + try: + data = json.loads(f.read_text(encoding="utf-8", errors="ignore")) + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + items.append(it) + except Exception as e: + log(f"[WARN] RAG 파음 로드 싀팚: {f.name} {e}") + + return items + + +def normalize_ws(s: str) -> str: + return " ".join((s or "").split()) + + +def make_evidence_snippet(text: str, max_chars: int) -> str: + t = normalize_ws(text) + if len(t) <= max_chars: + return t + return t[:max_chars] + "..." + + +def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]: + src = (it.get("source") or "").strip() + ch = int(it.get("chunk") or 0) + return (src, ch) + + +def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]: + m: Dict[Tuple[str, int], Dict[str, Any]] = {} + for it in items: + m[get_item_key(it)] = it + return m + + +def try_load_faiss(): + """faiss.index, meta.json, vectors.npy가 몚두 있고 faiss 몚듈읎 있윌멎 사용""" + if faiss is None: + log("[INFO] faiss 몚듈 없음 - 킀워드 검색 사용") + return None + if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()): + log("[INFO] FAISS 파음 없음 - 킀워드 검색 사용") + return None + try: + index = faiss.read_index(str(FAISS_INDEX_PATH)) + metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore")) + vecs = np.load(str(FAISS_VECTORS_PATH)) + log(f"[INFO] FAISS 로드 성공 - 읞덱슀 찚원: {index.d}, 메타 수: {len(metas)}") + return index, metas, vecs + except Exception as e: + log(f"[WARN] FAISS 로드 싀팚: {e}") + return None + + +def embed_query_openai(q: str) -> np.ndarray: + """OpenAI 임베딩 (Ʞ졎 FAISS 읞덱슀와 혾환)""" + try: + resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q]) + v = np.array(resp.data[0].embedding, dtype="float32") + n = np.linalg.norm(v) + 1e-12 + return v / n + except Exception as e: + log(f"[WARN] OpenAI 임베딩 싀팚: {e}") + return np.zeros(1536, dtype="float32") # OpenAI 찚원 + + +def retrieve_with_faiss( + index, + metas: List[Dict[str, Any]], + item_map: Dict[Tuple[str, int], Dict[str, Any]], + query: str, + top_k: int +) -> List[Dict[str, Any]]: + qv = embed_query_openai(query).reshape(1, -1).astype("float32") + D, I = index.search(qv, top_k) + out: List[Dict[str, Any]] = [] + for idx in I[0]: + if idx < 0 or idx >= len(metas): + continue + meta = metas[idx] + src = (meta.get("source") or "").strip() + ch = int(meta.get("chunk") or 0) + it = item_map.get((src, ch)) + if it: + out.append(it) + return out + + +def tokenize_simple(s: str) -> List[str]: + s = normalize_ws(s).lower() + return [t for t in re.split(r"\s+", s) if t] + + +def retrieve_with_keywords( + all_items: List[Dict[str, Any]], + query: str, + keywords: List[str], + top_k: int +) -> List[Dict[str, Any]]: + q_tokens = set(tokenize_simple(query)) + k_tokens = set([kw.lower() for kw in keywords if kw]) + + scored: List[Tuple[float, Dict[str, Any]]] = [] + for it in all_items: + txt = " ".join([ + str(it.get("title") or ""), + str(it.get("keywords") or ""), + str(it.get("summary") or ""), + str(it.get("text") or ""), + str(it.get("folder_context") or ""), + str(it.get("source_path") or ""), + ]) + t = normalize_ws(txt).lower() + + score = 0.0 + for tok in q_tokens: + if tok and tok in t: + score += 1.0 + for tok in k_tokens: + if tok and tok in t: + score += 2.0 + + if it.get("has_images"): + score += 0.5 + + if score > 0: + scored.append((score, it)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [it for _, it in scored[:top_k]] + + +def select_images_for_section( + evidences: List[Dict[str, Any]], + image_meta_by_file: Dict[str, Dict[str, Any]], + max_images: int +) -> List[Dict[str, Any]]: + """귌거 청크에서 images륌 몚아 섹션 읎믞지 후볎륌 만듀고 상한윌로 자륞닀.""" + seen = set() + out: List[Dict[str, Any]] = [] + + def infer_image_file(p: str) -> str: + p = p.replace("\\", "/") + return p.split("/")[-1] + + for ev in evidences: + imgs = ev.get("images") or [] + if not isinstance(imgs, list): + continue + for img in imgs: + if not isinstance(img, dict): + continue + rel_path = (img.get("path") or "").strip() + if not rel_path: + continue + key = rel_path.replace("\\", "/") + if key in seen: + continue + seen.add(key) + + img_file = infer_image_file(key) + meta = image_meta_by_file.get(img_file, {}) + + caption = "" + if meta: + caption = (meta.get("caption") or "").strip() + if not caption: + caption = (img.get("alt") or "").strip() or img_file + + out.append({ + "image_id": "", + "rel_path": key, + "image_file": img_file, + "caption": caption, + "source_path": ev.get("source_path") or ev.get("source") or "", + "page": meta.get("page", None) if meta else None, + "type": meta.get("type", None) if meta else None, + }) + if len(out) >= max_images: + return out + + return out + + +def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """섹션번혞 Ʞ반윌로 읎믞지아읎디륌 만듀고 placeholder륌 만든닀.""" + sec_key = section_number.replace(".", "_") + out = [] + for i, img in enumerate(images, start=1): + img_id = f"{sec_key}_img{i:02d}" + out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"}) + return out + + +# ★ 추가: 읎믞지 파음을 assets 폎더로 복사하는 핚수 +def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None: + """선택된 읎믞지듀을 generated/assets/로 복사""" + for img in image_info_list: + # 원볞 겜로 ì°Ÿêž° (여러 겜로 시도) + rel_path = img.get('rel_path', '') + src_path = None + + # 1ì°š: DATA_ROOT Ʞ쀀 상대겜로 + candidate1 = DATA_ROOT / rel_path + if candidate1.exists(): + src_path = candidate1 + + # 2ì°š: IMAGES_ROOT에서 파음명윌로 검색 + if src_path is None: + candidate2 = IMAGES_ROOT / img.get('image_file', '') + if candidate2.exists(): + src_path = candidate2 + + # 3ì°š: DATA_ROOT 전첎에서 파음명 검색 (재귀) + if src_path is None: + img_file = img.get('image_file', '') + if img_file: + for found in DATA_ROOT.rglob(img_file): + src_path = found + break + + if src_path and src_path.exists(): + # image_id Ʞ반윌로 새 파음명 생성 (확장자 유지) + ext = src_path.suffix or '.png' + dst_filename = f"{img['image_id']}{ext}" + dst_path = ASSETS_DIR / dst_filename + + try: + shutil.copy2(src_path, dst_path) + img['asset_path'] = f"assets/{dst_filename}" + log(f" [IMG] {img['image_id']} → {dst_filename}") + except Exception as e: + log(f" [WARN] 읎믞지 복사 싀팚: {img['image_id']} - {e}") + img['asset_path'] = None + else: + log(f" [WARN] 읎믞지 없음: {rel_path} ({img.get('image_file', '')})") + img['asset_path'] = None + + +# ===== Gemini 프롬프튞 구성 (자윚성 통제 강화) ===== + +def build_system_instruction(domain_prompt: str) -> str: + """ + Gemini 시슀템 지시묞 (v4 - 최종) + """ + return f"""{domain_prompt} + +═══════════════════════════════════════════════════════════════ + ★★★ 절대 쀀수 규칙 ★★★ +═══════════════════════════════════════════════════════════════ + +[ꞈ지 사항] +1. 원묞의 수치, 용얎, 표현을 임의로 변겜 ꞈ지 +2. 제공되지 않은 정볎 추론/찜작 ꞈ지 +3. 추잡성 표현 ꞈ지 ("~로 볎읞닀", "~음 것읎닀") +4. 쀑복 낎용 작성 ꞈ지 +5. 마크닀욎 헀딩(#, ##, ###, ####) 사용 ꞈ지 +6. ★ "ꌭ지", "항목 1", "Topic" 등 낎부 분류 용얎 출력 ꞈ지 +7. ★ "1. 2. 3." 형태 번혞 사용 ꞈ지 (반드시 "1) 2) 3)" 사용) + +[필수 사항] +1. 원묞 최대 볎졎 +2. 수치는 원볞 귞대로 +3. 전묞 용얎 변겜 없읎 사용 +4. 볎고서 형식윌로 전묞적 작성 + +═══════════════════════════════════════════════════════════════ + ★★★ 번혞 첎계 및 서식 규칙 (필수) ★★★ +═══════════════════════════════════════════════════════════════ + +【레벚별 번혞와 서식】 + +■ 1닚계: 1), 2), 3) +■ 2닚계: (1), (2), (3) +■ 3닚계: ①, ②, ③ 또는 -, * + +【핵심 서식 규칙】 + +★ 몚든 번혞의 제목은 반드시 **볌드** 처늬 +★ 제목곌 볞묞 사읎에 반드시 빈 쀄(엔터) 삜입 +★ 볞묞곌 닀음 번혞 사읎에 반드시 빈 쀄(엔터) 삜입 + +【올바륞 예시】 +``` +1) **VRS GNSS 잡량의 개요** + +읞공위성곌 위성Ʞ쀀점을 읎용한 위치 잡량 방식읎닀. 싀시간 볎정을 통핎 높은 정확도륌 확볎할 수 있닀. + +2) **UAV 사진잡량의 특징** + +묎읞항공Ʞ륌 활용한 ꎑ역 잡량 방식읎닀. 목적에 따띌 닀음곌 같읎 구분된닀. + + (1) **맵핑잡량** + + 정사영상 제작에 특화된 쎬영 방식읎닀. + + (2) **몚덞잡량** + + 3D 몚덞 생성에 특화된 쎬영 방식읎닀. +``` + +【잘못된 예시 - 절대 ꞈ지】 +``` +ꌭ지 1 VRS GNSS 잡량 ← "ꌭ지" 용얎 ꞈ지! +1. VRS GNSS 잡량 ← "1." 형태 ꞈ지! +1) VRS GNSS 잡량 읞공위성을... ← 제목+볞묞 한쀄 ꞈ지! +1) VRS GNSS 잡량 ← 볌드 없음 ꞈ지! +``` + +═══════════════════════════════════════════════════════════════ + +[작성 형식] +- 섹션 제목 없읎 바로 볞묞 시작 +- 죌제별 구분: 1), 2), 3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- 하위 구분: (1), (2), (3) + **볌드 제목** + 쀄바꿈 + 볞묞 +- [비교형]: 마크닀욎 표 포핚 +- [Ʞ술형]: Ʞ술 사양/수치 정확히 Ʞ재 +- [절찚형]: 닚계별 1), 2), 3) 사용 + +[출력 제한] +- 마크닀욎 헀딩 ꞈ지 +- "ꌭ지", "Topic", "항목" 등 분류 용얎 출력 ꞈ지 +- 낎부 메몚용 표현 ꞈ지 +- 출처 표시 ꞈ지 +═══════════════════════════════════════════════════════════════ +""" + + +def build_user_prompt( + report_title: str, + item, # OutlineItem + evidences, + image_info_list, + previous_sections_summary: str = "" +) -> str: + """ + 섹션별 사용자 프롬프튞 (v4) + """ + + # 귌거 자료 정늬 + ev_text = "" + for i, ev in enumerate(evidences, 1): + src = ev.get('source_path') or ev.get('source', '낎부자료') + text = ev.get('text', '')[:1500] + title = ev.get('title', '') + keywords = ev.get('keywords', '') + + ev_text += f""" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[데읎터 {i}] 출처: {src} +제목: {title} +킀워드: {keywords} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{text} +""" + + # ★ "ꌭ지" → "죌제"로 변겜, 번혞 부여 + topic_guides = "" + for idx, st in enumerate(item.sub_topics, 1): + topic_guides += f""" +【작성할 낎용 {idx}】 {st.title} + - 유형: {st.type} + - 핵심 킀워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'} + - ì°žê³  지칚: {st.guide} + - ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것 +""" + + # 읎믞지 안낎 + img_guide = "" + if image_info_list: + img_guide = "\n【삜입 가능 읎믞지】\n" + for img in image_info_list: + img_guide += f" - {img['placeholder']}: {img['caption']}\n" + img_guide += " → 묞맥에 맞는 위치에 삜입\n" + + # 쀑복 방지 + dup_guide = "" + if previous_sections_summary: + dup_guide = f""" +【쀑복 방지 - 읎믞 닀룬 낎용읎므로 제왞】 +{previous_sections_summary} +""" + + # ★ 서식 늬마읞더 강화 + format_reminder = """ +═══════════════════════════════════════════════════════════════ + ★★★ 출력 서식 필수 쀀수 ★★★ +═══════════════════════════════════════════════════════════════ +1) **제목은 반드시 볌드** + +볞묞은 제목 닀음 쀄에 작성 + +2) **닀음 제목도 볌드** + +볞묞... + + (1) **하위 제목도 볌드** + + 하위 볞묞... + +★ "ꌭ지", "항목", "Topic" 등 낎부 용얎 절대 출력 ꞈ지! +★ 제목곌 볞묞 사읎 반드시 빈 쀄! +═══════════════════════════════════════════════════════════════ +""" + + return f""" +╔═══════════════════════════════════════════════════════════════╗ +║ 볎고서: {report_title} +║ 작성 섹션: {item.number} {item.title} +╚═══════════════════════════════════════════════════════════════╝ + +{dup_guide} + +【읎 섹션에서 닀룰 낎용】 +{topic_guides} + +{img_guide} + +{format_reminder} + +【찞고 데읎터】 +{ev_text} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +지시: '{item.number} {item.title}' 섹션 볞묞을 작성하띌. + +★ 번혞: 1), 2) → (1), (2) → -, * +★ 제목: 반드시 **볌드** +★ 쀄바꿈: 제목↔볞묞 사읎 빈 쀄 필수 +★ ꞈ지얎: "ꌭ지", "항목", "Topic" 출력 ꞈ지 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +""" + + +def generate_section_text_gemini( + system_instruction: str, + user_prompt: str +) -> str: + """ + Gemini API륌 사용한 섹션 볞묞 생성 + - temperature=0.3윌로 자윚성 억제 + """ + try: + response = gemini_client.models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_instruction, + temperature=0.3, # 낮은 temperature로 찜의성 억제 + ) + ) + return (response.text or "").strip() + except Exception as e: + log(f"[ERROR] Gemini API 혞출 싀팚: {e}") + return f"[생성 싀팚: {e}]" + +import re + +def extract_section_summary(text: str, max_chars: int = 200) -> str: + """섹션 볞묞에서 핵심 킀워드/죌제 추출 (쀑복 방지용)""" + # 첫 200자 또는 첫 묞닚 + lines = text.split('\n') + summary_parts = [] + char_count = 0 + + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + summary_parts.append(line) + char_count += len(line) + if char_count >= max_chars: + break + + return ' '.join(summary_parts)[:max_chars] + + +def fix_numbering_format(text: str) -> str: + """ + Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환 + + 변환 규칙: + - "1. " → "1) " (쀄 시작, 듀여쓰Ʞ 0) + - " 1. " → " (1) " (듀여쓰Ʞ 있윌멎 하위 레벚) + """ + lines = text.split('\n') + result = [] + + for line in lines: + # 원볞 듀여쓰Ʞ 잡정 + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 감지 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위 레벚: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: 1. → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상 듀여쓰Ʞ: 귞대로 유지 또는 - 로 변환 + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def clean_generated_text_final(section_number: str, text: str) -> str: + """ + Gemini 출력 후처늬 (최종 버전) + + 1. 쀑복 섹션 제목 제거 + 2. "ꌭ지 N" 팹턮 제거 + 3. 번혞 첎계 변환 (1. → 1)) + 4. 제목 볌드 + 쀄바꿈 강제 적용 + 5. #### 헀딩 → 볌드 변환 + """ + + # 1닚계: Ʞ볞 정늬 + lines = text.split('\n') + cleaned = [] + + for line in lines: + stripped = line.strip() + + # 쀑복 섹션 제목 제거 (# 숫자.숫자.숫자 형태) + if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped): + continue + + # "ꌭ지 N" 팹턮 제거 (독늜 띌읞) + if re.match(r'^[\*\*]*ꌭ지\s*\d+[\*\*]*\s*', stripped): + continue + + # "**ꌭ지 N 제목**" → "**제목**" 변환 + cleaned_line = re.sub(r'\*\*ꌭ지\s*\d+\s*', '**', stripped) + + # #### 헀딩 → 볌드 + h4_match = re.match(r'^####\s+(.+)$', cleaned_line) + if h4_match: + title = h4_match.group(1).strip() + if not re.match(r'^\d+', title): + cleaned.append(f"\n**{title}**\n") + continue + + # 빈 쀄 연속 방지 (3쀄 읎상 → 2쀄) + if not stripped: + if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip(): + continue + + cleaned.append(cleaned_line if cleaned_line != stripped else line) + + result = '\n'.join(cleaned) + + # 2닚계: 번혞 첎계 변환 + result = fix_numbering_format(result) + + # 3닚계: 제목+볞묞 붙얎있는 것 분늬 + 볌드 적용 + result = fix_title_format(result) + + return result.strip() + + +def fix_numbering_format(text: str) -> str: + """ + "1. " → "1) " 변환 + 듀여쓰Ʞ 있윌멎 "(1)" 형태로 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # "숫자. " 팹턮 (마크닀욎 순서 늬슀튞) + match = re.match(r'^(\d+)\.\s+(.+)$', stripped) + + if match: + num = match.group(1) + content = match.group(2) + + if indent == 0: + # 최상위: 1. → 1) + result.append(f"{num}) {content}") + elif indent <= 4: + # 1닚계 듀여쓰Ʞ: → (1) + result.append(" " * indent + f"({num}) {content}") + else: + # 2닚계 읎상: → - + result.append(" " * indent + f"- {content}") + else: + result.append(line) + + return '\n'.join(result) + + +def fix_title_format(text: str) -> str: + """ + 번혞+제목+볞묞 한쀄 → 번혞+제목 / 볞묞 분늬 + 제목에 볌드 적용 + + 핵심: **볌드 제목** 뒀에 볞묞읎 읎얎지멎 쀄바꿈 삜입 + """ + lines = text.split('\n') + result = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(stripped) + indent_str = " " * indent + + # 팹턮 1: "1) **제목** 볞묞..." → "1) **제목**\n\n볞묞..." + m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m1: + num = m1.group(1) + title = m1.group(2) + body = m1.group(3).strip() + result.append(f"{indent_str}{num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 2: "(1) **제목** 볞묞..." → "(1) **제목**\n\n볞묞..." + m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped) + if m2: + num = m2.group(1) + title = m2.group(2) + body = m2.group(3).strip() + result.append(f"{indent_str}({num}) {title}") + result.append("") + result.append(f"{indent_str}{body}") + result.append("") + continue + + # 팹턮 3: "1) 제목:" 또는 "1) 제목" (볌드 없음, 짧은 제목) → 볌드 적용 + m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped) + if m3: + num = m3.group(1) + title = m3.group(2).strip().rstrip(':') + # 묞장읎 아닌 제목윌로 판당 (마칚표로 안 끝낹) + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}{num}) **{title}**") + result.append("") + continue + + # 팹턮 4: "(1) 제목" (볌드 없음) → 볌드 적용 + m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped) + if m4: + num = m4.group(1) + title = m4.group(2).strip().rstrip(':') + if not title.endswith(('.', 'ë‹€', '요', '음', '핹')): + result.append(f"{indent_str}({num}) **{title}**") + result.append("") + continue + + result.append(line) + + # 연속 빈쀄 정늬 + final = [] + for line in result: + if not line.strip(): + if len(final) >= 2 and not final[-1].strip() and not final[-2].strip(): + continue + final.append(line) + + return '\n'.join(final) + + +def main(): + log("=== step8 Gemini êž°ë°˜ 볎고서 생성 시작 ===") + + domain_prompt = load_domain_prompt() + report_title, outline_items = load_outline() + + log(f"볎고서 제목: {report_title}") + log(f"목찚 항목 수: {len(outline_items)}") + + # 데읎터 및 읎믞지 메타 로드 + image_meta_by_file = load_image_metadata() + all_rag_items = iter_rag_items() + item_map = build_item_index(all_rag_items) + faiss_pack = try_load_faiss() + use_faiss = faiss_pack is not None + + log(f"RAG 청크 수: {len(all_rag_items)}") + log(f"FAISS 사용: {use_faiss}") + + # 시슀템 지시묞 (한 번만 생성) + system_instruction = build_system_instruction(domain_prompt) + + md_lines = [f"# {report_title}", ""] + report_json_sections = [] + + # 쀑복 방지륌 위한 읎전 섹션 요앜 누적 + previous_sections_summary = "" + + # ★ 추가: 복사된 읎믞지 칎욎튞 + total_images_copied = 0 + + for it in outline_items: + # 대목찚와 쀑목찚는 제목만 적고 통곌 + if it.depth < 3: + prefix = "## " if it.depth == 1 else "### " + md_lines.append(f"\n{prefix}{it.number} {it.title}\n") + continue + + log(f"집필 쀑: {it.number} {it.title} (ꌭ지 {len(it.sub_topics)}개)") + + # ꌭ지듀의 킀워드륌 합쳐서 검색 + all_kws = [] + for st in it.sub_topics: + all_kws.extend(st.keywords) + query = f"{it.title} " + " ".join(all_kws) + + # RAG 검색 + if use_faiss: + evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12) + else: + evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12) + + log(f" → 검색된 귌거 청크: {len(evidences)}개") + + # 읎믞지 선택 및 플레읎슀홀더 생성 + section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION) + image_info_list = make_image_placeholders(it.number, section_images) + + # ★ 추가: 읎믞지 파음을 assets 폎더로 복사 + copy_images_to_assets(image_info_list) + copied_count = sum(1 for img in image_info_list if img.get('asset_path')) + total_images_copied += copied_count + + # 사용자 프롬프튞 생성 + user_prompt = build_user_prompt( + report_title=report_title, + item=it, + evidences=evidences, + image_info_list=image_info_list, + previous_sections_summary=previous_sections_summary + ) + + # Gemini로 볞묞 생성 + section_text = generate_section_text_gemini(system_instruction, user_prompt) + section_text = clean_generated_text_final(it.number, section_text) # ★ 읎 한 쀄만 추가! + + # 마크닀욎 낎용 추가 + md_lines.append(f"\n#### {it.number} {it.title}\n") + md_lines.append(section_text + "\n") + + # 쀑복 방지륌 위핎 현재 섹션 요앜 누적 ← 읎 부분은 귞대로! + section_summary = extract_section_summary(section_text) + if section_summary: + previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..." + + # JSON용 데읎터 수집 (★ asset_path 추가) + report_json_sections.append({ + "section_id": it.number, + "section_title": it.title, + "generated_text": section_text, + "sub_topics": [vars(st) for st in it.sub_topics], + "evidence_count": len(evidences), + "assets": [ + { + "type": "image", + "image_id": img["image_id"], + "filename": img["image_file"], + "caption": img["caption"], + "placeholder": img["placeholder"], + "source_path": img.get("source_path", ""), + "page": img.get("page"), + "asset_path": img.get("asset_path"), # ★ 추가 + } + for img in image_info_list + ] + }) + + log(f" → 생성 완료 ({len(section_text)} 자)") + + # 1. 마크닀욎(.md) 파음 저장 + REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8") + + # 2. JSON(.json) 파음 저장 + REPORT_JSON_PATH.write_text( + json.dumps({ + "generated_at": datetime.now().isoformat(), + "report_title": report_title, + "model": GEMINI_MODEL, + "sections": report_json_sections + }, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"파음 저장 완료:") + log(f" 1. {REPORT_MD_PATH}") + log(f" 2. {REPORT_JSON_PATH}") + log(f" 3. {ASSETS_DIR} (읎믞지 {total_images_copied}개 복사)") # ★ 추가 + log(f"═══════════════════════════════════════════════════") + log("=== step8 볎고서 생성 종료 ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/pipeline/step9_html.py b/03. Code/geulbeot_9th/converters/pipeline/step9_html.py new file mode 100644 index 0000000..9e20780 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/pipeline/step9_html.py @@ -0,0 +1,1249 @@ +# -*- coding: utf-8 -*- +""" +9_md_to_html_publisher.py + +Ʞ능: +- report_draft.md + report_sections.json → report.html 변환 +- A4 규격 페읎지넀읎션 템플늿 적용 +- 마크닀욎 테읎랔 → HTML 테읎랔 변환 +- 읎믞지 플레읎슀홀더 {{IMG:xxx}} →
                                  변환 +- 목찚(TOC) 자동 생성 + +사용법: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json --output report.html + python 9_md_to_html_publisher.py --no-toc --no-summary +""" + +import os +import re +import json +import argparse +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from dataclasses import dataclass, field + +# ===== 겜로 섀정 ===== +OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 +GEN_DIR = OUTPUT_ROOT / "generated" +ASSETS_DIR = GEN_DIR / "assets" +LOG_DIR = OUTPUT_ROOT / "logs" + +# Ʞ볞 입출력 파음 +DEFAULT_MD_PATH = GEN_DIR / "report_draft.md" +DEFAULT_JSON_PATH = GEN_DIR / "report_sections.json" +DEFAULT_OUTPUT_PATH = GEN_DIR / "report.html" + +for d in [GEN_DIR, ASSETS_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def log(msg: str): + """로깅 핚수""" + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with (LOG_DIR / "step9_html_publish_log.txt").open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +# ===== 데읎터 큎래슀 ===== +@dataclass +class ImageAsset: + """읎믞지 자산 정볎""" + image_id: str + filename: str + caption: str + placeholder: str + source_path: str = "" + page: Optional[int] = None + asset_path: Optional[str] = None + + +@dataclass +class Section: + """섹션 정볎""" + section_id: str + section_title: str + generated_text: str + assets: List[ImageAsset] = field(default_factory=list) + + +@dataclass +class TocItem: + """목찚 항목""" + number: str + title: str + level: int # 1, 2, 3 + + +# ===== 파음 로더 ===== +def load_json_meta(json_path: Path) -> Tuple[str, List[Section]]: + """JSON 파음에서 메타정볎와 섹션 로드""" + if not json_path.exists(): + raise FileNotFoundError(f"JSON 파음 없음: {json_path}") + + data = json.loads(json_path.read_text(encoding="utf-8")) + report_title = data.get("report_title", "볎고서") + + sections = [] + for sec in data.get("sections", []): + assets = [] + for asset in sec.get("assets", []): + assets.append(ImageAsset( + image_id=asset.get("image_id", ""), + filename=asset.get("filename", ""), + caption=asset.get("caption", ""), + placeholder=asset.get("placeholder", ""), + source_path=asset.get("source_path", ""), + page=asset.get("page"), + asset_path=asset.get("asset_path") + )) + + sections.append(Section( + section_id=sec.get("section_id", ""), + section_title=sec.get("section_title", ""), + generated_text=sec.get("generated_text", ""), + assets=assets + )) + + return report_title, sections + + +def load_markdown(md_path: Path) -> str: + """마크닀욎 파음 로드""" + if not md_path.exists(): + raise FileNotFoundError(f"MD 파음 없음: {md_path}") + return md_path.read_text(encoding="utf-8") + + +# ===== 읎믞지 ë§µ 생성 ===== +def build_image_map(sections: List[Section]) -> Dict[str, ImageAsset]: + """placeholder → ImageAsset 맀핑 생성""" + img_map = {} + for sec in sections: + for asset in sec.assets: + if asset.placeholder: + # {{IMG:xxx}} 형태에서 xxx 추출 + img_map[asset.image_id] = asset + return img_map + + +# ===== 목찚 생성 ===== +def extract_toc_from_md(md_content: str) -> List[TocItem]: + """마크닀욎에서 목찚 구조 추출""" + toc_items = [] + + # 헀딩 팹턮 + patterns = [ + (re.compile(r'^##\s+(\d+)\s+(.+)$', re.MULTILINE), 1), # ## 1 대목찚 + (re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE), 2), # ### 1.1 쀑목찚 + (re.compile(r'^####\s+(\d+\.\d+\.\d+)\s+(.+)$', re.MULTILINE), 3), # #### 1.1.1 소목찚 + ] + + for pattern, level in patterns: + for match in pattern.finditer(md_content): + number = match.group(1) + title = match.group(2).strip() + toc_items.append(TocItem(number=number, title=title, level=level)) + + # 번혞순 정렬 + def sort_key(item: TocItem) -> tuple: + parts = item.number.split('.') + return tuple(int(p) for p in parts) + + toc_items.sort(key=sort_key) + return toc_items + + +def generate_toc_html(toc_items: List[TocItem]) -> str: + """목찚 HTML 생성""" + if not toc_items: + return "" + + lines = ['
                                    '] + + current_l1 = None + for item in toc_items: + if item.level == 1: + # 새로욎 대목찚 귞룹 + if current_l1 is not None: + lines.append('') # 읎전 귞룹 ë‹«êž° + lines.append('
                                    ') + lines.append(f'
                                  • {item.number}. {item.title}
                                  • ') + current_l1 = item.number + elif item.level == 2: + lines.append(f'
                                  • {item.number} {item.title}
                                  • ') + elif item.level == 3: + lines.append(f'
                                  • {item.number} {item.title}
                                  • ') + + if current_l1 is not None: + lines.append('
                                    ') # 마지막 귞룹 ë‹«êž° + + lines.append('
                                  ') + return '\n'.join(lines) + + +# ===== 마크닀욎 → HTML 변환 ===== +class MarkdownToHtmlConverter: + """마크닀욎을 HTML로 변환하는 큎래슀""" + + def __init__(self, image_map: Dict[str, ImageAsset]): + self.image_map = image_map + self.table_counter = {} # chapter -> count + self.figure_counter = {} # chapter -> count + + def get_chapter(self, context: str = "1") -> str: + """현재 챕터 번혞 추출""" + return context.split('.')[0] if context else "1" + + def next_table_num(self, chapter: str) -> str: + """닀음 표 번혞""" + if chapter not in self.table_counter: + self.table_counter[chapter] = 0 + self.table_counter[chapter] += 1 + return f"{chapter}-{self.table_counter[chapter]}" + + def next_figure_num(self, chapter: str) -> str: + """닀음 귞늌 번혞""" + if chapter not in self.figure_counter: + self.figure_counter[chapter] = 0 + self.figure_counter[chapter] += 1 + return f"{chapter}-{self.figure_counter[chapter]}" + + def convert_table(self, md_table: str, caption: str = "", chapter: str = "1") -> str: + """마크닀욎 테읎랔 → HTML 테읎랔""" + lines = [l.strip() for l in md_table.strip().split('\n') if l.strip()] + if len(lines) < 2: + return "" + + # 헀더 행 + header_cells = [c.strip() for c in lines[0].split('|') if c.strip()] + + # 구분선 걎너뛰Ʞ (|---|---|) + data_start = 1 + if len(lines) > 1 and re.match(r'^[\|\s\-:]+$', lines[1]): + data_start = 2 + + # 데읎터 행 + data_rows = [] + for line in lines[data_start:]: + cells = [c.strip() for c in line.split('|') if c.strip()] + if cells: + data_rows.append(cells) + + # HTML 생성 + html_lines = [''] + + # thead + html_lines.append('') + for cell in header_cells: + # **text** → text + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + html_lines.append(f'') + html_lines.append('') + + # tbody + html_lines.append('') + for row in data_rows: + html_lines.append('') + for cell in row: + # **text** 처늬 + cell = re.sub(r'\*\*(.+?)\*\*', r'\1', cell) + #
                                  처늬 + cell = cell.replace('
                                  ', '
                                  ') + html_lines.append(f'') + html_lines.append('') + html_lines.append('') + html_lines.append('
                                  {cell}
                                  {cell}
                                  ') + + # 캡션 추가 + if caption: + html_lines.append(f'
                                  {caption}
                                  ') + + return '\n'.join(html_lines) + + def convert_image_placeholder(self, placeholder: str, chapter: str = "1") -> str: + """{{IMG:xxx}} →
                                  변환""" + # {{IMG:1_1_1_img01}} 에서 ID 추출 + match = re.match(r'\{\{IMG:(.+?)\}\}', placeholder) + if not match: + return placeholder + + image_id = match.group(1) + asset = self.image_map.get(image_id) + + if asset and asset.asset_path: + fig_num = self.next_figure_num(chapter) + caption = asset.caption if asset.caption and asset.caption != "Photo" else "" + caption_text = f"[귞늌 {fig_num}] {caption}" if caption else f"[귞늌 {fig_num}]" + + return f'''
                                  + {caption} +
                                  {caption_text}
                                  +
                                  ''' + else: + # 읎믞지 파음읎 없는 겜우 플레읎슀홀더 죌석윌로 + return f'' + + def convert_list(self, md_list: str) -> str: + """마크닀욎 늬슀튞 → HTML 늬슀튞""" + lines = md_list.strip().split('\n') + html_lines = [] + in_list = False + list_type = 'ul' + + for line in lines: + line = line.strip() + if not line: + continue + + # 순서 없는 늬슀튞 + ul_match = re.match(r'^[\*\-]\s+(.+)$', line) + # 순서 있는 늬슀튞 + ol_match = re.match(r'^(\d+)\.\s+(.+)$', line) + + if ul_match: + if not in_list: + html_lines.append('
                                    ') + in_list = True + list_type = 'ul' + content = ul_match.group(1) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                                  • {content}
                                  • ') + elif ol_match: + if not in_list: + html_lines.append('
                                      ') + in_list = True + list_type = 'ol' + content = ol_match.group(2) + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) + html_lines.append(f'
                                    1. {content}
                                    2. ') + + if in_list: + html_lines.append(f'') + + return '\n'.join(html_lines) + + def convert_paragraph(self, text: str) -> str: + """음반 텍슀튞 →

                                      변환""" + # 빈 쀄읎멎 묎시 + if not text.strip(): + return "" + + # **text** → + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + # *text* → + text = re.sub(r'\*(.+?)\*', r'\1', text) + # `code` → + text = re.sub(r'`(.+?)`', r'\1', text) + + return f'

                                      {text}

                                      ' + + def convert_full_content(self, md_content: str) -> str: + """전첎 마크닀욎 윘텐잠륌 HTML로 변환""" + lines = md_content.split('\n') + html_parts = [] + + current_chapter = "1" + i = 0 + + while i < len(lines): + line = lines[i].strip() + + # 빈 쀄 + if not line: + i += 1 + continue + + # H1 (# 제목) - 볎고서 제목, 섹션 시작 등 + h1_match = re.match(r'^#\s+(.+)$', line) + if h1_match and not line.startswith('##'): + title = h1_match.group(1) + # 섹션 번혞가 있윌멎 추출 + num_match = re.match(r'^(\d+(?:\.\d+)*)\s+', title) + if num_match: + current_chapter = num_match.group(1).split('.')[0] + html_parts.append(f'

                                      {title}

                                      ') + i += 1 + continue + + # H2 (## 대목찚) + h2_match = re.match(r'^##\s+(.+)$', line) + if h2_match: + title = h2_match.group(1) + num_match = re.match(r'^(\d+)\s+', title) + if num_match: + current_chapter = num_match.group(1) + html_parts.append(f'

                                      {title}

                                      ') # H1윌로 변환 (페읎지 분늬 튞늬거) + i += 1 + continue + + # H3 (### 쀑목찚) + h3_match = re.match(r'^###\s+(.+)$', line) + if h3_match: + html_parts.append(f'

                                      {h3_match.group(1)}

                                      ') + i += 1 + continue + + # H4 (#### 소목찚/ꌭ지) + h4_match = re.match(r'^####\s+(.+)$', line) + if h4_match: + html_parts.append(f'

                                      {h4_match.group(1)}

                                      ') + i += 1 + continue + + # 읎믞지 플레읎슀홀더 {{IMG:xxx}} + img_match = re.match(r'^\{\{IMG:(.+?)\}\}$', line) + if img_match: + html_parts.append(self.convert_image_placeholder(line, current_chapter)) + i += 1 + continue + + # 읎믞지 캡션 *(ì°žê³ : ...)* - figure 바로 뒀에 나였멎 묎시 (읎믞 figcaption윌로 처늬) + if line.startswith('*(') and line.endswith(')*'): + i += 1 + continue + + # 테읎랔 감지 (| 로 시작) + if line.startswith('|') or (line.startswith('**[표') and i + 1 < len(lines)): + # 표 제목 캡션 + caption = "" + if line.startswith('**[표'): + caption_match = re.match(r'^\*\*(\[표.+?\].*?)\*\*$', line) + if caption_match: + caption = caption_match.group(1) + i += 1 + if i >= len(lines): + break + line = lines[i].strip() + + # 테읎랔 볞묞 수집 + table_lines = [] + while i < len(lines) and (lines[i].strip().startswith('|') or + re.match(r'^[\|\s\-:]+$', lines[i].strip())): + table_lines.append(lines[i]) + i += 1 + + if table_lines: + table_md = '\n'.join(table_lines) + html_parts.append(self.convert_table(table_md, caption, current_chapter)) + continue + + # 늬슀튞 감지 (* 또는 - 또는 1. 로 시작) + if re.match(r'^[\*\-]\s+', line) or re.match(r'^\d+\.\s+', line): + list_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + if re.match(r'^[\*\-]\s+', next_line) or re.match(r'^\d+\.\s+', next_line): + list_lines.append(next_line) + i += 1 + elif not next_line: + i += 1 + break + else: + break + + html_parts.append(self.convert_list('\n'.join(list_lines))) + continue + + # 음반 묞닚 + para_lines = [line] + i += 1 + while i < len(lines): + next_line = lines[i].strip() + # 닀음읎 특수 요소멎 묞닚 종료 + if (not next_line or + next_line.startswith('#') or + next_line.startswith('|') or + next_line.startswith('**[표') or + next_line.startswith('{{IMG:') or + next_line.startswith('*(') or + re.match(r'^[\*\-]\s+', next_line) or + re.match(r'^\d+\.\s+', next_line)): + break + para_lines.append(next_line) + i += 1 + + para_text = ' '.join(para_lines) + if para_text: + html_parts.append(self.convert_paragraph(para_text)) + + return '\n'.join(html_parts) + + +# ===== HTML 템플늿 ===== +def get_html_template() -> str: + """A4 볎고서 HTML 템플늿 반환""" + return ''' + + + +{report_title} + + + + +
                                      +
                                      {box_cover}
                                      +
                                      {box_toc}
                                      +
                                      {box_summary}
                                      +
                                      {box_content}
                                      +
                                      + + + + + +''' + + +# ===== 메읞 핚수 ===== +def generate_report_html( + md_path: Path, + json_path: Path, + output_path: Path, + include_toc: bool = True, + include_summary: bool = True, + cover_info: Optional[Dict[str, str]] = None +): + """ + MD와 JSON을 A4 HTML 볎고서로 변환 + + Args: + md_path: report_draft.md 겜로 + json_path: report_sections.json 겜로 + output_path: 출력할 report.html 겜로 + include_toc: 목찚 포핚 여부 + include_summary: 요앜 포핚 여부 + cover_info: 표지 정볎 (date, author, department 등) + """ + log("=== Step 9: MD → HTML 변환 시작 ===") + + # 1. 데읎터 로드 + log(f"JSON 로드: {json_path}") + report_title, sections = load_json_meta(json_path) + + log(f"MD 로드: {md_path}") + md_content = load_markdown(md_path) + + log(f"볎고서 제목: {report_title}") + log(f"섹션 수: {len(sections)}") + + # 2. 읎믞지 ë§µ 생성 + image_map = build_image_map(sections) + log(f"읎믞지 자산 수: {len(image_map)}") + + # 3. 목찚 추출 + toc_items = extract_toc_from_md(md_content) + log(f"목찚 항목 수: {len(toc_items)}") + + # 4. MD → HTML 변환 + converter = MarkdownToHtmlConverter(image_map) + content_html = converter.convert_full_content(md_content) + + # 5. 박슀별 윘텐잠 생성 + + # box-cover (표지) + cover_date = cover_info.get('date', datetime.now().strftime('%Y.%m.%d')) if cover_info else datetime.now().strftime('%Y.%m.%d') + cover_author = cover_info.get('author', '') if cover_info else '' + cover_dept = cover_info.get('department', '') if cover_info else '' + + # 제목에서 부제목 분늬 (: Ʞ쀀) + title_parts = report_title.split(':') + main_title = title_parts[0].strip() + sub_title = title_parts[1].strip() if len(title_parts) > 1 else "" + + box_cover = f''' +

                                      {main_title}

                                      +

                                      {sub_title}

                                      +

                                      {cover_date}

                                      + {f'

                                      {cover_author}

                                      ' if cover_author else ''} + {f'

                                      {cover_dept}

                                      ' if cover_dept else ''} + ''' + + # box-toc (목찚) + box_toc = "" + if include_toc and toc_items: + box_toc = generate_toc_html(toc_items) + log(f"목찚 HTML 생성 완료") + + # box-summary (요앜) - 첫 번짞 섹션을 요앜윌로 사용하거나 비워둠 + box_summary = "" + if include_summary: + # 요앜 섹션읎 있윌멎 사용 + for sec in sections: + if '요앜' in sec.section_title or 'summary' in sec.section_title.lower(): + summary_converter = MarkdownToHtmlConverter(image_map) + box_summary = f"

                                      요앜

                                      \n{summary_converter.convert_full_content(sec.generated_text)}" + break + + # box-content (볞묞) + box_content = content_html + + # 6. 템플늿에 죌입 + template = get_html_template() + html_output = template.format( + report_title=report_title, + box_cover=box_cover, + box_toc=box_toc, + box_summary=box_summary, + box_content=box_content + ) + + # 7. 파음 저장 + output_path.write_text(html_output, encoding='utf-8') + + log(f"") + log(f"═══════════════════════════════════════════════════") + log(f"HTML 볎고서 생성 완료!") + log(f" 출력 파음: {output_path}") + log(f" 파음 크Ʞ: {output_path.stat().st_size / 1024:.1f} KB") + log(f"═══════════════════════════════════════════════════") + log("=== Step 9 종료 ===") + + return output_path + + +def main(): + """CLI 진입점""" + parser = argparse.ArgumentParser( + description='MD + JSON → A4 HTML 볎고서 변환', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +예시: + python 9_md_to_html_publisher.py + python 9_md_to_html_publisher.py --md report_draft.md --json report_sections.json + python 9_md_to_html_publisher.py --no-toc --no-summary + python 9_md_to_html_publisher.py --cover-date "2026.01.15" --cover-author "홍Ꞟ동" + ''' + ) + + parser.add_argument('--md', type=Path, default=DEFAULT_MD_PATH, + help='입력 마크닀욎 파음 겜로') + parser.add_argument('--json', type=Path, default=DEFAULT_JSON_PATH, + help='입력 JSON 파음 겜로') + parser.add_argument('--output', '-o', type=Path, default=DEFAULT_OUTPUT_PATH, + help='출력 HTML 파음 겜로') + parser.add_argument('--no-toc', action='store_true', + help='목찚 페읎지 제왞') + parser.add_argument('--no-summary', action='store_true', + help='요앜 페읎지 제왞') + parser.add_argument('--cover-date', type=str, default=None, + help='표지 날짜 (예: 2026.01.15)') + parser.add_argument('--cover-author', type=str, default=None, + help='표지 작성자') + parser.add_argument('--cover-dept', type=str, default=None, + help='표지 부서명') + + args = parser.parse_args() + + # 표지 정볎 구성 + cover_info = {} + if args.cover_date: + cover_info['date'] = args.cover_date + if args.cover_author: + cover_info['author'] = args.cover_author + if args.cover_dept: + cover_info['department'] = args.cover_dept + + # 변환 싀행 + generate_report_html( + md_path=args.md, + json_path=args.json, + output_path=args.output, + include_toc=not args.no_toc, + include_summary=not args.no_summary, + cover_info=cover_info if cover_info else None + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/03. Code/geulbeot_9th/converters/style_analyzer.py b/03. Code/geulbeot_9th/converters/style_analyzer.py new file mode 100644 index 0000000..d195ae6 --- /dev/null +++ b/03. Code/geulbeot_9th/converters/style_analyzer.py @@ -0,0 +1,935 @@ +""" +HTML 슀타음 분석Ʞ v3.0 +HTML 요소륌 분석하여 역할(Role)을 자동 분류 + +✅ v3.0 변겜사항: +- Ꞁ벗 HTML 구조 완벜 지원 (.sheet, .body-content) +- 뚞늬말/ꌬ늬말/페읎지번혞 제거 +- 강력한 쀑복 윘텐잠 필터링 +- 제목 계잵 구조 정확한 읞식 +""" + +import re +from bs4 import BeautifulSoup, Tag, NavigableString +from dataclasses import dataclass +from typing import List, Dict, Optional, Tuple, Set +from enum import Enum + + +class DocumentSection(Enum): + """묞서 섹션 유형""" + COVER = "cover" # 표지 + TOC = "toc" # 목찚 + CONTENT = "content" # 볞묞 + + +@dataclass +class StyledElement: + """슀타음읎 지정된 요소""" + role: str # 역할 (H1, BODY, TH 등) + text: str # 텍슀튞 낎용 + tag: str # 원볞 HTML 태귞 + html: str # 원볞 HTML + section: str # 섹션 (cover, toc, content) + attributes: Dict # 추가 속성 (읎믞지 src 등) + + def __repr__(self): + preview = self.text[:30] + "..." if len(self.text) > 30 else self.text + return f"<{self.role}> {preview}" + + +class StyleAnalyzer: + """HTML 묞서륌 분석하여 역할 분류""" + + # 번혞 팹턮 정의 + PATTERNS = { + # 장 번혞: "제1장", "제2장" + "chapter": re.compile(r'^제\s*\d+\s*장'), + # 1닚계 제목: "1 ", "2 " (숫자+공백, 점 없음) + "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), + # 대항목: "1.", "2." + "h2_num": re.compile(r'^(\d+)\.\s'), + # 쀑항목: "1.1 ", "1.2 " + "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), + # 소항목: "1.1.1" + "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), + # 섞부: "1)", "2)" + "h5_paren": re.compile(r'^(\d+)\)\s*'), + # 섞섞부: "(1)", "(2)" + "h6_paren": re.compile(r'^\((\d+)\)\s*'), + # 가나닀: "가.", "나." + "h4_korean": re.compile(r'^[가-하]\.\s'), + # 가나닀 ꎄ혞: "가)", "나)" + "h5_korean": re.compile(r'^[가-하]\)\s'), + # 원묞자: "①", "②" + "h6_circle": re.compile(r'^[①②③④⑀⑥⑊⑧⑚⑩]'), + # 목록: "•", "-", "○" + "list_bullet": re.compile(r'^[•\-○]\s'), + # 페읎지 번혞 팹턮: "- 1 -", "- 12 -" + "page_number": re.compile(r'^-\s*\d+\s*-$'), + # ꌬ늬말 팹턮: "묞서제목- 1 -" + "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), + } + + # 제거할 텍슀튞 팚턎듀 + REMOVE_PATTERNS = [ + re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" + re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "묞서제목- 1 -" + re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (읎믞지 크Ʞ) + re.compile(r'^\[읎믞지 없음:.*\]$'), # "[읎믞지 없음: xxx]" + re.compile(r'^\[귞늌\s*\d+-\d+\]$'), # "[귞늌 1-1]" + ] + + def __init__(self): + self.elements: List[StyledElement] = [] + self.current_section = DocumentSection.CONTENT + self.seen_texts: Set[str] = set() # 쀑복 방지용 + self.document_title = "" # 묞서 제목 (ꌬ늬말 제거용) + + def analyze(self, html: str) -> List[StyledElement]: + """HTML 묞서 분석하여 역할 분류된 요소 늬슀튞 반환""" + soup = BeautifulSoup(html, 'html.parser') + self.elements = [] + self.seen_texts = set() + + # 1. 전처늬: 불필요한 요소 제거 + self._preprocess(soup) + + # 2. 묞서 제목 추출 (ꌬ늬말 팹턮 감지용) + self._extract_document_title(soup) + + # 3. 섹션 감지 및 순회 + self._detect_and_process_sections(soup) + + # 4. 후처늬: 쀑복 및 불필요 요소 제거 + self._postprocess() + + return self.elements + + def _preprocess(self, soup: BeautifulSoup): + """HTML 전처늬 - 불필요한 요소 제거""" + print(" 🔧 HTML 전처늬 쀑...") + + # 1. 슀크늜튞/슀타음 태귞 제거 + removed_count = 0 + for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): + tag.decompose() + removed_count += 1 + + if removed_count > 0: + print(f" - script/style 등 {removed_count}개 제거") + + # 2. 뚞늬말/ꌬ늬말 영역 제거 (Ꞁ벗 HTML 구조) + header_footer_count = 0 + for selector in ['.page-header', '.page-footer', '.header', '.footer', + '[class*="header"]', '[class*="footer"]', + '.running-header', '.running-footer']: + for elem in soup.select(selector): + # 싀제 윘텐잠 헀더가 아닌 페읎지 헀더만 제거 + text = elem.get_text(strip=True) + if self._is_header_footer_text(text): + elem.decompose() + header_footer_count += 1 + + if header_footer_count > 0: + print(f" - 뚞늬말/ꌬ늬말 {header_footer_count}개 제거") + + # 3. 숚겚진 요소 제거 + hidden_count = 0 + for elem in soup.select('[style*="display:none"], [style*="display: none"]'): + elem.decompose() + hidden_count += 1 + for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): + elem.decompose() + hidden_count += 1 + + # 4. #raw-container 왞부의 .sheet 제거 (Ꞁ벗 구조) + raw_container = soup.find(id='raw-container') + if raw_container: + print(" - Ꞁ벗 구조 감지: #raw-container 우선 사용") + # raw-container 왞부의 몚든 .sheet 제거 + for sheet in soup.select('.sheet'): + if not self._is_descendant_of(sheet, raw_container): + sheet.decompose() + + def _extract_document_title(self, soup: BeautifulSoup): + """묞서 제목 추출 (ꌬ늬말 팹턮 감지용)""" + # 표지에서 제목 ì°Ÿêž° + cover = soup.find(id='box-cover') or soup.find(class_='box-cover') + if cover: + h1 = cover.find('h1') + if h1: + self.document_title = h1.get_text(strip=True) + print(f" - 묞서 제목 감지: {self.document_title[:30]}...") + + def _is_header_footer_text(self, text: str) -> bool: + """뚞늬말/ꌬ늬말 텍슀튞읞지 판당""" + if not text: + return False + + # 페읎지 번혞 팹턮 + if self.PATTERNS['page_number'].match(text): + return True + + # "묞서제목- 1 -" 팹턮 + if self.PATTERNS['footer_pattern'].match(text): + return True + + # 묞서 제목 + 페읎지번혞 조합 + if self.document_title and self.document_title in text: + if re.search(r'[-–]\s*\d+\s*[-–]', text): + return True + + return False + + def _should_skip_text(self, text: str) -> bool: + """걎너뛞 텍슀튞읞지 판당""" + if not text: + return True + + # 제거 팹턮 첎크 + for pattern in self.REMOVE_PATTERNS: + if pattern.match(text): + return True + + # 뚞늬말/ꌬ늬말 첎크 + if self._is_header_footer_text(text): + return True + + # 묞서 제목만 있는 쀄 (ꌬ늬말에서 옚 것) + if self.document_title and text.strip() == self.document_title: + # 읎믞 표지에서 처늬했윌멎 슀킵 + if any(e.role == 'COVER_TITLE' and self.document_title in e.text + for e in self.elements): + return True + + return False + + def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: + """element가 ancestor의 자손읞지 확읞""" + parent = element.parent + while parent: + if parent == ancestor: + return True + parent = parent.parent + return False + + def _detect_and_process_sections(self, soup: BeautifulSoup): + """섹션 감지 및 처늬""" + + # Ꞁ벗 구조 (#raw-container) 우선 처늬 + raw = soup.find(id='raw-container') + if raw: + self._process_geulbeot_structure(raw) + return + + # .sheet 구조 처늬 (렌더링된 페읎지) + sheets = soup.select('.sheet') + if sheets: + self._process_sheet_structure(sheets) + return + + # 음반 HTML 구조 처늬 + self._process_generic_html(soup) + + def _process_geulbeot_structure(self, raw: Tag): + """Ꞁ벗 HTML #raw-container 구조 처늬""" + print(" 📄 Ꞁ벗 #raw-container 구조 처늬 쀑...") + + # 표지 + cover = raw.find(id='box-cover') + if cover: + print(" - 표지 섹션") + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = raw.find(id='box-toc') + if toc: + print(" - 목찚 섹션") + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 요앜 + summary = raw.find(id='box-summary') + if summary: + print(" - 요앜 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(summary) + + # 볞묞 + content = raw.find(id='box-content') + if content: + print(" - 볞묞 섹션") + self.current_section = DocumentSection.CONTENT + self._process_content_element(content) + + def _process_sheet_structure(self, sheets: List[Tag]): + """Ꞁ벗 .sheet 페읎지 구조 처늬""" + print(f" 📄 .sheet 페읎지 구조 처늬 쀑... ({len(sheets)}페읎지)") + + for i, sheet in enumerate(sheets): + # 페읎지 낮 body-content만 추출 + body_content = sheet.select_one('.body-content') + if body_content: + self._process_content_element(body_content) + else: + # body-content가 없윌멎 뚞늬말/ꌬ늬말 제왞하고 처늬 + for child in sheet.children: + if isinstance(child, Tag): + classes = child.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer']): + continue + + self._process_content_element(child) + + def _process_generic_html(self, soup: BeautifulSoup): + """음반 HTML 구조 처늬""" + print(" 📄 음반 HTML 구조 처늬 쀑...") + + # 표지 + cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) + if cover: + self.current_section = DocumentSection.COVER + self._process_cover(cover) + + # 목찚 + toc = soup.find(class_=re.compile(r'toc|table-of-contents')) + if toc: + self.current_section = DocumentSection.TOC + self._process_toc(toc) + + # 볞묞 + self.current_section = DocumentSection.CONTENT + main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup + + for child in main_content.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _process_cover(self, cover: Tag): + """표지 처늬""" + # H1 = 제목 + h1 = cover.find('h1') + if h1: + text = h1.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_TITLE", + text=text, + tag="h1", + html=str(h1)[:200], + section="cover", + attributes={} + )) + + # H2 = 부제목 + h2 = cover.find('h2') + if h2: + text = h2.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_SUBTITLE", + text=text, + tag="h2", + html=str(h2)[:200], + section="cover", + attributes={} + )) + + # P = 정볎 + for p in cover.find_all('p'): + text = p.get_text(strip=True) + if text and not self._is_duplicate(text): + self.elements.append(StyledElement( + role="COVER_INFO", + text=text, + tag="p", + html=str(p)[:200], + section="cover", + attributes={} + )) + + def _process_toc(self, toc: Tag): + """목찚 처늬""" + # UL/OL êž°ë°˜ 목찚 + for li in toc.find_all('li'): + text = li.get_text(strip=True) + if not text or self._is_duplicate(text): + continue + + classes = li.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 레벚 판당 (구첎적 → 음반 순서!) + if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: + role = "TOC_H1" + elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: + role = "TOC_H2" + elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: + role = "TOC_H3" + elif self.PATTERNS['h4_num'].match(text): # 1.1.1 뚌저! + role = "TOC_H3" + elif self.PATTERNS['h3_num'].match(text): # 1.1 귞닀음 + role = "TOC_H2" + elif self.PATTERNS['h2_num'].match(text): # 1. 귞닀음 + role = "TOC_H1" + else: + role = "TOC_H1" + + self.elements.append(StyledElement( + role=role, + text=text, + tag="li", + html=str(li)[:200], + section="toc", + attributes={} + )) + + def _process_content_element(self, element: Tag): + """볞묞 요소 재귀 처늬""" + if not isinstance(element, Tag): + return + + tag_name = element.name.lower() if element.name else "" + classes = element.get('class', []) + class_str = ' '.join(classes) if classes else '' + + # 뚞늬말/ꌬ늬말 큎래슀 슀킵 + if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): + return + + # 테읎랔 특수 처늬 + if tag_name == 'table': + self._process_table(element) + return + + # 귞늌 특수 처늬 + if tag_name in ['figure', 'img']: + self._process_figure(element) + return + + # 텍슀튞 추출 + text = self._get_direct_text(element) + + if text: + # 걎너뛞 텍슀튞 첎크 + if self._should_skip_text(text): + pass # 자식은 계속 처늬 + elif not self._is_duplicate(text): + role = self._classify_role(element, tag_name, classes, text) + if role: + self.elements.append(StyledElement( + role=role, + text=text, + tag=tag_name, + html=str(element)[:200], + section=self.current_section.value, + attributes=dict(element.attrs) if element.attrs else {} + )) + + # 자식 요소 재귀 처늬 (컚테읎너 태귞) + if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', + 'ul', 'ol', 'dl', 'blockquote']: + for child in element.children: + if isinstance(child, Tag): + self._process_content_element(child) + + def _get_direct_text(self, element: Tag) -> str: + """요소의 직접 텍슀튞만 추출 (자식 컚테읎너 제왞)""" + # 제목 태귞는 전첎 텍슀튞 + if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: + return element.get_text(strip=True) + + # 컚테읎너 태귞는 직접 텍슀튞만 + texts = [] + for child in element.children: + if isinstance(child, NavigableString): + t = str(child).strip() + if t: + texts.append(t) + + return ' '.join(texts) + + def _is_duplicate(self, text: str) -> bool: + """쀑복 텍슀튞읞지 확읞""" + if not text: + return True + + # 정규화 + normalized = re.sub(r'\s+', ' ', text.strip()) + + # 짧은 텍슀튞는 쀑복 허용 (번혞 등) + if len(normalized) < 10: + return False + + # 첫 50자로 첎크 + key = normalized[:50] + + if key in self.seen_texts: + return True + + self.seen_texts.add(key) + return False + + def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: + """요소의 역할 분류 + + ⚠ 쀑요: 팹턮 맀칭은 반드시 구첎적읞 것 → 음반적읞 것 순서로! + 1.1.1 → 1.1 → 1. → 1 + (1) → 1) + 가) → 가. + """ + + class_str = ' '.join(classes) if classes else '' + + # ============ 제목 태귞 (HTML 태귞 우선) ============ + if tag == 'h1': + return "H1" + if tag == 'h2': + return "H2" + if tag == 'h3': + return "H3" + if tag == 'h4': + return "H4" + if tag == 'h5': + return "H5" + if tag == 'h6': + return "H6" + + # ============ 볞묞 (p, div 등) - 번혞 팚턎윌로 분류 ============ + if tag in ['p', 'div', 'span']: + + # ------ 숫자.숫자 팹턮 (구첎적 → 음반 순서!) ------ + + # "1.1.1" 팹턮 (가장 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h4_num'].match(text): + if len(text) < 100: + return "H3" + return "BODY" + + # "1.1 " 팹턮 + if self.PATTERNS['h3_num'].match(text): + if len(text) < 100: + return "H2" + return "BODY" + + # "1." 팹턮 + if self.PATTERNS['h2_num'].match(text): + if len(text) < 100: + return "H1" + return "BODY" + + # "1 가나닀..." 팹턮 (숫자+공백+한Ꞁ) + if self.PATTERNS['h1_num'].match(text): + return "H1" + + # ------ ꎄ혞 팹턮 (구첎적 → 음반 순서!) ------ + + # "(1)" 팹턮 (ꎄ혞로 감싌 게 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h6_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H5" + return "BODY" + + # "1)" 팹턮 + if self.PATTERNS['h5_paren'].match(text): + if element.find('strong') or len(text) < 80: + return "H4" + return "BODY" + + # ------ 한Ꞁ 팹턮 (구첎적 → 음반 순서!) ------ + + # "가)" 팹턮 (ꎄ혞가 더 구첎적 - 뚌저 첎크!) + if self.PATTERNS['h5_korean'].match(text): + return "H5" + + # "가." 팹턮 + if self.PATTERNS['h4_korean'].match(text): + return "H4" + + # ------ 특수 Ʞ혞 팹턮 ------ + + # "①②③" 팹턮 + if self.PATTERNS['h6_circle'].match(text): + return "H6" + + # ------ Ʞ타 ------ + + # 강조 박슀 + if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): + return "HIGHLIGHT_BOX" + + # 음반 볞묞 + return "BODY" + + # ============ 목록 ============ + if tag == 'li': + return "LIST_ITEM" + + # ============ 정의 목록 ============ + if tag == 'dt': + return "H5" + if tag == 'dd': + return "BODY" + + return "BODY" + + def _process_table(self, table: Tag): + """테읎랔 처늬 - 구조 데읎터 포핚""" + + # 캡션 + caption = table.find('caption') + caption_text = "" + if caption: + caption_text = caption.get_text(strip=True) + if caption_text and not self._is_duplicate(caption_text): + self.elements.append(StyledElement( + role="TABLE_CAPTION", + text=caption_text, + tag="caption", + html=str(caption)[:100], + section=self.current_section.value, + attributes={} + )) + + # 🆕 표 구조 데읎터 수집 + table_data = {'rows': [], 'caption': caption_text} + + for tr in table.find_all('tr'): + row = [] + for cell in tr.find_all(['th', 'td']): + cell_info = { + 'text': cell.get_text(strip=True), + 'is_header': cell.name == 'th', + 'colspan': int(cell.get('colspan', 1)), + 'rowspan': int(cell.get('rowspan', 1)), + 'bg_color': self._extract_bg_color(cell), + } + row.append(cell_info) + if row: + table_data['rows'].append(row) + + # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) + if table_data['rows']: + self.elements.append(StyledElement( + role="TABLE", + text=f"[표: {len(table_data['rows'])}행]", + tag="table", + html=str(table)[:200], + section=self.current_section.value, + attributes={'table_data': table_data} + )) + + def _extract_bg_color(self, element: Tag) -> str: + """요소에서 배겜색 추출""" + style = element.get('style', '') + + # background-color 추출 + match = re.search(r'background-color:\s*([^;]+)', style) + if match: + return self._normalize_color(match.group(1)) + + # bgcolor 속성 + bgcolor = element.get('bgcolor', '') + if bgcolor: + return self._normalize_color(bgcolor) + + return '' + + def _process_figure(self, element: Tag): + """귞늌 처늬""" + img = element.find('img') if element.name == 'figure' else element + + if img and img.name == 'img': + src = img.get('src', '') + alt = img.get('alt', '') + + if src: # src가 있을 때만 추가 + self.elements.append(StyledElement( + role="FIGURE", + text=alt or "읎믞지", + tag="img", + html=str(img)[:100], + section=self.current_section.value, + attributes={"src": src, "alt": alt} + )) + + # 캡션 + if element.name == 'figure': + figcaption = element.find('figcaption') + if figcaption: + text = figcaption.get_text(strip=True) + if text and not self._should_skip_text(text): + self.elements.append(StyledElement( + role="FIGURE_CAPTION", + text=text, + tag="figcaption", + html=str(figcaption)[:100], + section=self.current_section.value, + attributes={} + )) + + def _postprocess(self): + """후처늬: 불필요 요소 제거""" + print(f" 🧹 후처늬 쀑... (처늬 전: {len(self.elements)}개)") + + filtered = [] + for elem in self.elements: + # 빈 텍슀튞 제거 + if not elem.text or not elem.text.strip(): + continue + + # 뚞늬말/ꌬ늬말 텍슀튞 제거 + if self._is_header_footer_text(elem.text): + continue + + # 제거 팹턮 첎크 + skip = False + for pattern in self.REMOVE_PATTERNS: + if pattern.match(elem.text.strip()): + skip = True + break + + if not skip: + filtered.append(elem) + + self.elements = filtered + print(f" - 처늬 후: {len(self.elements)}개") + + def get_role_summary(self) -> Dict[str, int]: + """역할별 요소 수 요앜""" + summary = {} + for elem in self.elements: + summary[elem.role] = summary.get(elem.role, 0) + 1 + return dict(sorted(summary.items())) + + + def extract_css_styles(self, html: str) -> Dict[str, Dict]: + """ + HTML에서 역할별 CSS 슀타음 추출 + Returns: {역할: {font_size, color, bold, ...}} + """ + soup = BeautifulSoup(html, 'html.parser') + role_styles = {} + + # + + +
                                      + +
                                      +

                                      1 DX 개요와 Ʞ볞 개념

                                      +

                                      1.1 잡량 DX 프레임

                                      +

                                      1.1.1 잡량 DX 발전 닚계

                                      +

                                      1) Digitization 정의

                                      +

                                      볞묞 낎용입니닀. 읎것은 충분히 ꞎ 텍슀튞로 볞묞윌로 읞식되얎알 합니닀.

                                      +

                                      (1) 닚계별 정의 및 진화

                                      +

                                      잡량 Ʞ술의 발전은 장비의 변화와 성곌묌의 찚원에 따띌 구분된닀.

                                      +
                                      + +
                                      + +
                                      + +
                                      +

                                      ① 첫 번짞 항목

                                      + + + + +
                                      표 1. 데읎터 비교
                                      구분낎용
                                      항목1섀명1
                                      +
                                      + +
                                      + + + """ + + analyzer = StyleAnalyzer() + elements = analyzer.analyze(test_html) + + print("\n" + "="*60) + print("분석 결곌") + print("="*60) + for elem in elements: + print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") + + print("\n" + "="*60) + print("역할 요앜") + print("="*60) + for role, count in analyzer.get_role_summary().items(): + print(f" {role}: {count}") \ No newline at end of file diff --git a/03. Code/geulbeot_9th/domain/__init__.py b/03. Code/geulbeot_9th/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_9th/domain/hwpx/__init__.py b/03. Code/geulbeot_9th/domain/hwpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03. Code/geulbeot_9th/domain/hwpx/hwpx_domain_guide.md b/03. Code/geulbeot_9th/domain/hwpx/hwpx_domain_guide.md new file mode 100644 index 0000000..da48039 --- /dev/null +++ b/03. Code/geulbeot_9th/domain/hwpx/hwpx_domain_guide.md @@ -0,0 +1,769 @@ +# HWP/HWPX ↔ HTML/CSS 도메읞 가읎드 + +> **목적**: HWPX에서 묞서 유형·슀타음·템플늿을 추출하거나, HTML → HWPX → HWP 변환 시 +> 하드윔딩 없읎 읎 가읎드륌 찞조하여 정확한 맀핑을 수행한닀. +> **출처**: 한Ꞁ곌컎퓚터 공식 "Ꞁ 묞서 파음 구조 5.0" (revision 1.3, 2018-11-08) +> **범위**: HWP 5.0 바읎너늬 슀펙의 개념 첎계 + HWPX XML 태귞 + HTML/CSS 맀핑 + +--- + +## 0. 묞서 형식 ꎀ계 + +``` +HWP (바읎너늬) HWPX (XML) HTML/CSS +───────────────── ───────────────────── ───────────────── +Compound File ZIP Archive 닚음 HTML 파음 +├─ FileHeader ├─ META-INF/ ├─ +├─ DocInfo │ └─ manifest.xml │ ├─ +│ (Ꞁꌎ, 슀타음, ├─ Contents/ │ └─ + + +
                                      + +
                                      +

                                      {{title}}

                                      +
                                      +
                                      +
                                      +
                                      +
                                      {{lead.text}} - 킀워드 강조
                                      +
                                      + +
                                      +
                                      {{conclusion.label}}
                                      +
                                      {{conclusion.text}}
                                      +
                                      +
                                      +
                                      - 1 -
                                      +
                                      + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                                      +
                                      {{section.title}}
                                      +
                                        +
                                      • {{item.keyword}}: {{item.text}} {{highlight}}
                                      • +
                                      +
                                      +``` + +### table → data-table +```html +
                                      +
                                      {{section.title}}
                                      + + + + + + + + + + + + + +
                                      {{col1}}{{col2}}
                                      {{text}}{{text}}
                                      +
                                      +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                                      +
                                      {{section.title}}
                                      +
                                      +
                                      +
                                      {{item.title}}
                                      +

                                      {{item.text}} {{highlight}}

                                      +
                                      +
                                      +
                                      +``` + +### two-column → two-col +```html +
                                      +
                                      {{section.title}}
                                      +
                                      +
                                      +
                                      {{item.title}}
                                      +

                                      {{item.text}} {{highlight}}

                                      +
                                      +
                                      +
                                      +``` + +### process → process-container +```html +
                                      +
                                      {{section.title}}
                                      +
                                      +
                                      +
                                      {{step.number}}
                                      +
                                      {{step.title}}: {{step.text}}
                                      +
                                      +
                                      ▌
                                      + +
                                      +
                                      +``` + +### qa → qa-grid +```html +
                                      +
                                      {{section.title}}
                                      +
                                      +
                                      + Q. {{question}}
                                      + A. {{answer}} +
                                      +
                                      +
                                      +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                                      [첚부] 핎당 낎용에 맞는 제목

                                      ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                                      [첚부] 핎당 페읎지 낎용에 맞는 제목

                                      ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/common.py b/03. Code/geulbeot_9th/handlers/common.py new file mode 100644 index 0000000..bb63382 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +공통 유틞늬티 핚수 +- Claude API 혞출 +- JSON/HTML 추출 +""" + +import os +import re +import json +import anthropic +from api_config import API_KEYS + +# Claude API 큎띌읎얞튞 +client = anthropic.Anthropic( + api_key=API_KEYS.get('CLAUDE_API_KEY', '') +) + + +def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str: + """Claude API 혞출""" + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=max_tokens, + system=system_prompt, + messages=[{"role": "user", "content": user_message}] + ) + return response.content[0].text + + +def extract_json(text: str) -> dict: + """텍슀튞에서 JSON 추출""" + # 윔드 랔록 제거 + if '```json' in text: + text = text.split('```json')[1].split('```')[0] + elif '```' in text: + text = text.split('```')[1].split('```')[0] + + text = text.strip() + + # JSON 파싱 시도 + try: + return json.loads(text) + except json.JSONDecodeError: + # JSON 부분만 추출 시도 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group()) + except: + pass + return None + + +def extract_html(text: str) -> str: + """텍슀튞에서 HTML 추출""" + # 윔드 랔록 제거 + if '```html' in text: + text = text.split('```html')[1].split('```')[0] + elif '```' in text: + parts = text.split('```') + if len(parts) >= 2: + text = parts[1] + + text = text.strip() + + # )', text, re.IGNORECASE) + if match: + text = match.group(1) + + return text + + +def load_prompt(prompts_dir: str, filename: str) -> str: + """프롬프튞 파음 로드""" + prompt_path = os.path.join(prompts_dir, filename) + try: + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + return None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/content_analyzer.py b/03. Code/geulbeot_9th/handlers/content_analyzer.py new file mode 100644 index 0000000..47ae5ed --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/content_analyzer.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +""" +Content Analyzer (Phase 3 — Layer A) +- template_info + semantic_map → content_prompt.json +- 각 placeholder의 의믞/유형/예시값/작성 팹턮 추출 +- Phase 5에서 AI가 새 묞서 생성 시 "레시플"로 ì°žì¡° + +★ 원칙: 몚든 분류는 윔드 100% (AI 없음) + purpose_hint / audience_hint / tone_hint는 빈 묞자엎로 낚김 + → 추후 AI enrichment 닚계에서 채욞 수 있도록 섀계 +""" + +import re + + +def generate(template_info: dict, semantic_map: dict, + parsed: dict = None) -> dict: + """ + content_prompt.json 생성 + + Args: + template_info: doc_template_analyzer 추출 결곌 + semantic_map: semantic_mapper 분류 결곌 + parsed: HWPX 파싱 원볞 (선택) + + Returns: + content_prompt.json 구조 + """ + placeholders = {} + table_guide = {} + + # ① 묞서 Ʞ볞 정볎 + document = _analyze_document(template_info) + + # ② 헀더 placeholders + _analyze_header(template_info, placeholders) + + # ③ 푾터 placeholders + _analyze_footer(template_info, placeholders) + + # ④ 제목 placeholder + _analyze_title(template_info, semantic_map, placeholders) + + # â‘€ 섹션 placeholders + _analyze_sections(semantic_map, placeholders, template_info) + + # â‘€-b content_order êž°ë°˜ 묞닚/읎믞지 placeholders + _analyze_content_order(template_info, semantic_map, placeholders) + + # ⑥ 표 가읎드 + placeholders + _analyze_tables(template_info, semantic_map, + placeholders, table_guide) + + # ⑩ 작성 팹턮 + writing_guide = _analyze_writing_patterns(template_info, semantic_map) + + return { + "version": "1.0", + "document": document, + "placeholders": placeholders, + "table_guide": table_guide, + "writing_guide": writing_guide + } + + +# ================================================================ +# 묞서 Ʞ볞 정볎 +# ================================================================ + +def _analyze_document(template_info: dict) -> dict: + """묞서 레벚 정볎 추출""" + page = template_info.get("page", {}) + paper = page.get("paper", {}) + + return { + "paper": paper.get("name", "A4"), + "layout": "landscape" if paper.get("landscape") else "portrait", + "margins": page.get("margins", {}), + "purpose_hint": "", # AI enrichment 예앜 + "audience_hint": "", # AI enrichment 예앜 + "tone_hint": "" # AI enrichment 예앜 + } + + +# ================================================================ +# 텍슀튞 유형 분류 (윔드 100%, AI 없음) +# ================================================================ + +def _classify_text(text: str) -> dict: + """텍슀튞 팚턎윌로 윘텐잠 유형 분류""" + text = text.strip() + if not text: + return {"type": "empty", "pattern": "빈 셀"} + + # 날짜: "2025. 1. 30(ꞈ)", "2025-01-30", "2025.01.30" + if re.match(r'\d{4}[\.\-/]\s*\d{1,2}[\.\-/]\s*\d{1,2}', text): + return {"type": "date", "pattern": "날짜 (YYYY. M. D)"} + + # ★ 직꞉+읎늄 (부서볎닀 뚌저!) + positions = [ + '사원', '대늬', '곌장', '찚장', '부장', '읎사', '상묎', '전묎', + '연구원', '선임연구원', '책임연구원', '수석연구원', + '죌임', '계장', '팀장', '싀장', '부서장', '섌터장' + ] + for pos in positions: + if pos in text: + return {"type": "author", "pattern": f"읎늄 + 직꞉({pos})"} + + # 부서 (직꞉ 아닌 것만 여Ʞ로) + if re.search(r'(ì‹€|부|êµ­|곌|원|처|섌터|볞부)$', text) and len(text) <= 12: + return {"type": "department", "pattern": "조직명"} + + # 팀 + if re.search(r'팀$', text) and len(text) <= 10: + return {"type": "team", "pattern": "팀명"} + + # 페읎지 ì°žì¡°: "1p", "2p" + if re.match(r'\d+p$', text): + return {"type": "page_ref", "pattern": "페읎지 ì°žì¡°"} + + # 묞서 제목: ~계획(안), ~볎고서, ~제안서 등 + if re.search(r'(계획|볎고서|제안서|Ʞ획서|결곌|방안|현황|분석)' + r'\s*(\(안\))?\s*$', text): + return {"type": "doc_title", "pattern": "묞서 제목"} + + # 슬로걎/비전 (êžžê³  추상적 킀워드 포핚) + if len(text) > 10 and any(k in text for k in + ['핚께', '섞상', '믞래', '가치', '만듀얎']): + return {"type": "slogan", "pattern": "회사 슬로걎/비전"} + + # Ʞ볞 + return {"type": "text", "pattern": "자유 텍슀튞"} + + +# ================================================================ +# 헀더 분석 +# ================================================================ + +def _analyze_header(template_info: dict, placeholders: dict): + """헀더 영역 placeholder 분석""" + header = template_info.get("header", {}) + if not header or not header.get("exists"): + return + + if header.get("type") == "table" and header.get("table"): + _analyze_table_area(header["table"], "HEADER", "header", + placeholders) + else: + texts = header.get("texts", []) + for i in range(max(len(texts), 1)): + ph = f"HEADER_TEXT_{i+1}" + example = texts[i] if i < len(texts) else "" + info = _classify_text(example) + info["example"] = example.strip() + info["location"] = "header" + placeholders[ph] = info + + +# ================================================================ +# 푾터 분석 +# ================================================================ + +def _analyze_footer(template_info: dict, placeholders: dict): + """푾터 영역 placeholder 분석""" + footer = template_info.get("footer", {}) + if not footer or not footer.get("exists"): + return + + if footer.get("type") == "table" and footer.get("table"): + _analyze_table_area(footer["table"], "FOOTER", "footer", + placeholders) + else: + placeholders["PAGE_NUMBER"] = { + "type": "page_number", + "pattern": "페읎지 번혞", + "example": "1", + "location": "footer" + } + + +# ================================================================ +# 헀더/푾터 공통: 표 형태 영역 분석 +# ================================================================ + +def _analyze_table_area(tbl: dict, prefix: str, location: str, + placeholders: dict): + """표 형태의 헀더/푾터 → placeholder 맀핑 + + Args: + tbl: header["table"] 또는 footer["table"] + prefix: "HEADER" 또는 "FOOTER" + location: "header" 또는 "footer" + placeholders: 결곌 dict (in-place 수정) + """ + rows = tbl.get("rows", []) + + for r_idx, row in enumerate(rows): + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + + if len(lines) > 1: + for l_idx, line_text in enumerate(lines): + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}_LINE_{l_idx+1}" + info = _classify_text(line_text) + info["example"] = line_text.strip() + info["location"] = location + placeholders[ph] = info + elif lines: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(lines[0]) + info["example"] = lines[0].strip() + info["location"] = location + placeholders[ph] = info + else: + ph = f"{prefix}_R{r_idx+1}_C{c_idx+1}" + placeholders[ph] = { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": location + } + + +# ================================================================ +# 제목 분석 +# ================================================================ + +def _analyze_title(template_info: dict, semantic_map: dict, + placeholders: dict): + """제목 랔록 placeholder 분석 + + ★ v1.1: template_manager._build_title_block_html()곌 동음한 + TITLE_R{r}_C{c} 명명 규칙 사용 (범용 맀핑) + """ + title_idx = semantic_map.get("title_table") + if title_idx is None: + return + + tables = template_info.get("tables", []) + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if not title_tbl: + return + + # 각 셀별로 placeholder 생성 (template곌 동음한 읎늄) + for r_idx, row in enumerate(title_tbl.get("rows", [])): + for c_idx, cell in enumerate(row): + cell_text = cell.get("text", "").strip() + if not cell_text: + continue # 빈 셀은 template에서도 placeholder 없음 + + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + info = _classify_text(cell_text) + if "title" not in info["type"] and "doc_title" not in info["type"]: + # 제목표 안의 텍슀튞가 doc_title읎 아닐 수도 있음 (부제 등) + # 가장 ꞎ 텍슀튞만 doc_title로 분류 + pass + info["example"] = cell_text + info["location"] = "title_block" + placeholders[ph_name] = info + + # 가장 ꞎ 텍슀튞륌 가진 셀을 doc_title로 마킹 + longest_ph = None + longest_len = 0 + for ph_key in list(placeholders.keys()): + if ph_key.startswith("TITLE_R"): + ex = placeholders[ph_key].get("example", "") + if len(ex) > longest_len: + longest_len = len(ex) + longest_ph = ph_key + if longest_ph: + placeholders[longest_ph]["type"] = "doc_title" + placeholders[longest_ph]["pattern"] = "묞서 제목" + + +# ================================================================ +# 섹션 분석 +# ================================================================ + +def _analyze_sections(semantic_map: dict, placeholders: dict, + template_info: dict = None): + """섹션 placeholder 분석. + + content_order에 묞닚읎 있윌멎 SECTION_n_CONTENT는 생략 + (개별 PARA_n읎 볞묞 역할을 대신핚). + """ + sections = semantic_map.get("sections", []) + + # content_order에 묞닚읎 있윌멎 개별 PARA_n읎 볞묞 닎당 → CONTENT 불필요 + has_co_paragraphs = False + if template_info: + co = template_info.get("content_order", []) + has_co_paragraphs = any(c['type'] == 'paragraph' for c in co) if co else False + + if not sections: + placeholders["SECTION_1_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": "", "location": "body" + } + if not has_co_paragraphs: + placeholders["SECTION_1_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + return + + for i, sec in enumerate(sections): + s_num = i + 1 + title_text = sec if isinstance(sec, str) else sec.get("title", "") + + placeholders[f"SECTION_{s_num}_TITLE"] = { + "type": "section_title", "pattern": "섹션 제목", + "example": title_text, "location": "body" + } + if not has_co_paragraphs: + placeholders[f"SECTION_{s_num}_CONTENT"] = { + "type": "section_content", "pattern": "섹션 볞묞", + "example": "", "location": "body" + } + +# ================================================================ +# content_order êž°ë°˜ 묞닚/읎믞지 분석 (v5.2+) +# ================================================================ + +def _analyze_content_order(template_info: dict, semantic_map: dict, + placeholders: dict): + """content_order의 paragraph/image → PARA_n, IMAGE_n placeholder 생성. + + content_order가 없거나 묞닚읎 없윌멎 아묎것도 안 핹 (legacy 혾환). + """ + content_order = template_info.get("content_order") + if not content_order: + return + if not any(c['type'] == 'paragraph' for c in content_order): + return + + # 섹션 제목 팹턮 (template_manager와 동음) + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + para_num = 0 + img_num = 0 + section_num = 0 + + for item in content_order: + itype = item['type'] + + if itype == 'empty': + continue + + # ── 표: _analyze_tables에서 처늬 → 걎너뛰Ʞ ── + if itype == 'table': + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + placeholders[f"IMAGE_{img_num}"] = { + "type": "image", + "pattern": "읎믞지", + "example_ref": item.get("binaryItemIDRef", ""), + "location": "body" + } + caption = item.get("text", "") + if caption: + placeholders[f"IMAGE_{img_num}_CAPTION"] = { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": caption, + "location": "body" + } + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + + # 섹션 제목 → SECTION_n_TITLE (읎믞 _analyze_sections에서 등록됐을 수 있음) + if any(p.match(text) for p in sec_patterns): + section_num += 1 + ph = f"SECTION_{section_num}_TITLE" + if ph not in placeholders: + placeholders[ph] = { + "type": "section_title", + "pattern": "섹션 제목", + "example": text, + "location": "body" + } + continue + + # 음반 묞닚 + para_num += 1 + runs = item.get('runs', []) + + if len(runs) > 1: + # 닀쀑 run → 각 run별 placeholder + for r_idx, run in enumerate(runs): + ph = f"PARA_{para_num}_RUN_{r_idx+1}" + run_text = run.get("text", "") + info = _classify_text(run_text) + info["example"] = run_text[:100] if len(run_text) > 100 else run_text + info["location"] = "body" + info["run_index"] = r_idx + 1 + placeholders[ph] = info + else: + ph = f"PARA_{para_num}" + info = _classify_text(text) + info["example"] = text[:100] if len(text) > 100 else text + info["location"] = "body" + placeholders[ph] = info + + +# ================================================================ +# 표 분석 → placeholder + 표 가읎드 +# ================================================================ + +def _analyze_tables(template_info: dict, semantic_map: dict, + placeholders: dict, table_guide: dict): + """볞묞 데읎터 표 → placeholder + table_guide""" + tables = template_info.get("tables", []) + body_indices = semantic_map.get("body_tables", []) + table_roles = semantic_map.get("table_roles", {}) + + for tbl_num_0, tbl_idx in enumerate(body_indices): + tbl_num = tbl_num_0 + 1 + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + role_info = table_roles.get(tbl_idx, table_roles.get(str(tbl_idx), {})) + col_headers = role_info.get("col_headers", []) + col_cnt = len(col_headers) if col_headers else tbl.get("colCnt", 0) + + # ── 헀더 placeholder ── + for c_idx, h_text in enumerate(col_headers): + ph = f"TABLE_{tbl_num}_H_C{c_idx+1}" + placeholders[ph] = { + "type": "table_header", "pattern": "표 ì—Ž 제목", + "example": h_text, "location": f"table_{tbl_num}" + } + + # ── BODY placeholder ── + placeholders[f"TABLE_{tbl_num}_BODY"] = { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML 반복)", + "example": "", + "location": f"table_{tbl_num}" + } + + # ── 표 가읎드 ── + table_guide[str(tbl_num)] = { + "col_headers": col_headers, + "col_count": col_cnt, + "row_count": tbl.get("rowCnt", 0), + "merge_pattern": _detect_merge_pattern(tbl), + "bullet_chars": _detect_bullet_chars(tbl), + "example_rows": _extract_example_rows(tbl, role_info), + "col_types": _classify_columns(col_headers), + "row_bf_pattern": _extract_row_bf_pattern(tbl, role_info), + } + + +def _detect_merge_pattern(tbl: dict) -> dict: + """셀 병합 팹턮 감지""" + pattern = {} + for row in tbl.get("rows", []): + for cell in row: + col = cell.get("colAddr", 0) + if cell.get("rowSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "row_group") + if cell.get("colSpan", 1) > 1: + pattern.setdefault(f"col_{col}", "col_span") + return pattern + + +def _detect_bullet_chars(tbl: dict) -> list: + """표 셀 텍슀튞에서 불늿 묞자 감지""" + bullets = set() + pats = [ + (r'^-\s', '- '), (r'^·\s', '· '), (r'^•\s', '• '), + (r'^▾\s', '▾ '), (r'^▶\s', '▶ '), (r'^※\s', '※ '), + (r'^◈\s', '◈ '), (r'^○\s', '○ '), (r'^●\s', '● '), + ] + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + for pat, char in pats: + if re.match(pat, line.strip()): + bullets.add(char) + return sorted(bullets) + + +def _extract_example_rows(tbl: dict, role_info: dict) -> list: + """데읎터 행에서 예시 최대 3행 추출""" + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + examples = [] + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + row_data = [] + for cell in row: + text = cell.get("text", "").strip() + if len(text) > 80: + text = text[:77] + "..." + row_data.append(text) + examples.append(row_data) + if len(examples) >= 3: + break + return examples + + +def _classify_columns(col_headers: list) -> list: + """ì—Ž 헀더 킀워드로 용도 추론""" + type_map = { + "category": ['구분', '분류', '항목', '칎테고늬'], + "content": ['낎용', '섀명', '상섞', '섞부낎용'], + "note": ['비고', 'ì°žê³ ', 'Ʞ타', '메몚'], + "date": ['날짜', '음자', '음시', 'êž°ê°„'], + "person": ['닎당', '닎당자', '작성자', '책임'], + "number": ['수량', 'ꞈ액', '닚가', '합계'], + } + result = [] + for c_idx, header in enumerate(col_headers): + h = header.strip() + col_type = "text" + for t, keywords in type_map.items(): + if h in keywords: + col_type = t + break + result.append({"col": c_idx, "type": col_type, "header": h}) + return result + +def _extract_row_bf_pattern(tbl: dict, role_info: dict) -> list: + """첫 데읎터행의 셀별 borderFillIDRef → 엎별 bf class 팹턮. + + AI가 TABLE_BODY 생성 시 class="bf-{id}" 적용하도록 안낎. + 예: [{"col": 0, "bf_class": "bf-12"}, {"col": 1, "bf_class": "bf-8"}, ...] + """ + rows = tbl.get("rows", []) + header_row = role_info.get("header_row") + if header_row is None: + header_row = -1 + + # 첫 데읎터행 ì°Ÿêž° + for r_idx, row in enumerate(rows): + if r_idx <= header_row: + continue + pattern = [] + for cell in row: + bf_id = cell.get("borderFillIDRef") + pattern.append({ + "col": cell.get("colAddr", len(pattern)), + "bf_class": f"bf-{bf_id}" if bf_id else "", + "colSpan": cell.get("colSpan", 1), + "rowSpan": cell.get("rowSpan", 1), + }) + return pattern + + return [] +# ================================================================ +# 작성 팹턮 분석 +# ================================================================ + +def _analyze_writing_patterns(template_info: dict, + semantic_map: dict) -> dict: + """묞서 전첎의 작성 팹턮 분석""" + result = { + "bullet_styles": [], + "numbering_patterns": [], + "avg_line_length": 0, + "font_primary": "", + "font_size_body": "" + } + + # ── 불늿 수집 (몚든 표 텍슀튞) ── + all_bullets = set() + tables = template_info.get("tables", []) + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if re.match(r'^[-·•▞▶※◈○●]\s', line.strip()): + all_bullets.add(line.strip()[0] + " ") + + # ── numbering tools 데읎터 ── + numbering = template_info.get("numbering", {}) + for num in numbering.get("numberings", []): + levels = num.get("levels", []) + patterns = [lv.get("pattern", "") for lv in levels[:3]] + if patterns: + result["numbering_patterns"].append(patterns) + + for b in numbering.get("bullets", []): + char = b.get("char", "") + if char: + all_bullets.add(char + " ") + + result["bullet_styles"] = sorted(all_bullets) + +# ── 평균 띌읞 Ꞟ읎 ── + lengths = [] + for tbl in tables: + for row in tbl.get("rows", []): + for cell in row: + for line in cell.get("lines", []): + if line.strip(): + lengths.append(len(line.strip())) + + # content_order 묞닚 텍슀튞도 포핚 + content_order = template_info.get("content_order", []) + for item in content_order: + if item['type'] == 'paragraph': + text = item.get('text', '').strip() + if text: + lengths.append(len(text)) + # 불늿 감지도 추가 + if re.match(r'^[-·•▞▶※◈○●]\s', text): + all_bullets.add(text[0] + " ") + + if lengths: + result["avg_line_length"] = round(sum(lengths) / len(lengths)) + + # ── 죌요 폰튾 ── + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + result["font_primary"] = hangul[0].get("face", "") + + # ── 볞묞 Ꞁ자 크Ʞ (char_styles id=0 Ʞ볞) ── + char_styles = template_info.get("char_styles", []) + if char_styles: + result["font_size_body"] = f"{char_styles[0].get('height_pt', 10)}pt" + + return result \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/custom_doc_type.py b/03. Code/geulbeot_9th/handlers/custom_doc_type.py new file mode 100644 index 0000000..c07daf1 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/custom_doc_type.py @@ -0,0 +1,555 @@ +# -*- coding: utf-8 -*- +""" +사용자 정의 묞서 유형 프로섞서 (v2.1 - 템플늿 êž°ë°˜) +- template.html 로드 +- config.json의 구조/가읎드 활용 +- 사용자 입력 낎용을 템플늿에 정늬하여 채움 +- 찜작 X, 정늬/재구성 O + +★ v2.1 변겜사항: +- 한Ꞁ 포핚 placeholder 지원 (TABLE_1_H_구분 등) +- TABLE_*_BODY / TABLE_*_H_* placeholder 구분 처늬 +- 개조식 항목
                                        래핑 +- 페읎지 분량 제한 프롬프튞 강화 +- 헀더/푾터 닀쀑행 placeholder 섀명 추가 +""" + +import json +import re +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from .template_manager import TemplateManager +from pathlib import Path +from .common import call_claude, extract_html + + +# ★ 한Ꞁ 포핚 placeholder 정규식 (영묞 + 숫자 + 얞더슀윔얎 + 한Ꞁ) +PH_PATTERN = re.compile(r'\{\{([A-Za-z0-9_\uAC00-\uD7AF]+)\}\}') + + +class CustomDocTypeProcessor: + """사용자 정의 묞서 유형 처늬Ʞ (템플늿 êž°ë°˜)""" + + def __init__(self): + self.doc_types_user = Path('templates/user/doc_types') + self.template_manager = TemplateManager() + + def load_config(self, doc_type_id: str) -> dict: + """config.json 로드""" + config_path = self.doc_types_user / doc_type_id / 'config.json' + if not config_path.exists(): + raise FileNotFoundError(f"묞서 유형을 찟을 수 없습니닀: {doc_type_id}") + + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + + def load_content_prompt(self, doc_type_id: str, template_id: str = None) -> dict: + """content_prompt.json 로드 (doc_type 우선 → template fallback)""" + # ① doc_type 폮더 + path = self.doc_types_user / doc_type_id / 'content_prompt.json' + if path.exists(): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + # ② template 폮더 fallback + if template_id: + tpl_path = Path('templates/user/templates') / template_id / 'content_prompt.json' + if tpl_path.exists(): + with open(tpl_path, 'r', encoding='utf-8') as f: + return json.load(f) + + return {} + + def load_template(self, doc_type_id: str) -> str: + """template.html 로드 — template_manager 겜유 (분늬 구조)""" + # ① config에서 template_id 확읞 + config = self.load_config(doc_type_id) + tpl_id = config.get("template_id") + + if tpl_id: + # ★ 새 구조: template_manager에서 로드 + tpl_data = self.template_manager.load_template(tpl_id) + if "html" in tpl_data: + return tpl_data["html"] + + # ★ 하위 혾환: 레거시 방식 (같은 폎더의 template.html) + template_path = self.doc_types_user / doc_type_id / 'template.html' + if template_path.exists(): + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + + return None + + def generate(self, content: str, doc_type_id: str, options: dict = None, + image_data: dict = None) -> dict: + """묞서 생성 - 템플늿 + 사용자 입력 + + Args: + content: 사용자 입력 텍슀튞 + doc_type_id: 묞서 유형 ID + options: 추가 옵션 (instruction 등) + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + None읎멎 템플늿 폎더에서 자동 로드 시도 + """ + try: + config = self.load_config(doc_type_id) + template = self.load_template(doc_type_id) + + if template: + # 읎믞지 데읎터 쀀비 + if image_data is None: + image_data = self._load_image_data(config) + result = self._generate_with_template( + content, config, template, options, image_data + ) + else: + result = self._generate_with_guide(content, config, options) + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _generate_with_template(self, content: str, config: dict, + template: str, options: dict, + image_data: dict = None) -> dict: + """템플늿 êž°ë°˜ 생성 — content_prompt.json 활용""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + instruction = options.get('instruction', '') if options else '' + + # ★ content_prompt 로드 + doc_type_id = config.get('id', '') + template_id = config.get('template_id', '') + cp = self.load_content_prompt(doc_type_id, template_id) + + placeholders_info = cp.get('placeholders', {}) + table_guide = cp.get('table_guide', {}) + writing_guide = cp.get('writing_guide', {}) + doc_info = cp.get('document', {}) + + # ★ placeholder 가읎드 생성 (type/pattern/example 포핚) + ph_guide_lines = [] + for ph_key, ph_info in placeholders_info.items(): + ph_type = ph_info.get('type', 'text') + pattern = ph_info.get('pattern', '') + example = ph_info.get('example', '') + location = ph_info.get('location', '') + + line = f" {ph_key}:" + line += f"\n type: {ph_type}" + line += f"\n pattern: {pattern}" + if example: + line += f"\n example: \"{example}\"" + line += f"\n location: {location}" + ph_guide_lines.append(line) + + ph_guide = "\n".join(ph_guide_lines) if ph_guide_lines else "(no guide available)" + + # ★ 표 가읎드 생성 + tbl_guide_lines = [] + for tbl_num, tbl_info in table_guide.items(): + headers = tbl_info.get('col_headers', []) + col_types = tbl_info.get('col_types', []) + merge = tbl_info.get('merge_pattern', {}) + bullets = tbl_info.get('bullet_chars', []) + examples = tbl_info.get('example_rows', []) + + tbl_guide_lines.append(f"\n### Table {tbl_num}:") + tbl_guide_lines.append(f" Columns: {json.dumps(headers, ensure_ascii=False)}") + if col_types: + for ct in col_types: + tbl_guide_lines.append( + f" Col {ct['col']} '{ct['header']}': {ct['type']}") + if merge: + tbl_guide_lines.append(f" Merge: {json.dumps(merge, ensure_ascii=False)}") + tbl_guide_lines.append( + f" → row_group means: use rowspan to group rows by that column") + if bullets: + tbl_guide_lines.append(f" Bullet chars: {bullets}") + + # ★ row_bf_pattern 추가 + bf_pattern = tbl_info.get('row_bf_pattern', []) + if bf_pattern: + tbl_guide_lines.append(f" Row cell classes (apply to each ):") + for bp in bf_pattern: + col = bp.get('col', '?') + bf_cls = bp.get('bf_class', '') + cs = bp.get('colSpan', 1) + rs = bp.get('rowSpan', 1) + span_info = "" + if cs > 1: span_info += f" colSpan={cs}" + if rs > 1: span_info += f" rowSpan={rs}" + tbl_guide_lines.append( + f' col_{col}: class="{bf_cls}"{span_info}') + + if examples: + tbl_guide_lines.append(f" Example rows:") + for ex in examples[:2]: + tbl_guide_lines.append( + f" {json.dumps(ex, ensure_ascii=False)}") + + tbl_guide = "\n".join(tbl_guide_lines) if tbl_guide_lines else "No table guide" + + # ★ 페읎지 추정 + page_estimate = structure.get('pageEstimate', 1) + + # ★ placeholder í‚€ 목록 (from template) + placeholders = PH_PATTERN.findall(template) + placeholders = list(dict.fromkeys(placeholders)) + + prompt = f"""Fill the template placeholders with reorganized content. + +## Document Definition +{context.get('documentDefinition', 'structured document')} + +## Context +- Type: {context.get('documentType', '')} +- Purpose: {context.get('purpose', '')} +- Audience: {context.get('audience', '')} +- Tone: {context.get('tone', '')} +- Layout: {doc_info.get('layout', 'portrait')} +- Page limit: {page_estimate} page(s). Be CONCISE. + +## Writing Style +- Bullet chars: {writing_guide.get('bullet_styles', ['- ', '· '])} +- Primary font: {writing_guide.get('font_primary', '')} +- Keep lines ~{writing_guide.get('avg_line_length', 25)} chars average + +## Placeholder Guide (type, pattern, example for each) +{ph_guide} + +## Table Structure Guide +{tbl_guide} + +## Input Content +{content[:6000] if content else '(empty)'} + +## Additional Instructions +{instruction if instruction else 'None'} + +## ALL Placeholders to fill (JSON keys): +{json.dumps(placeholders, ensure_ascii=False)} + +## ★ Critical Rules +1. Output ONLY valid JSON — every placeholder above as a key +2. HEADER/FOOTER: use the PATTERN and modify the EXAMPLE for new content + - department → user's department or keep example + - author → user's name or keep example + - date → today's date in same format + - slogan → keep exactly as example +3. TITLE: create title matching doc_title pattern from input content +4. TABLE_*_H_*: plain text column headers (use col_headers from guide) +5. TABLE_*_BODY: HTML rows only (no wrapper) + - Follow merge_pattern: row_group → use rowspan + - Use bullet_chars from guide inside cells + - Match example_rows structure +5b. TABLE_*_BODY \n +6. SECTION_*_CONTENT: use bullet style from writing guide +7. Empty string "" for inapplicable placeholders +8. Do NOT invent content — reorganize input only +9. PARA_*: reorganize input text for each paragraph placeholder + - Keep the meaning, improve clarity and structure + - PARA_n_RUN_m: if a paragraph has multiple runs, fill each run separately +10. IMAGE_*: output exactly "KEEP_ORIGINAL" (image is auto-inserted from source) +11. IMAGE_*_CAPTION: write a concise caption describing the image context +12. Total volume: {page_estimate} page(s) + +Output ONLY valid JSON:""" + + try: + response = call_claude( + "You fill document template placeholders with reorganized content. " + "Output valid JSON only. Respect the template structure exactly.", + prompt, + max_tokens=6000 + ) + + fill_data = self._extract_json(response) + + if not fill_data: + return {'error': 'JSON extraction failed', 'raw': response[:500]} + + html = self._fill_template(template, fill_data, image_data) + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def _fill_template(self, template: str, data: dict, + image_data: dict = None) -> str: + """템플늿에 데읎터 채우Ʞ + + Args: + template: HTML 템플늿 + data: AI가 채욎 placeholder → value dict + image_data: 읎믞지 dict {binaryItemIDRef: {"base64": ..., "mime": ...}} + """ + html = template + + # ★ content_prompt에서 IMAGE_n → binaryItemIDRef 맀핑 빌드 + image_ref_map = self._build_image_ref_map(data, image_data) + + for key, value in data.items(): + placeholder = '{{' + key + '}}' + + # ── IMAGE_n: 원볞 읎믞지 삜입 ── + if re.match(r'^IMAGE_\d+$', key): + img_tag = image_ref_map.get(key, '') + html = html.replace(placeholder, img_tag) + continue + + if isinstance(value, str) and value.strip(): + # ★ 개조식 낎용 처늬: · 또는 - 로 시작하는 항목 + lines = value.strip().split('\n') + is_bullet_list = sum( + 1 for l in lines + if l.strip().startswith('·') or l.strip().startswith('-') + ) > len(lines) * 0.5 + + if is_bullet_list and len(lines) > 1: + # ★ v2.2: inline context (

                                        안)에서는

                                          ꞈ지 + # PARA_*, SECTION_*_TITLE, HEADER_*, FOOTER_*, TITLE_*, *_RUN_* + # 읎듀은

                                          또는

                                        , 등의 Ʞ능 덕분에 표가 잘 표시되고 쉜게 슀타음링되며 액섞슀할 수 있습니닀. 불행히도 화멎에 렌더링할 때는 좋지 않습니닀(punk-bands-unstyled.html 에서 띌읎람 ì°žì¡°). + + + +Ʞ볞 람띌우저 슀타음만 사용하멎, 비좁고 읜Ʞ 얎렀우며 지룚핎 볎입니닀. 읎 묞제륌 핎결하렀멎 CSS 륌 사용핎알 합니닀. + +우늬의 표 슀타음링 +표 예제륌 핚께 슀타음링 핮 뎅시닀. + +시작하렀멎, sample markup 의 로컬 사볞을 만듀고 두 읎믞지 (녞읎슈 및 표범가죜) 륌 몚두 닀욎로드한 닀음, 섞 개의 결곌 파음을 로컬 컎퓚터의 작업 디렉토늬에 넣습니닀. + +닀음윌로, style.css 띌는 새 파음을 만듀고 닀륞 파음곌 같은 디렉토늬에 저장하십시였. + + 안에 닀음 HTML 행을 배치하여 CSS 륌 HTML 에 연결하십시였. + +html + +Copy + +간격 및 레읎아웃 +가장 뚌저 핎알할 음은 간격/레읎아웃을 정렬하는 것입니닀 — Ʞ볞 표 슀타음은 너묎 비좁습니닀! 읎렇게 하렀멎, style.css 파음에 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* 간격 */ + +table { + table-layout: fixed; + width: 100%; + border-collapse: collapse; + border: 3px solid purple; +} + +thead th:nth-child(1) { + width: 30%; +} + +thead th:nth-child(2) { + width: 20%; +} + +thead th:nth-child(3) { + width: 15%; +} + +thead th:nth-child(4) { + width: 35%; +} + +th, +td { + padding: 20px; +} +가장 쀑요한 부분은 닀음곌 같습니닀. + +fixed 의 table-layout 값은 음반적윌로 표가 Ʞ볞적윌로 좀 더 예잡 가능하게 작동하므로 표에 섀정하는 것읎 좋습니닀. 음반적윌로, 표의 엎은 낎용의 양에 따띌 크Ʞ가 정핎지는 겜향읎 있윌며, ê·ž 결곌 읎상한 결곌가 발생합니닀. table-layout: fixed 륌 사용하멎 제목의 너비에 따띌 엎의 크Ʞ륌 지정한 닀음 낎용을 적절하게 처늬할 수 있습니닀. 읎것읎 우늬가 thead th:nth-child(n) (:nth-child) 선택자 (" 요소 낎에서 륌 순서대로 선택합니닀") 요소륌 섀정하고 백분윚 너비륌 섀정했습니닀. 전첎 ì—Ž 너비는 제목 너비륌 따륎므로, 표 엎의 크Ʞ륌 정할 수 있습니닀. Chris Coyier 는 읎 Ʞ술에 대핮 고정 표 레읎아웃 에서 자섞히 섀명합니닀. + +읎륌 width 와 100% 결합했습니닀. 슉, 표에 넣은 container 륌 표에 채우고 반응성읎 뛰얎납니닀 (아직 더 많은 작업읎 필요하지만 좁은 화멎 너비에서 잘 볎임). + +collapse 의 border-collapse 값은 몚든 표 슀타음 작업에 대한 표쀀 몚범 사례입니닀. Ʞ볞적윌로, 표 요소에 테두늬륌 섀정하멎, 아래 읎믞지와 같읎 테두늬 사읎에 간격읎 있습니닀:읎것은 맀우 멋지게 볎읎지 않습니닀 (원하는 몚양음 수 있는지, 누가 알겠습니까?) border-collapse: collapse; 로 섀정하멎 테두늬가 하나로 축소되얎 훚씬 좋아 볎입니닀: + +우늬는 전첎 표 죌위에 border 륌 넣었습니닀. 나쀑에 표 뚞늬Ꞁ곌 바닥Ꞁ에 테두늬륌 씌욞 것입니닀 — 표 바깥쪜에 테두늬가 없고 틈새가 생Ʞ멎 정말 읎상하게 볎입니닀. + + 및 요소의 제목에 맞춀 Ꞁꌎ을 섀정하여 멋지고 펑킀한 몚양을 만듀었습니닀. +가독성을 높읎Ʞ 위핎 제목곌 셀에 letter-spacing 을 섀정했습니닀. 닀시 말하지만, 죌로 슀타음 선택입니닀. + 낎부의 표 셀에서 텍슀튞륌 가욎데 정렬하여 제목곌 음치하도록 했습니닀. Ʞ볞적윌로, 셀에는 text-align 에 left 값읎 제공되고, 뚞늬Ꞁ에는 center 값읎 제공되지만 둘 닀에 대핮 정렬을 동음하게 섀정하는것읎 좋습니닀. 제목 Ꞁꌎ의 Ʞ볞 굵은첎는 몚양을 구별하Ʞ에 충분합니닀. +데읎터와 시작적윌로 더 잘 연결되도록 낎부에서 제목을 였륞쪜 정렬했습니닀. +결곌는 조ꞈ 깔끔핎 볎입니닀. + + + +귞래픜 곌 색상 +읎제 귞래픜곌 색상윌로 넘얎가겠습니닀! 표에는 punk and attitude 가 가득하Ʞ 때묞에, 밝은 읞상적읞 슀타음링을 제공핎알합니닀. 걱정하지 마십시였. 표륌 크게 만듀 필요는 없습니닀 — 더 믞묘하고 섞렚된 것을 선택할 수 있습니닀. + +아래에서 닀시 CSS 륌 style.css 파음에 추가하고, 닀시 시작하십시였. + +css + +Copy +thead, +tfoot { + background: url(leopardskin.jpg); + color: white; + text-shadow: 1px 1px 1px black; +} + +thead th, +tfoot th, +tfoot td { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.5) + ); + border: 3px solid purple; +} +닀시 말하지만, 여Ʞ에는 표에만 핎당되는 것읎 없지만, 몇 가지 죌목할 가치가 있습니닀. + + 및 에 background-image 륌 추가하고, 뚞늬Ꞁ곌 바닥Ꞁ에 있는 몚든 텍슀튞의 color 륌 흰색윌로 (텍슀튞에 귞늌자 추가) 변겜하여 읜Ʞ 쉜게 했습니닀. 텍슀튞가 배겜곌 잘 대비되도록 핎알합니닀. 귞래알 잘 읜을 수 있습니닀. + +또한 뚞늬Ꞁ곌 바닥Ꞁ 낎부의 및 요소에 배겜 읎믞지와 선형 귞띌데읎션을 몚두 넣을 수는 있지만, 별도로 결정했습니닀. 여러 배겜 읎믞지 또는 선형 귞띌데읎션을 지원하지 않는 구형 람띌우저의 읎점을 위핎서입니닀. + +얌룩말 쀄묎늬 (Zebra striping) +우늬는 표의 닀륞 데읎터 행을 더 쉜게 구묞 분석하고 읜을 수 있도록 번갈아 가며 zebra stripes 륌 구현하는 방법을 볎여죌Ʞ 위핎 별도의 섹션을 제공하고자 했습니닀. style.css 파음의 ë§š 아래에 닀늄 CSS 륌 추가하십시였. + +css + +Copy +tbody tr:nth-child(odd) { + background-color: #ff33cc; +} + +tbody tr:nth-child(even) { + background-color: #e495e4; +} + +tbody tr { + background-image: url(noise.png); +} + +table { + background-color: #ff33cc; +} +읎전에는 :nth-child 선택자가 특정 자식 요소륌 선택하는 데 사용되는 것을 볎았습니닀. 수식을 맀개 변수로 제공할 수도 있윌므로 음렚의 요소륌 선택합니닀. 수식 2n-1 은 홀수 번짞 자식 (1, 3, 5 등) 을 몚두 선택하고 수식 2n 은 짝수 번짞 자식 (2, 4, 6 등) 을 몚두 선택합니닀. 윔드의 odd 및 even 킀워드 조찚도 앞에서 얞꞉한 공식곌 정확히 동음한 Ʞ능을 수행합니닀. 읎 겜우 홀수 및 짝수 행에 닀륞 색상 (선정적읞 색상) 을 부여합니닀. +또한 몚든 볞묞 행에 반복적읞 배겜 타음을 추가하여, 앜간의 녞읎슈 (앜간 시각적 왜곡읎 있는 반투명 .png) 륌 사용하여 질감을 제공했습니닀. +마지막윌로, :nth-child 선택자륌 지원하지 않는 람띌우저는 여전히 볞묞 행의 배겜을 갖도록 전첎 표에 닚색 배겜색을 지정했습니닀. +읎러한 색상은 닀음곌 같은 몚양을 만듭니닀. + + + +자, 읎것은 여러분의 췚향에 맞지 않을 수도 있습니닀. 하지만 , 우늬가 하렀고하는 요점은 표가 지룚하고 학묞적음 필요는 없닀는 것입니닀. + +caption 슀타음링 +표와 ꎀ렚하여 마지막윌로 핎알할 음읎 있습니닀 — caption 에 슀타음을 지정하는 음입니닀. 읎렇게 하렀멎, style.css 파음의 ë§š 아래에 닀늄을 추가하십시였. + +css + +Copy +caption { + font-family: "Rock Salt", cursive; + padding: 20px; + font-style: italic; + caption-side: bottom; + color: #666; + text-align: right; + letter-spacing: 1px; +} +bottom 값을 가진 caption-side 속성을 제왞하고는 여Ʞ서 죌목할만한 것읎 없습니닀. 읎로 읞핎 caption 읎 표의 ë§š 아래에 배치되고 닀륞 선얞곌 핚께 최종 몚양을 얻을 수 있습니닀(punk-bands-complete.html ì°žì¡°). + + + +적극적읞 학습: 나만의 표 슀타음 +읎 시점에서 표 HTML 예제 (또는 음부륌 사용하십시였!) 륌 가젞와서 표볎닀 훚씬 더 나은 디자읞곌 장식을 갖도록 슀타음을 지정하고 싶습니닀. + +표 슀타음링 빠륎게 하는 팁 +닀음 닚계로 넘얎가지 전에, 위에서 섀명한 가장 유용한 요점에 대한 간닚한 목록을 제공핎알 한닀고 생각했습니닀. + +표 마크 업을 가능한 한 간닚하게 만듀고, 유연한 작업을 유지하십시였 예: 백분유을 사용하여 디자읞의 반응속도륌 향상시킵니닀. +table-layout: fixed 륌 사용하여 제목 (, 및 륌 사용하여 표륌 녌늬적 덩얎늬로 나누고 CSS 륌 적용할 추가 위치륌 제공하십시였. 필요한 겜우 서로 위에 슀타음을 더 쉜게 레읎얎할 수 있습니닀. +alternative 행을 읜Ʞ 쉜게 하렀멎 얌룩말 쀄묎늬륌 사용합니닀. +text-align 을 사용하여 \n" + if col_pcts and len(col_pcts) == num_cols: + for pct in col_pcts: + colgroup += f' \n' + else: + for _ in range(num_cols): + colgroup += " \n" + colgroup += "\n" + + # 헀더 행 — ★ bf_id가 있윌멎 class 적용 + header_cells = [] + if header_row: + for c, cell in enumerate(header_row): + bf_id = cell.get("borderFillIDRef") + cs = cell.get("colSpan", 1) + + attrs = "" + if bf_id: + attrs += f' class="bf-{bf_id}"' + if cs > 1: + attrs += f' colspan="{cs}"' + + header_cells.append( + f' {{{{TABLE_{tbl_num}_H_C{c+1}}}}}' + ) + else: + # fallback: bf 없는 겜우 + for c in range(col_cnt): + header_cells.append( + f' ' + ) + + header_row_html = "\n".join(header_cells) + + return ( + f'
                                        : apply class from 'Row cell classes' guide\n + - e.g. content 안에 있얎 block 요소 삜입 시 HTML 깚짐 + _is_inline = re.match( + r'^(PARA_|SECTION_\d+_TITLE|HEADER_|FOOTER_|TITLE_|.*_RUN_)', + key + ) + if _is_inline: + #
                                        쀄바꿈윌로 구조 볎졎 + clean_lines = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + clean_lines.append(f'· {item}') + value = '
                                        \n'.join(clean_lines) + else: + #
                                        안 (SECTION_*_CONTENT 등) →
                                        • 허용 + li_items = [] + for item in lines: + item = item.strip() + if item.startswith('·'): + item = item[1:].strip() + elif item.startswith('-'): + item = item[1:].strip() + if item: + li_items.append(f'
                                        • {item}
                                        • ') + value = '
                                            \n' + '\n'.join(li_items) + '\n
                                          ' + + html = html.replace(placeholder, str(value) if value else '') + + # ★ 낚은 placeholder 정늬 (한Ꞁ 포핚) + html = PH_PATTERN.sub('', html) + + return html + + def _build_image_ref_map(self, data: dict, image_data: dict = None) -> dict: + """IMAGE_n placeholder → 태귞 맀핑 생성. + + content_prompt.json의 placeholders에서 IMAGE_n의 example_ref + (= binaryItemIDRef)륌 ì°Ÿê³ , image_data에서 base64륌 가젞옎. + """ + ref_map = {} + if not image_data: + return ref_map + + # content_prompt placeholders에서 IMAGE_n → ref 맀핑 + # (generate 혞출 시 content_prompt륌 아직 안 가지고 있윌므로 + # template HTML의 data-ref 속성 또는 순서 맀칭윌로 핎결) + # 방법: template에서 IMAGE_1, IMAGE_2... 순서와 + # image_data의 í‚€ 순서륌 맀칭 + + # image_data í‚€ 목록 (BinData 등장 순서) + img_refs = sorted(image_data.keys()) + + img_num = 0 + for ref in img_refs: + img_num += 1 + key = f"IMAGE_{img_num}" + img_info = image_data[ref] + + b64 = img_info.get("base64", "") + mime = img_info.get("mime", "image/png") + + if b64: + ref_map[key] = ( + f'' + ) + else: + # base64 없윌멎 파음 겜로 ì°žì¡° + file_path = img_info.get("path", "") + if file_path: + ref_map[key] = ( + f'' + ) + else: + ref_map[key] = f'' + + return ref_map + + def _load_image_data(self, config: dict) -> dict: + """템플늿 폎더에서 images.json 로드 (BinData 추출 결곌). + + images.json 구조: + { + "IMG001": {"base64": "iVBOR...", "mime": "image/png"}, + "IMG002": {"base64": "...", "mime": "image/jpeg"} + } + + 또는 읎믞지 파음읎 직접 저장된 겜우 겜로륌 반환. + """ + tpl_id = config.get("template_id", "") + if not tpl_id: + return {} + + tpl_path = Path('templates/user/templates') / tpl_id + + # ① images.json (base64 저장 방식) + images_json = tpl_path / 'images.json' + if images_json.exists(): + try: + with open(images_json, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + + # ② images/ 폮더 (파음 저장 방식) + images_dir = tpl_path / 'images' + if images_dir.exists(): + result = {} + mime_map = { + '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.bmp': 'image/bmp', '.svg': 'image/svg+xml', + '.wmf': 'image/x-wmf', '.emf': 'image/x-emf', + } + for img_file in sorted(images_dir.iterdir()): + if img_file.suffix.lower() in mime_map: + ref = img_file.stem # 파음명 = binaryItemIDRef + result[ref] = { + "path": str(img_file), + "mime": mime_map.get(img_file.suffix.lower(), "image/png") + } + return result + + return {} + + def _extract_json(self, response: str) -> dict: + """응답에서 JSON 추출""" + # ```json ... ``` 랔록 ì°Ÿêž° + match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except: + pass + + # 가장 큰 { } 랔록 ì°Ÿêž° + brace_depth = 0 + start = -1 + for i, ch in enumerate(response): + if ch == '{': + if brace_depth == 0: + start = i + brace_depth += 1 + elif ch == '}': + brace_depth -= 1 + if brace_depth == 0 and start >= 0: + try: + return json.loads(response[start:i+1]) + except: + start = -1 + + return None + + def _generate_with_guide(self, content: str, config: dict, options: dict) -> dict: + """가읎드 êž°ë°˜ 생성 (템플늿 없을 때)""" + + context = config.get('context', {}) + structure = config.get('structure', {}) + layout = config.get('layout', {}) + style = config.get('style', {}) + + instruction = options.get('instruction', '') if options else '' + + # 섹션 구조 섀명 + sections = layout.get('sections', []) + sections_desc = "" + for i, sec in enumerate(sections, 1): + sections_desc += f""" +{i}. {sec.get('name', f'섹션{i}')} + - 작성 슀타음: {sec.get('writingStyle', '혌합')} + - 불늿: {'있음' if sec.get('hasBulletIcon') else '없음'} + - 표: {'있음' if sec.get('hasTable') else '없음'} + - 낎용: {sec.get('contentDescription', '')} +""" + + page_estimate = structure.get('pageEstimate', 1) + + system_prompt = f"""당신은 "{context.get('documentType', '묞서')}" 작성 전묞가입니닀. + +## 묞서 특성 +- 목적: {context.get('purpose', '')} +- 대상: {context.get('audience', '')} +- 톀: {context.get('tone', '')} +- 전첎 슀타음: {structure.get('writingStyle', '혌합')} +- 분량: 앜 {page_estimate}페읎지 + +## 묞서 구조 +{sections_desc} + +## 작성 원칙 +{chr(10).join('- ' + p for p in structure.get('writingPrinciples', []))} + +## 죌의사항 +{chr(10).join('- ' + m for m in structure.get('commonMistakes', []))} + +## 핵심! +- 사용자 입력을 **정늬/재구성**하섞요 +- **새로 찜작하지 마섞요** +- 분석된 묞서 구조륌 귞대로 따륎섞요 +- 개조식 섹션은 "· " 불늿 사용 +- 분량을 {page_estimate}페읎지 낎로 제한하섞요""" + + user_prompt = f"""닀음 낎용을 "{context.get('documentType', '묞서')}" 양식윌로 정늬핎죌섞요. + +## 입력 낎용 +{content[:6000] if content else '(낎용 없음)'} + +## 추가 요청 +{instruction if instruction else '없음'} + +## 출력 형식 +완전한 A4 규격 HTML 묞서로 출력하섞요. +- 로 시작 +- UTF-8 읞윔딩 +- @page {{ size: A4 }} CSS 포핚 +- 폰튾: {style.get('font', {}).get('name', '맑은 고딕')} +- 뚞늿말/ꌬ늬말 포핚 +- 앜 {page_estimate}페읎지 분량 + +HTML만 출력하섞요.""" + + try: + response = call_claude(system_prompt, user_prompt, max_tokens=6000) + html = extract_html(response) + + if not html: + return {'error': 'HTML 생성 싀팚'} + + return {'success': True, 'html': html} + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/doc_template_analyzer.py b/03. Code/geulbeot_9th/handlers/doc_template_analyzer.py new file mode 100644 index 0000000..4ad0139 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/doc_template_analyzer.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +묞서 템플늿 분석Ʞ v5.1 (였쌀슀튞레읎터) + +역할: tools/ 몚듈을 조합하여 HWPX → 템플늿 정볎 추출 +- 직접 파싱 로직 없음 (몚두 tools에 위임) +- 디폎튞값 생성 없음 (tools가 None 반환하멎 결곌에서 제왞) +- 사용자 추가 사항(config.json) → 템플늿에도 반영 + +구조: + tools/ + page_setup.py §7 용지/여백 + font.py §3 Ꞁꌎ + char_style.py §4 Ꞁ자 몚양 + para_style.py §5 묞닚 몚양 + border_fill.py §2 테두늬/배겜 + table.py §6 표 + header_footer.py §8 뚞늬말/ꌬ늬말 + section.py §9 구역 정의 + style_def.py 슀타음 정의 + numbering.py 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image.py 읎믞지 +""" + +import json +from pathlib import Path +from typing import Optional + +from .tools import ( + page_setup, + font, + char_style, + para_style, + border_fill, + table, + header_footer, + section, + style_def, + numbering, + image, + content_order, +) + + +class DocTemplateAnalyzer: + """HWPX → 템플늿 추출 였쌀슀튞레읎터""" + + # ================================================================ + # Phase 1: 추출 (몚든 tools 혞출) + # ================================================================ + + def analyze(self, parsed: dict) -> dict: + """HWPX parsed 결곌에서 템플늿 구조 추출. + + Args: + parsed: processor.py가 HWPX륌 파싱한 결곌 dict. + raw_xml, section_xml, header_xml, footer_xml, + tables, paragraphs 등 포핚. + + Returns: + 추출된 항목만 포핚하는 dict (None읞 항목은 제왞). + """ + raw_xml = parsed.get("raw_xml", {}) + + extractors = { + "page": lambda: page_setup.extract(raw_xml, parsed), + "fonts": lambda: font.extract(raw_xml, parsed), + "char_styles": lambda: char_style.extract(raw_xml, parsed), + "para_styles": lambda: para_style.extract(raw_xml, parsed), + "border_fills": lambda: border_fill.extract(raw_xml, parsed), + "tables": lambda: table.extract(raw_xml, parsed), + "header": lambda: header_footer.extract_header(raw_xml, parsed), + "footer": lambda: header_footer.extract_footer(raw_xml, parsed), + "section": lambda: section.extract(raw_xml, parsed), + "styles": lambda: style_def.extract(raw_xml, parsed), + "numbering": lambda: numbering.extract(raw_xml, parsed), + "images": lambda: image.extract(raw_xml, parsed), + "content_order":lambda: content_order.extract(raw_xml, parsed), + } + + result = {} + for key, extractor in extractors.items(): + try: + value = extractor() + if value is not None: + result[key] = value + except Exception as e: + # 개별 tool 싀팚 시 로귞만, 전첎 쀑닚 안 핹 + result.setdefault("_errors", []).append( + f"{key}: {type(e).__name__}: {e}" + ) + + return result + + + # ================================================================ + # Phase 2: 사용자 추가 사항 병합 + # ================================================================ + + def merge_user_config(self, template_info: dict, + config: dict) -> dict: + """config.json의 사용자 요구사항을 template_info에 병합. + + 사용자가 묞서 유형 추가 시 지정한 컀슀텀 사항을 반영: + - 색상 였버띌읎드 + - Ꞁꌎ 였버띌읎드 + - 제목 크Ʞ 였버띌읎드 + - Ʞ타 레읎아웃 컀슀텀 + + 읎 병합 결곌는 style.json에 저장되고, + 읎후 template.html 생성 시에도 반영됚. + + Args: + template_info: analyze()의 결곌 + config: config.json 낎용 + + Returns: + 병합된 template_info (원볞 수정됚) + """ + user_overrides = config.get("user_overrides", {}) + if not user_overrides: + return template_info + + # 몚든 사용자 였버띌읎드륌 template_info에 Ʞ록 + template_info["user_overrides"] = user_overrides + + return template_info + + # ================================================================ + # Phase 3: template_info → style.json 저장 + # ================================================================ + + def save_style(self, template_info: dict, + save_path: Path) -> Path: + """template_info륌 style.json윌로 저장. + + Args: + template_info: analyze() + merge_user_config() 결곌 + save_path: 저장 겜로 (예: templates/user/{doc_type}/style.json) + + Returns: + 저장된 파음 겜로 + """ + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(template_info, f, ensure_ascii=False, indent=2) + + return save_path \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/doc_type_analyzer.py b/03. Code/geulbeot_9th/handlers/doc_type_analyzer.py new file mode 100644 index 0000000..9ba0d2f --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/doc_type_analyzer.py @@ -0,0 +1,1058 @@ +# -*- coding: utf-8 -*- +""" +묞서 유형 분석 Agent (v3.1 - 윔드 êž°ë°˜ 레읎아웃 추출, hwpx_utils 연동) + +★ v3.1 변겜사항 (from v3.0): +- 레읎아웃 추출: AI 혞출 제거 → 윔드 êž°ë°˜ (HWPX 파싱 데읎터에서 직접 도출) + · headerLayout: 싀제 헀더 테읎랔 colCount/셀텍슀튞 귞대로 반영 + · footerLayout: 싀제 푾터 테읎랔 구조 귞대로 반영 + · sections: 묞닚 텍슀튞 팹턮 맀칭윌로 추출 + · overallStyle: 불늿/표 사용 팹턮 윔드 분석 +- 닚위 변환: hwpx_utils 연동 (hwpunit_to_mm, charsize_to_pt) +- AI는 맥띜(목적/묞서유형)곌 구조가읎드(섹션별 작성법)에만 사용 +- headerLayout 할룚시넀읎션 완전 제거 +- _generate_html_template (AI fallback) 제거 — template_manager가 전닎 +- _parse_tables_in_region: 태귞 속성에서도 병합/너비 추출 (도메읞 가읎드 §6.3) + +ì°žì¡°: +- domain/hwpx/hwpx_domain_guide.md (§1~§11) +- domain/hwpx/hwpx_utils.py (변환 핚수/상수) +""" + +import zipfile +import json +import time +import re +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from .template_manager import TemplateManager +from pathlib import Path +from typing import Dict, List, Any, Optional +from .common import call_claude, extract_json + +# ================================================================ +# hwpx_utils 연동 (fallback 포핚) +# ================================================================ +try: + from domain.hwpx.hwpx_utils import ( + hwpunit_to_mm, charsize_to_pt, mm_format, detect_paper_size + ) +except ImportError: + def hwpunit_to_mm(hu): return hu / 7200 * 25.4 + def charsize_to_pt(s): return s / 100 + def mm_format(hu, d=1): return f'{hwpunit_to_mm(hu):.{d}f}mm' + def detect_paper_size(w, h, t=200): return 'A4' + + +class DocTypeAnalyzer: + """묞서 유형 분석 Orchestrator (v3.1 - 윔드 êž°ë°˜ 레읎아웃)""" + + def __init__(self, progress_callback=None): + self.progress_callback = progress_callback + self.template_manager = TemplateManager() + self.steps = [ + {"id": 1, "name": "묞서 파싱", "status": "pending"}, + {"id": 2, "name": "레읎아웃 추출", "status": "pending"}, + {"id": 3, "name": "맥띜 분석", "status": "pending"}, + {"id": 4, "name": "구조 분석", "status": "pending"}, + {"id": 5, "name": "템플늿 추출", "status": "pending"}, + {"id": 6, "name": "최종 검슝", "status": "pending"}, + ] + + def _update_step(self, step_id: int, status: str, message: str = ""): + for step in self.steps: + if step["id"] == step_id: + step["status"] = status + break + if self.progress_callback: + self.progress_callback(step_id, status, message) + + # ================================================================ + # 메읞 분석 플로우 + # ================================================================ + + def analyze(self, file_path: str, doc_name: str, + description: str = "") -> dict: + """전첎 분석 싀행 + + Flow: + Step 1: HWPX 파싱 (윔드) + Step 2: 레읎아웃 추출 (윔드) — AI 믞사용 + Step 3: 맥띜 분석 (AI) — 묞서 유형/목적/대상 판정 + Step 4: 구조 분석 (AI) — 섹션별 작성 가읎드 생성 + Step 5: 템플늿 추출 (template_manager → doc_template_analyzer) + Step 6: config.json 생성 + """ + result = {"name": doc_name, "description": description} + + # Step 1: HWPX 파싱 + self._update_step(1, "running", "HWPX 파음 분석 쀑...") + parsed = self._parse_hwpx(file_path) + result["parsed"] = parsed + self._update_step( + 1, "done", + f"텍슀튞 {len(parsed.get('text', ''))}자, " + f"표 {len(parsed.get('tables', []))}개") + + # Step 2: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + self._update_step(2, "running", "묞서 레읎아웃 구조 추출 쀑...") + layout = self._extract_layout(parsed) + result["layout"] = layout + self._update_step( + 2, "done", + f"헀더:{layout.get('hasHeader')}, " + f"푾터:{layout.get('hasFooter')}, " + f"섹션:{len(layout.get('sections', []))}개") + + # Step 3: 맥띜 분석 (AI - 의믞 분석만) + self._update_step(3, "running", "묞서의 목적/배겜 분석 쀑...") + context = self._analyze_context(parsed, layout) + result["context"] = context + self._update_step( + 3, "done", + f"묞서 유형: {context.get('documentType', '알 수 없음')}") + + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + self._update_step(4, "running", "묞서 구조 상섞 분석 쀑...") + structure = self._analyze_structure(parsed, layout, context) + result["structure"] = structure + style = self._extract_style(parsed) + result["style"] = style + self._update_step(4, "done", "구조 분석 완료") + + # Step 5: 템플늿 추출 (template_manager 전닎) + self._update_step(5, "running", "HTML 템플늿 추출 쀑...") + try: + tpl_result = self.template_manager.extract_and_save( + parsed, + name=f"{doc_name} 양식", + source_file=os.path.basename(file_path), + description=f"{doc_name}에서 추출한 묞서 양식" + ) + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result keys: {list(tpl_result.keys())}") + print(f"[DEBUG] tpl_result: {tpl_result}") + except Exception as e: + import traceback + print(f"[ERROR] Step 5 싀팚: {e}") + traceback.print_exc() + tpl_result = {} + + # ★ template_id륌 result로 전달 ← 추가! + if tpl_result.get("success"): + result["template_id"] = tpl_result["template_id"] + + # Step 5-b: content_prompt 생성 (doc_type 소속) ← ★ 읎 랔록 추가 + content_prompt = {} + if tpl_result.get("success"): + try: + from . import content_analyzer + content_prompt = content_analyzer.generate( + tpl_result["template_info"], + tpl_result["semantic_map"], + parsed + ) + result["content_prompt"] = content_prompt + except Exception as e: + print(f"[WARN] content_prompt 생성 였류: {e}") + + # Step 6: Config 생성 + self._update_step(6, "running", "최종 섀정 파음 생성 쀑...") + config = self._generate_config(doc_name, description, result) + result["config"] = config + self._update_step(6, "done", "분석 완료!") + + return result + + # ================================================================ + # Step 1: HWPX 파싱 + # ================================================================ + + def _parse_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) + # ★ tc 태귞 속성 + 낎부 윘텐잠 몚두 캡처 + tc_pattern = re.compile( + r']*)>(.*?)', re.DOTALL) + + row_cells = [] + for cell_match in tc_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: + """뚞늿말 XML 추출""" + m = re.search( + r']*>(.*?)', xml, re.DOTALL) + return m.group(1) if m else "" + + def _extract_footer(self, xml: str) -> str: + """ꌬ늬말 XML 추출""" + 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: 레읎아웃 추출 (★ 윔드 êž°ë°˜, AI 믞사용) + # ================================================================ + + def _extract_layout(self, parsed: dict) -> dict: + """윔드 êž°ë°˜ 레읎아웃 추출 - HWPX 파싱 데읎터에서 직접 도출 + + ★ v3.1: AI 혞출 완전 제거. 할룚시넀읎션 없읎 싀제 HWPX 데읎터만 반영. + + 읎전 v3.0 묞제: + - AI가 "columns": ["부서명", "묞서번혞", "작성음자"] 식윌로 지얎냄 + - hasHeader: True 하드윔딩 (HWPX에 헀더 없얎도) + - left/center/right 3ì—Ž 고정 슀킀마 강제 + + v3.1 핎결: + - headerLayout.cellTexts: 싀제 HWPX 셀 텍슀튞 귞대로 + - hasHeader: bool(header_xml.strip()) 싀제 졎재 여부 + - colCount: 싀제 ì—Ž 수 + """ + tables = parsed.get("tables", []) + paragraphs = parsed.get("paragraphs", []) + section_xml = parsed.get("section_xml", "") + header_xml = parsed.get("page_header_xml", "") + footer_xml = parsed.get("page_footer_xml", "") + + 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(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 데읎터 귞대로 반영 + + ★ AI가 "좌잡=부서명, ìš°ìž¡=날짜" 식윌로 지얎낎던 것을 + 싀제 테읎랔 셀 텍슀튞로 대첎. + """ + 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에서 묞닚 팹턮 맀칭 + + doc_template_analyzer.py v3.0곌 동음한 팹턮 감지: + 1. "1. 개요" (번혞+점) + 2. 아읎윘 + 짧은 텍슀튞 + 3. "Ⅰ.", "Ⅱ." (로마자) + 4. "제1장", "제2절" (한국식) + """ + sections = [] + data_tables = [t for t in body_tables + if not t.get("isLayoutTable")] + table_idx = 0 + current_section = None + + # header/footer 제거 + 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) + + # 묞첎 판정 + if bullet_total > prose_count * 2: + writing_style = "개조식" + elif prose_count > bullet_total * 2: + writing_style = "서술식" + else: + 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: 맥띜 분석 (AI - 의믞 분석만) + # ================================================================ + + 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": "음반", + "tone": "볎고형", + } + + # ================================================================ + # Step 4: 구조 분석 (AI - 섹션별 작성 가읎드) + # ================================================================ + + 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": "개조식/서술식", + "contentGuide": "작성 가읎드", + "hasTable": false + }}, + {{ + "name": "섹션명", + "role": "역할", + "writingStyle": "개조식", + "contentGuide": "가읎드", + "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, + } + + # ================================================================ + # 슀타음 추출 (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) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/report/__init__.py b/03. Code/geulbeot_9th/handlers/report/__init__.py new file mode 100644 index 0000000..a5c2655 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/report/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 몚듈 +""" +from .processor import ReportProcessor \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/report/processor.py b/03. Code/geulbeot_9th/handlers/report/processor.py new file mode 100644 index 0000000..19def30 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/report/processor.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +볎고서(report) 처늬 로직 +- 닀페읎지 볎고서 +- 원볞 구조 유지 +- RAG 파읎프띌읞 연동 (ꞎ 묞서) +""" + +import os +import re +from pathlib import Path +from flask import session + +from handlers.common import call_claude, extract_html, load_prompt, client +from converters.pipeline.router import process_document, convert_image_paths + + +class ReportProcessor: + """볎고서 처늬 큎래슀""" + + def __init__(self): + self.prompts_dir = Path(__file__).parent / 'prompts' + + def _load_prompt(self, filename: str) -> str: + """프롬프튞 로드""" + return load_prompt(str(self.prompts_dir), filename) + + def generate(self, content: str, options: dict) -> dict: + """볎고서 생성""" + try: + if not content.strip(): + return {'error': '낎용읎 비얎있습니닀.'} + + # ⭐ 템플늿 슀타음 로드 + template_id = options.get('template_id') + if template_id: + from handlers.template import TemplateProcessor + template_processor = TemplateProcessor() + style = template_processor.get_style(template_id) + if style and style.get('css'): + options['template_css'] = style['css'] + + # 읎믞지 겜로 변환 + processed_html = convert_image_paths(content) + + # router륌 통핎 분량에 따띌 파읎프띌읞 ë¶„êž° + result = process_document(processed_html, options) + + if result.get('success'): + session['original_html'] = content + session['current_html'] = result.get('html', '') + + return result + + except Exception as e: + import traceback + return {'error': str(e), 'trace': traceback.format_exc()} + + def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict: + """플드백 반영""" + try: + if not feedback.strip(): + return {'error': '플드백 낎용을 입력핎죌섞요.'} + + if not current_html: + return {'error': '수정할 HTML읎 없습니닀.'} + + refine_prompt = f"""당신은 HTML 볎고서 수정 전묞가입니닀. + +사용자의 플드백을 반영하여 현재 HTML을 수정합니닀. + +## 규칙 +1. 플드백에서 얞꞉된 부분만 정확히 수정 +2. **페읎지 구조(sheet, body-content, page-header 등)는 절대 변겜하지 마섞요** +3. 완전한 HTML 묞서로 출력 ( ~ ) +4. 윔드 랔록(```) 없읎 순수 HTML만 출력 + +## 현재 HTML +{current_html} + +## 사용자 플드백 +{feedback} + +--- +위 플드백을 반영하여 수정된 완전한 HTML을 출력하섞요.""" + + response = call_claude("", refine_prompt, max_tokens=8000) + new_html = extract_html(response) + + session['current_html'] = new_html + + return { + 'success': True, + 'html': new_html + } + + except Exception as e: + return {'error': str(e)} + + def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict: + """선택된 부분만 수정 (볎고서용 - 페읎지 구조 볎졎)""" + try: + if not current_html or not selected_text or not user_request: + return {'error': '필수 데읎터가 없습니닀.'} + + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=8000, + messages=[{ + "role": "user", + "content": f"""HTML 묞서에서 지정된 부분만 수정핎죌섞요. + +## 전첎 묞서 (컚텍슀튞 파악용) +{current_html[:5000]} + +## 수정 대상 텍슀튞 +"{selected_text}" + +## 수정 요청 +{user_request} + +## 규칙 +1. **절대로 페읎지 구조(sheet, body-content, page-header, page-footer)륌 변겜하지 마섞요** +2. 선택된 텍슀튞만 수정하고, 죌변 HTML 태귞는 귞대로 유지 +3. 요청을 분석하여 수정 유형을 판당: + - TEXT: 텍슀튞 낎용만 수정 (요앜, 묞장 변겜, 닚얎 수정, 번역 등) + - STRUCTURE: HTML 구조 변겜 필요 (표 생성, 박슀 추가 등) + +4. 반드시 닀음 형식윌로만 출력: + +TYPE: (TEXT 또는 STRUCTURE) +CONTENT: +(수정된 낎용만 - 선택된 텍슀튞의 수정볞만) + +5. TEXT읞 겜우: 순수 텍슀튞만 출력 (HTML 태귞 없읎, 선택된 텍슀튞의 수정볞만) +6. STRUCTURE읞 겜우: 핎당 요소만 출력 (전첎 페읎지 구조 X) +7. 개조식 묞첎 유지 (~임, ~핹, ~필요) +""" + }] + ) + + result = message.content[0].text + result = result.replace('```html', '').replace('```', '').strip() + + edit_type = 'TEXT' + content = result + + if 'TYPE:' in result and 'CONTENT:' in result: + type_line = result.split('CONTENT:')[0] + if 'STRUCTURE' in type_line: + edit_type = 'STRUCTURE' + content = result.split('CONTENT:')[1].strip() + + return { + 'success': True, + 'type': edit_type, + 'html': content + } + + except Exception as e: + return {'error': str(e)} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/report/prompts/refine_selection.txt b/03. Code/geulbeot_9th/handlers/report/prompts/refine_selection.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/report/prompts/refine_selection.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/semantic_mapper.py b/03. Code/geulbeot_9th/handlers/semantic_mapper.py new file mode 100644 index 0000000..8d6b2b5 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/semantic_mapper.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Semantic Mapper v1.0 + +HWPX tools 추출 결곌(template_info)에서 각 요소의 "의믞"륌 판별. + +역할: + - 표 분류: 헀더표 / 푾터표 / 제목랔록 / 데읎터표 + - 섹션 감지: 볞묞 텍슀튞에서 섹션 팹턮 탐색 + - 슀타음 맀핑 쀀비: charPr→HTML태귞, borderFill→CSS큎래슀 (Phase 2에서 구현) + +입력: template_info (DocTemplateAnalyzer.analyze()), parsed (HWPX 파싱 결곌) +출력: semantic_map dict → semantic_map.json윌로 저장 + +★ 위치: template_manager.py, doc_template_analyzer.py 와 같은 디렉토늬 +★ 혞출: template_manager.extract_and_save() 낎에서 analyze() 직후 +""" + +import re + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate(template_info: dict, parsed: dict) -> dict: + """semantic_map 생성 — 몚든 판별 로직 조합. + + Args: + template_info: DocTemplateAnalyzer.analyze() 결곌 + parsed: HWPX 파서 결곌 (raw_xml, section_xml, paragraphs 등) + + Returns: + { + "version": "1.0", + "table_roles": { "0": {"role": "footer_table", ...}, ... }, + "body_tables": [3], # 볞묞에 듀얎갈 표 index 목록 + "title_table": 2, # 제목 랔록 index (없윌멎 None) + "sections": [...], # 감지된 섹션 목록 + "style_mappings": {...}, # Phase 2용 슀타음 맀핑 (현재 빈 구조) + } + """ + tables = template_info.get("tables", []) + header = template_info.get("header") + footer = template_info.get("footer") + + # ① 표 역할 분류 + table_roles = _classify_tables(tables, header, footer) + + # ② 볞묞 전용 표 / 제목 랔록 추출 + body_tables = sorted( + idx for idx, info in table_roles.items() + if info["role"] == "data_table" + ) + title_table = next( + (idx for idx, info in table_roles.items() + if info["role"] == "title_block"), + None + ) + + # ③ 섹션 감지 + sections = _detect_sections(parsed) + + # ④ 슀타음 맀핑 (Phase 2에서 구현, 현재는 빈 구조) + style_mappings = _prepare_style_mappings(template_info) + + return { + "version": "1.0", + "table_roles": table_roles, + "body_tables": body_tables, + "title_table": title_table, + "sections": sections, + "style_mappings": style_mappings, + } + + +# ================================================================ +# 표 분류 +# ================================================================ + +def _classify_tables(tables: list, header: dict | None, + footer: dict | None) -> dict: + """각 표의 역할 판별: header_table / footer_table / title_block / data_table + + 판별 순서: + Pass 1 — header/footer 텍슀튞 맀칭 + Pass 2 — 제목 랔록 팹턮 (1행, 좁은+넓은 ì—Ž 구조) + Pass 3 — 나뚞지 → 데읎터 표 + """ + header_texts = _collect_hf_texts(header) + footer_texts = _collect_hf_texts(footer) + + roles = {} + classified = set() + + # ── Pass 1: header/footer 맀칭 ── + for tbl in tables: + idx = tbl["index"] + tbl_texts = _collect_table_texts(tbl) + if not tbl_texts: + continue + + # header 맀칭 + if header_texts: + overlap = len(tbl_texts & header_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "header_table", + "match_source": "header", + "matched_texts": list(tbl_texts & header_texts), + } + classified.add(idx) + continue + + # footer 맀칭 + if footer_texts: + overlap = len(tbl_texts & footer_texts) + if overlap > 0 and overlap / max(len(tbl_texts), 1) >= 0.5: + roles[idx] = { + "role": "footer_table", + "match_source": "footer", + "matched_texts": list(tbl_texts & footer_texts), + } + classified.add(idx) + continue + + # ── Pass 2: 제목 랔록 탐지 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + if _is_title_block(tbl): + title_text = _extract_longest_text(tbl) + roles[idx] = { + "role": "title_block", + "title_text": title_text, + } + classified.add(idx) + continue + + # ── Pass 3: 나뚞지 → 데읎터 표 ── + for tbl in tables: + idx = tbl["index"] + if idx in classified: + continue + + col_headers = _detect_table_headers(tbl) + roles[idx] = { + "role": "data_table", + "header_row": 0 if col_headers else None, + "col_headers": col_headers, + "row_count": tbl.get("rowCnt", 0), + "col_count": tbl.get("colCnt", 0), + } + + return roles + + +# ── 표 분류 볎조 핚수 ── + +def _collect_hf_texts(hf_info: dict | None) -> set: + """header/footer의 table 셀 텍슀튞 수집""" + if not hf_info or not hf_info.get("table"): + return set() + texts = set() + for row in hf_info["table"].get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _collect_table_texts(tbl: dict) -> set: + """표의 몚든 셀 텍슀튞 수집""" + texts = set() + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if t: + texts.add(t) + return texts + + +def _extract_longest_text(tbl: dict) -> str: + """표에서 가장 ꞎ 텍슀튞 추출 (제목 랔록용)""" + longest = "" + for row in tbl.get("rows", []): + for cell in row: + t = cell.get("text", "").strip() + if len(t) > len(longest): + longest = t + return longest + + +def _is_title_block(tbl: dict) -> bool: + """제목 랔록 팹턮 판별. + + 조걎 (하나띌도 충족): + A) 1행 2ì—Ž, 왌쪜 ì—Ž 비윚 ≀ 10% (불늿아읎윘 + 제목) + B) 1행 1ì—Ž, 텍슀튞 Ꞟ읎 5~100자 (제목 당독) + """ + if tbl.get("rowCnt", 0) != 1: + return False + + col_cnt = tbl.get("colCnt", 0) + col_pcts = tbl.get("colWidths_pct", []) + + # 팹턮 A: 좁은 왌쪜 + 넓은 였륞쪜 + if col_cnt == 2 and len(col_pcts) >= 2: + if col_pcts[0] <= 10: + return True + + # 팹턮 B: 닚음 셀 제목 + if col_cnt == 1: + rows = tbl.get("rows", []) + if rows and rows[0]: + text = rows[0][0].get("text", "") + if 5 < len(text) < 100: + return True + + return False + + +def _detect_table_headers(tbl: dict) -> list: + """표 첫 행의 컬럌 헀더 텍슀튞 반환. + + 헀더 판별: 첫 행의 몚든 텍슀튞가 짧음 (20자 읎하) + """ + rows = tbl.get("rows", []) + if not rows or len(rows) < 2: + return [] + + first_row = rows[0] + headers = [] + for cell in first_row: + t = cell.get("text", "").strip() + headers.append(t) + + # 전부 짧은 텍슀튞읎멎 헀더행 + if headers and all(len(h) <= 20 for h in headers if h): + non_empty = [h for h in headers if h] + if non_empty: # 최소 1개는 텍슀튞가 있얎알 + return headers + + return [] + + +# ================================================================ +# 섹션 감지 +# ================================================================ + +_SECTION_PATTERNS = [ + (r'^(\d+)\.\s+(.+)', "numbered"), # "1. 개요" + (r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ][\.\s]+(.+)', "roman"), # "Ⅰ. 개요" + (r'^제\s*(\d+)\s*([장절항])\s*(.+)', "korean_formal"), # "제1장 개요" + (r'^[▶►▞●◆■□◎★☆]\s*(.+)', "bullet_heading"), # "▶ 개요" +] + + +def _detect_sections(parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색. + + Returns: + [ + {"index": 1, "title": "▶ 개요", "pattern_type": "bullet_heading"}, + {"index": 2, "title": "▶ 발표 구성(안)", "pattern_type": "bullet_heading"}, + ... + ] + """ + paragraphs = _extract_paragraphs(parsed) + sections = [] + sec_idx = 0 + + for text in paragraphs: + text = text.strip() + if not text or len(text) > 100: + # 너묎 ꞎ 텍슀튞는 제목읎 아님 + continue + + for pat, pat_type in _SECTION_PATTERNS: + m = re.match(pat, text) + if m: + # numbered 팹턮: 숫자가 100 읎상읎멎 섹션 번혞가 아님 (연도 등 제왞) + if pat_type == "numbered" and int(m.group(1)) > 99: + continue + sec_idx += 1 + sections.append({ + "index": sec_idx, + "title": text, + "pattern_type": pat_type, + }) + break + + return sections + + +def _extract_paragraphs(parsed: dict) -> list: + """parsed에서 텍슀튞 닚띜 추출. + + 우선순위: + 1. parsed["paragraphs"] (파서가 직접 제공) + 2. section_xml의 태귞에서 추출 + """ + paragraphs = parsed.get("paragraphs", []) + if paragraphs: + return [ + p.get("text", "") if isinstance(p, dict) else str(p) + for p in paragraphs + ] + + # section_xml에서 추출 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower() and isinstance(val, str): + section_xml = val + break + + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + return [ + t.strip() + for t in re.findall(r'([^<]+)', section_xml) + if t.strip() + ] + + return [] + + +# ================================================================ +# 슀타음 맀핑 (Phase 2에서 확장) +# ================================================================ + +def _prepare_style_mappings(template_info: dict) -> dict: + """슀타음 맀핑 빈 구조 생성. + + Phase 2에서 읎 구조륌 채움: + - char_styles → CSS font/color rules + - border_fills → CSS border/background rules + - para_styles → CSS margin/alignment rules + """ + mappings = { + "char_pr": {}, + "border_fill": {}, + "para_pr": {}, + } + + # border_fills가 있윌멎 Ʞ볞 맀핑 생성 + border_fills = template_info.get("border_fills", {}) + for bf_id, bf_data in border_fills.items(): + # ★ 싀제 í‚€ 구조 대응 (bg→background, sides→css/직접킀) + bg = bf_data.get("background", bf_data.get("bg", "")) + + # borders: css dict 또는 직접 킀에서 추출 + borders = {} + css_dict = bf_data.get("css", {}) + if css_dict: + for prop, val in css_dict.items(): + if prop.startswith("border-") and val and val != "none": + borders[prop] = val + else: + # fallback: 직접 side í‚€ + for side in ("top", "bottom", "left", "right"): + si = bf_data.get(side, {}) + if isinstance(si, dict) and si.get("type", "NONE").upper() != "NONE": + borders[f"border-{side}"] = ( + f"{si.get('width','0.1mm')} " + f"{si.get('type','solid').lower()} " + f"{si.get('color','#000')}" + ) + + mappings["border_fill"][str(bf_id)] = { + "css_class": f"bf-{bf_id}", + "bg": bg, + "borders": borders, + } + + return mappings \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/style_generator.py b/03. Code/geulbeot_9th/handlers/style_generator.py new file mode 100644 index 0000000..29f5579 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/style_generator.py @@ -0,0 +1,824 @@ +# -*- coding: utf-8 -*- +""" +Style Generator v2.1 (Phase 4 — 하드윔딩 제거) + +template_info의 tools 추출값 → CSS 묞자엎 생성. + +★ v2.1 변겜사항: + - 하드윔딩 간격 → 추출값 대첎: + · .doc-header margin-bottom → page.margins.header에서 계산 + · .doc-footer margin-top → page.margins.footer에서 계산 + · .title-block margin/padding → title paraPr spacing에서 유도 + - .img-wrap, .img-caption CSS 추가 (content_order 읎믞지 지원) + +★ v2.0 변겜사항 (v1.0 대비): + - charPr 28개 전첎 → .cpr-{id} CSS 큎래슀 생성 + - paraPr 23개 전첎 → .ppr-{id} CSS 큎래슀 생성 + - styles 12개 → .sty-{id} CSS 큎래슀 (charPr + paraPr 조합) + - fontRef → 싀제 폰튞명 핎석 (font_map 빌드) + - 제목 랔록: 하드윔딩 제거 → 싀제 추출 데읎터 사용 + - 쀄간격: paraPr별 line-height 개별 적용 + - 여백: @page는 읞쇄용, .page는 화멎용 (읎쀑 적용 제거) + - bf CSS: NONE-only borderFill도 큎래슀 생성 (border: none 명시) + - 텍슀튞 색상: charPr별 color 반영 + - 폰튾: charPr별 fontRef → 싀제 font-family 핎석 + +★ 원칙: hwpx_domain_guide.md §1~§8 맀핑 규칙 100% 쀀수 +★ 원칙: 하드윔딩 값 0개. 몚든 CSS 값은 template_info에서 유래. +""" + +HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm + + +# ================================================================ +# 메읞 엔튞늬포읞튞 +# ================================================================ + +def generate_css(template_info: dict, semantic_map: dict = None) -> str: + """template_info + semantic_map → CSS 묞자엎 전첎 생성.""" + # font_map 빌드 (charPr CSS에서 재사용) + fm = _build_font_map(template_info) + + parts = [ + _page_css(template_info), + _body_css(template_info, fm), + _layout_css(template_info), + _header_footer_css(template_info), + _title_block_css(template_info, fm, semantic_map), + _section_css(template_info), + _table_base_css(template_info), + _border_fill_css(template_info), + _char_pr_css(template_info, fm), + _para_pr_css(template_info), + _named_style_css(template_info), + _table_detail_css(template_info, semantic_map), + ] + return "\n\n".join(p for p in parts if p) + + +# ================================================================ +# @page (읞쇄 전용) +# ================================================================ + +def _page_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + h = paper.get("height_mm", 297) + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + ml = margins.get("left", "20mm") + mr = margins.get("right", "20mm") + + return ( + "@page {\n" + f" size: {w}mm {h}mm;\n" + f" margin: {mt} {mr} {mb} {ml};\n" + "}\n" + "@media screen {\n" + " @page { margin: 0; }\n" # 화멎에서는 .page padding만 사용 + "}" + ) + + +# ================================================================ +# body +# ================================================================ + +def _body_css(ti: dict, fm: dict) -> str: + """바탕Ꞁ 슀타음 Ʞ쀀 body CSS""" + # '바탕Ꞁ' 슀타음 → charPr → fontRef → 싀제 폰튾 + base_charpr = _resolve_style_charpr(ti, "바탕Ꞁ") + base_parapr = _resolve_style_parapr(ti, "바탕Ꞁ") + + # 폰튾 + font_family = _charpr_font_family(base_charpr, fm) + # 크Ʞ + size_pt = base_charpr.get("height_pt", 10.0) + # 색상 + color = base_charpr.get("textColor", "#000000") + # 쀄간격 + line_height = _parapr_line_height(base_parapr) + # 정렬 + # body에는 정렬 넣지 않음 (paraPr별로) + + return ( + "body {\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" color: {color};\n" + " margin: 0; padding: 0;\n" + "}" + ) + + +# ================================================================ +# .page 레읎아웃 (화멎 전용 — 여백은 여Ʞ서만) +# ================================================================ + +def _layout_css(ti: dict) -> str: + page = ti.get("page", {}) + paper = page.get("paper", {}) + margins = page.get("margins", {}) + + w = paper.get("width_mm", 210) + ml = _mm(margins.get("left", "20mm")) + mr = _mm(margins.get("right", "20mm")) + body_w = w - ml - mr + + mt = margins.get("top", "20mm") + mb = margins.get("bottom", "20mm") + m_left = margins.get("left", "20mm") + m_right = margins.get("right", "20mm") + + return ( + ".page {\n" + f" width: {body_w:.0f}mm;\n" + " margin: 0 auto;\n" + f" padding: {mt} {m_right} {mb} {m_left};\n" + "}" + ) + + +# ================================================================ +# 헀더 / 푾터 +# ================================================================ + +def _header_footer_css(ti: dict) -> str: + page = ti.get("page", {}) + margins = page.get("margins", {}) + + # 헀더 margin-bottom: page.margins.header에서 유도 + # 푾터 margin-top: page.margins.footer에서 유도 + hdr_margin = margins.get("header", "") + ftr_margin = margins.get("footer", "") + + hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm" + ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm" + + lines = [ + "/* 헀더/푾터 */", + f".doc-header {{ margin-bottom: {hdr_mb}; }}", + f".doc-footer {{ margin-top: {ftr_mt}; }}", + ".doc-header table, .doc-footer table {", + " width: 100%; border-collapse: collapse;", + "}", + ] + + hdr_padding = _hf_cell_padding(ti.get("header")) + ftr_padding = _hf_cell_padding(ti.get("footer")) + + lines.append( + f".doc-header td {{ {hdr_padding} vertical-align: middle; }}" + ) + lines.append( + f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}" + ) + return "\n".join(lines) + + +# ================================================================ +# 제목 랔록 — ★ 하드윔딩 제거, 싀제 데읎터 사용 +# ================================================================ + +def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str: + """제목 랔록 CSS — title_table의 싀제 셀 데읎터에서 추출""" + tables = ti.get("tables", []) + + # semantic_map에서 title_table 읞덱슀 가젞였Ʞ + title_idx = None + if sm: + title_idx = sm.get("title_table") + + title_tbl = None + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + + # 못 찟윌멎 1행 표 쀑 텍슀튞 있는 것 검색 + if not title_tbl: + for t in tables: + rows = t.get("rows", []) + if rows and len(rows) == 1: + for cell in rows[0]: + if cell.get("text", "").strip(): + title_tbl = t + break + if title_tbl: + break + + lines = ["/* 제목 랔록 */"] + + if title_tbl: + # 텍슀튞 있는 셀에서 charPr, paraPr, bf 추출 + title_charpr = None + title_parapr = None + title_bf_id = None + + for row in title_tbl.get("rows", []): + for cell in row: + if cell.get("text", "").strip(): + # ★ primaryCharPrIDRef 사용 (table_v2 추출) + cpr_id = cell.get("primaryCharPrIDRef") + if cpr_id is not None: + title_charpr = next( + (c for c in ti.get("char_styles", []) + if c.get("id") == cpr_id), None + ) + ppr_id = cell.get("primaryParaPrIDRef") + if ppr_id is not None: + title_parapr = next( + (p for p in ti.get("para_styles", []) + if p.get("id") == ppr_id), None + ) + title_bf_id = cell.get("borderFillIDRef") + break + if title_charpr: + break + + # charPr 못 찟윌멎 폎백 (charPrIDRef가 없는 구버전 table.py) + if not title_charpr: + title_charpr = _find_title_charpr(ti) + + # CSS 생성 + font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif" + size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0 + bold = title_charpr.get("bold", False) if title_charpr else False + color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000" + + # 쀄간격 + line_height = _parapr_line_height(title_parapr) if title_parapr else "180%" + align = _parapr_align(title_parapr) if title_parapr else "center" + + # ★ margin/padding — paraPr 또는 page.margins에서 유도 + title_after_mm = "4mm" # Ʞ볞값 + title_padding = "4mm 0" # Ʞ볞값 + if title_parapr: + margin_info = title_parapr.get("margin", {}) + after_hu = margin_info.get("after_hu", 0) + if after_hu: + title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm" + before_hu = margin_info.get("before_hu", 0) + if before_hu or after_hu: + b_mm = before_hu * HU_TO_MM if before_hu else 4 + a_mm = after_hu * HU_TO_MM if after_hu else 0 + title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0" + + lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + f".title-block h1 {{\n" + f" font-family: {font_family};\n" + f" font-size: {size_pt}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" text-align: {align};\n" + f" line-height: {line_height};\n" + f" margin: 0; padding: {title_padding};\n" + f"}}" + ) + + # bf 적용 (파란 하닚선 등) + if title_bf_id: + bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {}) + css_dict = bf_data.get("css", {}) + bf_rules = [] + for prop, val in css_dict.items(): + if val and val.lower() != "none": + bf_rules.append(f" {prop}: {val};") + if bf_rules: + lines.append( + f".title-block {{\n" + + "\n".join(bf_rules) + + "\n}" + ) + else: + lines.append(".title-block { margin-bottom: 4mm; }") + lines.append(".title-table { width: 100%; border-collapse: collapse; }") + lines.append( + ".title-block h1 {\n" + " font-size: 15pt; font-weight: normal;\n" + " text-align: center; margin: 0; padding: 4mm 0;\n" + "}" + ) + + return "\n".join(lines) + + +# ================================================================ +# 섹션 — 하드윔딩 제거 +# ================================================================ + +def _section_css(ti: dict) -> str: + """섹션 CSS — '#큰아읎윘' 또는 '개요1' 슀타음에서 추출""" + lines = ["/* 섹션 */"] + + # 섹션 제목: '#큰아읎윘' 또는 가장 큰 bold charPr + title_charpr = _resolve_style_charpr(ti, "#큰아읎윘") + if not title_charpr or title_charpr.get("id") == 0: + title_charpr = _resolve_style_charpr(ti, "개요1") + if not title_charpr or title_charpr.get("id") == 0: + # 폎백: bold읞 charPr 쀑 가장 큰 것 + for cs in sorted(ti.get("char_styles", []), + key=lambda x: x.get("height_pt", 0), reverse=True): + if cs.get("bold"): + title_charpr = cs + break + + if title_charpr: + size = title_charpr.get("height_pt", 11) + bold = title_charpr.get("bold", True) + color = title_charpr.get("textColor", "#000000") + lines.append( + f".section-title {{\n" + f" font-size: {size}pt;\n" + f" font-weight: {'bold' if bold else 'normal'};\n" + f" color: {color};\n" + f" margin-bottom: 3mm;\n" + f"}}" + ) + else: + lines.append( + ".section-title { font-weight: bold; margin-bottom: 3mm; }" + ) + + lines.append(".section { margin-bottom: 6mm; }") + lines.append(".section-content { text-align: justify; }") + + # content_order êž°ë°˜ 볞묞용 슀타음 + lines.append("/* 읎믞지/묞닚 (content_order) */") + lines.append( + ".img-wrap { text-align: center; margin: 3mm 0; }" + ) + lines.append( + ".img-wrap img { max-width: 100%; height: auto; }" + ) + lines.append( + ".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }" + ) + + return "\n".join(lines) + + +# ================================================================ +# 데읎터 표 Ʞ볞 CSS +# ================================================================ + +def _table_base_css(ti: dict) -> str: + """표 Ʞ볞 — '표낎용' 슀타음 charPr에서 추출""" + tbl_charpr = _resolve_style_charpr(ti, "표낎용") + tbl_parapr = _resolve_style_parapr(ti, "표낎용") + + size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0 + line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%" + align = _parapr_align(tbl_parapr) if tbl_parapr else "justify" + + border_fills = ti.get("border_fills", {}) + if border_fills: + # bf-{id} 큎래슀가 셀별 테두늬륌 닎당 → Ʞ볞값은 none + # (하드윔딩 border륌 넣윌멎 bf 큎래슀볎닀 specificity가 높아 덮얎씀) + border_rule = "border: none;" + else: + # border_fills 추출 싀팚 시에만 폎백 + border_rule = "border: 1px solid #000;" + + return ( + "/* 데읎터 표 */\n" + ".data-table {\n" + " width: 100%; border-collapse: collapse; margin: 4mm 0;\n" + "}\n" + ".data-table th, .data-table td {\n" + f" {border_rule}\n" + f" font-size: {size_pt}pt;\n" + f" line-height: {line_height};\n" + f" text-align: {align};\n" + " vertical-align: middle;\n" + "}\n" + ".data-table th {\n" + " font-weight: bold; text-align: center;\n" + "}" + ) + + +# ================================================================ +# borderFill → .bf-{id} CSS 큎래슀 +# ================================================================ + +def _border_fill_css(ti: dict) -> str: + """★ v2.0: NONE-only bf도 큎래슀 생성 (border: none 명시)""" + border_fills = ti.get("border_fills", {}) + if not border_fills: + return "" + + parts = ["/* borderFill → CSS 큎래슀 */"] + + for bf_id, bf in border_fills.items(): + rules = [] + + css_dict = bf.get("css", {}) + for prop, val in css_dict.items(): + if val: + # NONE도 포핚 (border: none 명시) + rules.append(f" {prop}: {val};") + + # background + if "background-color" not in css_dict: + bg = bf.get("background", "") + if bg and bg.lower() not in ("", "none", "transparent", + "#ffffff", "#fff"): + rules.append(f" background-color: {bg};") + + if rules: + parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: charPr → .cpr-{id} CSS 큎래슀 +# ================================================================ + +def _char_pr_css(ti: dict, fm: dict) -> str: + """charPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포핚. + HTML에서 등윌로 ì°žì¡°. + """ + char_styles = ti.get("char_styles", []) + if not char_styles: + return "" + + parts = ["/* charPr → CSS 큎래슀 (Ꞁ자 몚양) */"] + + for cs in char_styles: + cid = cs.get("id") + rules = [] + + # font-family + ff = _charpr_font_family(cs, fm) + if ff: + rules.append(f" font-family: {ff};") + + # font-size + pt = cs.get("height_pt") + if pt: + rules.append(f" font-size: {pt}pt;") + + # bold + if cs.get("bold"): + rules.append(" font-weight: bold;") + + # italic + if cs.get("italic"): + rules.append(" font-style: italic;") + + # color + color = cs.get("textColor", "#000000") + if color and color.lower() != "#000000": + rules.append(f" color: {color};") + + # underline — type읎 NONE읎 아닌 싀제 밑쀄만 + underline = cs.get("underline", "NONE") + ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"} + if underline in ACTIVE_UNDERLINE: + rules.append(" text-decoration: underline;") + + # strikeout — shape="NONE" 또는 "3D"는 췚소선 아님 + # 싀제 췚소선: CONTINUOUS, DASH, DOT 등 선 슀타음만 + strikeout = cs.get("strikeout", "NONE") + ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT", + "DASH_DOT_DOT", "LONG_DASH", "DOUBLE"} + if strikeout in ACTIVE_STRIKEOUT: + rules.append(" text-decoration: line-through;") + + # ── 자간 (letter-spacing) ── + # HWPX spacing은 % 닚위: letter-spacing = height_pt × spacing / 100 + spacing_pct = cs.get("spacing", {}).get("hangul", 0) + if spacing_pct != 0 and pt: + ls_val = round(pt * spacing_pct / 100, 2) + rules.append(f" letter-spacing: {ls_val}pt;") + + # ── 장평 (scaleX) ── + # HWPX ratio는 Ꞁ자 폭 비윚 (100=Ʞ볞). CSS transform윌로 변환 + ratio_pct = cs.get("ratio", {}).get("hangul", 100) + if ratio_pct != 100: + rules.append(f" transform: scaleX({ratio_pct / 100});") + rules.append(" display: inline-block;") # scaleX 적용 필수 + + if rules: + parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: paraPr → .ppr-{id} CSS 큎래슀 +# ================================================================ + +def _para_pr_css(ti: dict) -> str: + """paraPr 전첎 → 개별 CSS 큎래슀 생성. + + 각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포핚. + HTML에서

                                          등윌로 ì°žì¡°. + """ + para_styles = ti.get("para_styles", []) + if not para_styles: + return "" + + parts = ["/* paraPr → CSS 큎래슀 (묞닚 몚양) */"] + + for ps in para_styles: + pid = ps.get("id") + rules = [] + + # text-align + align = _parapr_align(ps) + if align: + rules.append(f" text-align: {align};") + + # line-height + lh = _parapr_line_height(ps) + if lh: + rules.append(f" line-height: {lh};") + + # text-indent + margin = ps.get("margin", {}) + indent_hu = margin.get("indent_hu", 0) + if indent_hu: + indent_mm = indent_hu * HU_TO_MM + rules.append(f" text-indent: {indent_mm:.1f}mm;") + + # margin-left + left_hu = margin.get("left_hu", 0) + if left_hu: + left_mm = left_hu * HU_TO_MM + rules.append(f" margin-left: {left_mm:.1f}mm;") + + # margin-right + right_hu = margin.get("right_hu", 0) + if right_hu: + right_mm = right_hu * HU_TO_MM + rules.append(f" margin-right: {right_mm:.1f}mm;") + + # spacing before/after + before = margin.get("before_hu", 0) + if before: + rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;") + after = margin.get("after_hu", 0) + if after: + rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;") + + if rules: + parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}") + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# ★ NEW: named style → .sty-{id} CSS 큎래슀 +# ================================================================ + +def _named_style_css(ti: dict) -> str: + """styles 목록 → .sty-{id} CSS 큎래슀. + + 각 style은 charPrIDRef + paraPrIDRef 조합. + → .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의믞. + HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 ì°žì¡°. + """ + styles = ti.get("styles", []) + if not styles: + return "" + + parts = ["/* named styles */"] + + for s in styles: + sid = s.get("id") + name = s.get("name", "") + cpr_id = s.get("charPrIDRef") + ppr_id = s.get("paraPrIDRef") + + # 죌석윌로 맀핑 Ʞ록 + parts.append( + f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */" + ) + + return "\n".join(parts) + + +# ================================================================ +# 표 상섞 CSS (ì—Ž 너비, 셀 팚딩) +# ================================================================ + +def _table_detail_css(ti: dict, sm: dict = None) -> str: + if not sm: + return "" + + body_indices = sm.get("body_tables", []) + tables = ti.get("tables", []) + if not body_indices or not tables: + return "" + + parts = ["/* 표 상섞 (tools 추출값) */"] + + for tbl_num, tbl_idx in enumerate(body_indices, 1): + tbl = next((t for t in tables if t["index"] == tbl_idx), None) + if not tbl: + continue + + cls = f"tbl-{tbl_num}" + + # ì—Ž 너비 + col_pcts = tbl.get("colWidths_pct", []) + if col_pcts: + for c_idx, pct in enumerate(col_pcts): + parts.append( + f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}" + ) + + # 셀 팚딩 + cm = _first_cell_margin(tbl) + if cm: + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + parts.append( + f".{cls} td, .{cls} th {{\n" + f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n" + f"}}" + ) + + # 헀더행 높읎 + first_row = tbl.get("rows", [[]])[0] + if first_row: + h_hu = first_row[0].get("height_hu", 0) + if h_hu > 0: + h_mm = h_hu * HU_TO_MM + parts.append( + f".{cls} thead th {{ height: {h_mm:.1f}mm; }}" + ) + + return "\n".join(parts) if len(parts) > 1 else "" + + +# ================================================================ +# 볎조 핚수 +# ================================================================ + +def _build_font_map(ti: dict) -> dict: + """fonts → {(lang, id): face_name} 딕셔너늬""" + fm = {} + for lang, flist in ti.get("fonts", {}).items(): + if isinstance(flist, list): + for f in flist: + fm[(lang, f.get("id", 0))] = f.get("face", "") + return fm + + +def _charpr_font_family(charpr: dict, fm: dict) -> str: + """charPr의 fontRef → 싀제 font-family CSS 값""" + if not charpr: + return "'맑은 고딕', sans-serif" + + fr = charpr.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + latin_id = fr.get("latin", 0) + + hangul_face = fm.get(("HANGUL", hangul_id), "") + latin_face = fm.get(("LATIN", latin_id), "") + + faces = [] + if hangul_face: + faces.append(f"'{hangul_face}'") + if latin_face and latin_face != hangul_face: + faces.append(f"'{latin_face}'") + faces.append("sans-serif") + + return ", ".join(faces) + + +def _resolve_style_charpr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → charPr dict 핎석""" + styles = ti.get("styles", []) + char_styles = ti.get("char_styles", []) + + for s in styles: + if s.get("name") == style_name: + cpr_id = s.get("charPrIDRef") + for cs in char_styles: + if cs.get("id") == cpr_id: + return cs + + # 못 찟윌멎 charPr[0] (바탕Ꞁ Ʞ볞) + return char_styles[0] if char_styles else {} + + +def _resolve_style_parapr(ti: dict, style_name: str) -> dict: + """슀타음 읎늄 → paraPr dict 핎석""" + styles = ti.get("styles", []) + para_styles = ti.get("para_styles", []) + + for s in styles: + if s.get("name") == style_name: + ppr_id = s.get("paraPrIDRef") + for ps in para_styles: + if ps.get("id") == ppr_id: + return ps + + return para_styles[0] if para_styles else {} + + +def _find_title_charpr(ti: dict) -> dict: + """제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폎백). + + 헀드띌읞 폰튾 or 가장 큰 크Ʞ Ʞ쀀. + """ + headline_keywords = ["헀드띌읞", "headline", "제목", "title"] + fm = _build_font_map(ti) + + best = {} + best_pt = 0 + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + fr = cs.get("fontRef", {}) + hangul_id = fr.get("hangul", 0) + face = fm.get(("HANGUL", hangul_id), "").lower() + + # 헀드띌읞 폰튞멎 우선 + if any(kw in face for kw in headline_keywords): + if pt > best_pt: + best_pt = pt + best = cs + + # 헀드띌읞 폰튾 못 찟윌멎 가장 큰 것 + if not best: + for cs in ti.get("char_styles", []): + pt = cs.get("height_pt", 0) + if pt > best_pt: + best_pt = pt + best = cs + + return best + + +def _parapr_line_height(parapr: dict) -> str: + """paraPr → CSS line-height""" + if not parapr: + return "160%" + ls = parapr.get("lineSpacing", {}) + ls_type = ls.get("type", "PERCENT") + ls_val = ls.get("value", 160) + if ls_type == "PERCENT": + return f"{ls_val}%" + elif ls_type == "FIXED": + return f"{ls_val / 100:.1f}pt" + else: + return f"{ls_val}%" + + +def _parapr_align(parapr: dict) -> str: + """paraPr → CSS text-align""" + if not parapr: + return "justify" + align = parapr.get("align", "JUSTIFY") + return { + "JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right", + "CENTER": "center", "DISTRIBUTE": "justify", + "DISTRIBUTE_SPACE": "justify" + }.get(align, "justify") + + +def _hf_cell_padding(hf_info: dict | None) -> str: + if not hf_info or not hf_info.get("table"): + return "padding: 2px 4px;" + rows = hf_info["table"].get("rows", []) + if not rows or not rows[0]: + return "padding: 2px 4px;" + cm = rows[0][0].get("cellMargin", {}) + if not cm: + return "padding: 2px 4px;" + ct = cm.get("top", 0) * HU_TO_MM + cb = cm.get("bottom", 0) * HU_TO_MM + cl = cm.get("left", 0) * HU_TO_MM + cr = cm.get("right", 0) * HU_TO_MM + return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;" + + +def _first_cell_margin(tbl: dict) -> dict | None: + for row in tbl.get("rows", []): + for cell in row: + cm = cell.get("cellMargin") + if cm: + return cm + return None + + +def _mm(val) -> float: + if isinstance(val, (int, float)): + return float(val) + try: + return float(str(val).replace("mm", "").strip()) + except (ValueError, TypeError): + return 20.0 \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/template/__init__.py b/03. Code/geulbeot_9th/handlers/template/__init__.py new file mode 100644 index 0000000..8187b2d --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/template/__init__.py @@ -0,0 +1,3 @@ +from .processor import TemplateProcessor + +__all__ = ['TemplateProcessor'] \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/template/html_table_template_css.txt b/03. Code/geulbeot_9th/handlers/template/html_table_template_css.txt new file mode 100644 index 0000000..1868522 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/template/html_table_template_css.txt @@ -0,0 +1,1442 @@ +- type 1 table + +1) HTML + +

                                          HTML table advanced features and accessibility

                                          + +

                                          Origin: HTML table advanced features and accessibility - Learn web development | MDN.

                                          + +

                                          Adding a <caption>, and structure with <thead>, <tfoot> and <tbody>

                                          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                          How I chose to spend my money
                                          PurchaseLocationDateEvaluationCost (€)
                                          HaircutHairdresser12/09Great idea30
                                          LasagnaRestaurant12/09Regrets18
                                          ShoesShoeshop13/09Big regrets65
                                          ToothpasteSupermarket13/09Good5
                                          SUM118
                                          + +

                                          Using the scope attribute

                                          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                          Items Sold August 2016
                                            ClothesAccessories
                                            TrousersSkirtsDressesBraceletsRings
                                          BelgiumAntwerp5622437223
                                          Gent4618506115
                                          Brussels5127386928
                                          The NetherlandsAmsterdam8934698538
                                          Utrecht8012433619
                                          + +

                                          Using the id and headers attributes

                                          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                          Items Sold August 2016
                                            ClothesAccessories
                                            TrousersSkirtsDressesBraceletsRings
                                          BelgiumAntwerp5622437223
                                          Gent4618506115
                                          Brussels5127386928
                                          The NetherlandsAmsterdam8934698538
                                          Utrecht8012433619
                                          + + +2) CSS +@import "https://germanfrelo.github.io/base-css-stylesheet/base.css" layer(base); +@import "https://codepen.io/germanfrelo/pen/mdMYKza.css" layer(styles); + +:root { + --page-max-inline-size: 100%; +} + +body { + padding-block: 2rem; +} + +caption { + text-align: start; +} + + + +- type 2 table + +1) HTML +
                                          +

                                          CSS responsive table

                                          +

                                          ...with fixed column and row headers and scroll snap. - @scottjehl

                                          +
                                          +
                                          +
                                          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                          Col HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol HeaderCol Header
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          Row HeaderCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell DataCell Data
                                          +
                                          + +
                                          +
                                          + Site footer +
                                          + +2) CSS +body { + font: 90%/1.4 system-ui; + margin: 0; + font-family: sans-serif; +} +header { + padding: 7vh 5vw; + border-bottom: 1px solid #ddd; +} +header h1, +header p { + margin: 0; +} +footer { + padding: 7vh 5vw; + border-top: 1px solid #ddd; +} +aside { + padding: 7vh 5vw; +} +.primary { + overflow: auto; + scroll-snap-type: both mandatory; + height: 80vh; +} +@media (min-width: 40em) { + main { + display: flex; + } + aside { + flex: 0 1 20vw; + order: 1; + border-right: 1px solid #ddd; + } + .primary { + order: 2; + } +} +table { + border-collapse: collapse; + border: 0; +} +th, +td { + border: 1px solid #aaa; + background-clip: padding-box; + scroll-snap-align: start; +} +tbody tr:last-child th, +tbody tr:last-child td { + border-bottom: 0; +} +thead { + z-index: 1000; + position: relative; +} +th, +td { + padding: 0.6rem; + min-width: 6rem; + text-align: left; + margin: 0; +} +thead th { + position: sticky; + top: 0; + border-top: 0; + background-clip: padding-box; +} +thead th.pin { + left: 0; + z-index: 1001; + border-left: 0; +} +tbody th { + background-clip: padding-box; + border-left: 0; +} +tbody { + z-index: 10; + position: relative; +} +tbody th { + position: sticky; + left: 0; +} +thead th, +tbody th { + background-color: #f8f8f8; +} + + +- type 3 tabel +1) HTML + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                          Developers Rating
                                          AvatarGroupNamePointsControl
                                          imgNinjaAsma Ad120 + + +
                                          imgShadesDavid180 + + +
                                          imgAlex160 + + +
                                          imgValhala Kawtar190 + + +
                                          imgKatara110 + + +
                                          imgUnionAshraf90 + + +
                                          + +2) CSS +table { + width: 700px; + text-align: center; + border: 1px solid #fff; + border-spacing: 1px; + font-family: 'Cairo', sans-serif; + margin: auto; +} + +caption { + font-weight: bold; +} + +table td { + padding: 10px; + background-color: #eee; +} + +table th { + background-color: #333; + color: #fff; + padding: 10px; +} + +img { + width: 90px; + height: 90px; +} + +.view, +.delete { + border: none; + padding: 5px 10px; + color: #fff; + font-weight: bold; +} + +.view { + background-color: #03A9F4; +} + +.delete { + background-color: #E91E63; +} + +.tablefoot { + padding: 0; + border-bottom: 3px solid #009688; +} + +HTML 표 슀타음링은 섞계에서 가장 맀력적읞 음읎 아니지만, 때로는 우늬 몚두가 핎알할 음입니닀. 읎 Ʞ사에서는 특정 표 슀타음링 Ʞ술을 강조 표시하여 HTML 표륌 볎Ʞ좋게 만드는 방법에 대한 안낎서륌 제공합니닀. + +전제조걎: HTML Ʞ볞 사항 (HTML 소개 학습), HTML 표 에 대한 지식 및 CSS 작동 방식에 대한 읎핎 (CSS 첫 번짞 닚계 학습.) +목적: HTML 표륌 횚곌적윌로 슀타음링하는 방법 ë°°ìš°êž°. +In this article +전형적읞 HTML 표 +우늬의 표 슀타음링 +적극적읞 학습: 나만의 표 슀타음 +표 슀타음링 빠륎게 하는 팁 +요앜 +Auth0 +Make login our problem. Not yours. +Your time is valuable. Use it to focus on your app, and let us handle login (and much more). +Try it Free Now +Ad +전형적읞 HTML 표 +전형적읞 HTML 표륌 삎펎 뎅시닀. Ꞁ쎄요, 음반적읞 표의 예듀은 — 신발, 날씚 또는 직원듀에 ꎀ한 것입니닀; 우늬는 영국의 유명한 펑크 밎드에 ꎀ한것을 만듀얎서 더 흥믞롭게 만듀Ʞ로 결정했습니닀. 윔드는 닀음곌 같습니닀. + +html + +Copy + + + + + + + + + + + + + + + + + + + + + + + + + ... some rows removed for brevity + + + + + + + + + + + + + + +
                                          + A summary of the UK's most famous punk bands +
                                          BandYear formedNo. of AlbumsMost famous song
                                          Buzzcocks19769Ever fallen in love (with someone you shouldn't've)
                                          The Clash19766London Calling
                                          The Stranglers197417No More Heroes
                                          Total albums77
                                          +scope,
                                        ,
                                        요소읞 n번짞 자식 요소
                                        및 요소에 padding 을 섀정했습니닀 — 읎렇게 하멎 데읎터 항목에 숚을 공간읎 생깁니닀. 표륌 훚씬 읜Ʞ 쉜게 볎읎게합니닀. + +읎 시점에서, 우늬 표는 읎믞 훚씬 좋아볎입니닀. + + + +간닚한 typography +읎제 텍슀튞륌 앜간 정늬핎 볎겠습니닀. + +우선, Google Fonts 에서 펑크 밮드 ꎀ렚 표에 적합한 Ꞁꌎ을 찟았습니닀. 원하는 겜우 거Ʞ에 가서 닀륞 것을 찟을 수 있습니닀. 제공된 요소 및 custom font-family 선얞을 Google Fonts 에서 제공하는 선얞윌로 바꟞멎 됩니닀. + +뚌저, 닀음 요소륌 Ʞ졎 요소 바로 위의 HTML head 에 추가하십시였. + +html + +Copy + +읎제 읎전 CSS 아래의 style.css 파음에, 닀음 CSS 륌 추가하십시였. + +css + +Copy +/* typography */ + +html { + font-family: "helvetica neue", helvetica, arial, sans-serif; +} + +thead th, +tfoot th { + font-family: "Rock Salt", cursive; +} + +th { + letter-spacing: 2px; +} + +td { + letter-spacing: 1px; +} + +tbody td { + text-align: center; +} + +tfoot th { + text-align: right; +} +여Ʞ에서는 표에 특별한 것은 없습니닀. 우늬는 음반적윌로 쉜게 읜을 수 있도록 Ꞁꌎ 슀타음을 조정합니닀. + +전역 sans-serif Ꞁꌎ 슀택을 섀정했습니닀; 읎것은 순전히 묞첎 선택입니닀. 또한
                                        및 요소에 선형 귞띌데읎션을 추가하여 앜간의 질감을 개선하였, 밝은 볎띌색 테두늬륌 부여했습니닀. 쀑첩된 요소륌 여러 개의 쀑첩된 요소륌 사용하여 슀타음을 서로 겹칠 수 있는 것읎 유용합니닀. 예, 여러 배겜 읎믞지륌 사용하여
                                        ) 에서 width 륌 섀정하여 ì—Ž 너비륌 쉜게 섀정할 수 있는 예잡 가능한 표 레읎아웃을 작성합니닀. +border-collapse: collapse 륌 사용하여 표 요소 테두늬륌 서로 접얎서 깔끔하게 만듀 수 있습니닀. +
                                        및 텍슀튞륌 정렬하여, 더 깜끔하고 쉜게 따띌할 수 있도록 하십시였. \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/template/processor.py b/03. Code/geulbeot_9th/handlers/template/processor.py new file mode 100644 index 0000000..f8cb6d1 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/template/processor.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +""" +템플늿 처늬 로직 (v3 - 싀제 구조 정확 분석) +- HWPX 파음의 싀제 표 구조, 읎믞지 배겜, 테두늬 정확히 추출 +- ARGB 8자늬 색상 정규화 +- NONE 테두늬 색상 제왞 +""" + +import os +import json +import uuid +import shutil +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional +from collections import Counter, defaultdict + +# 템플늿 저장 겜로 +TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store' +TEMPLATES_DIR.mkdir(exist_ok=True) + +# HWP 명섞서 êž°ë°˜ 상수 +LINE_TYPES = { + 'NONE': '없음', + 'SOLID': '싀선', + 'DASH': 'ꞎ 점선', + 'DOT': '점선', + 'DASH_DOT': '-.-.-.-.', + 'DASH_DOT_DOT': '-..-..-..', + 'DOUBLE_SLIM': '2쀑선', + 'SLIM_THICK': '가는선+굵은선', + 'THICK_SLIM': '굵은선+가는선', + 'SLIM_THICK_SLIM': '가는선+굵은선+가는선', + 'WAVE': '묌결', + 'DOUBLE_WAVE': '묌결 2쀑선', +} + + +class TemplateProcessor: + """템플늿 처늬 큎래슀 (v3)""" + + NS = { + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'hs': 'http://www.hancom.co.kr/hwpml/2011/section', + } + + def __init__(self): + self.templates_dir = TEMPLATES_DIR + self.templates_dir.mkdir(exist_ok=True) + + # ========================================================================= + # 공개 API + # ========================================================================= + + def get_list(self) -> Dict[str, Any]: + """저장된 템플늿 목록""" + templates = [] + for item in self.templates_dir.iterdir(): + if item.is_dir(): + meta_path = item / 'meta.json' + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + templates.append({ + 'id': meta.get('id', item.name), + 'name': meta.get('name', item.name), + 'features': meta.get('features', []), + 'created_at': meta.get('created_at', '') + }) + except: + pass + templates.sort(key=lambda x: x.get('created_at', ''), reverse=True) + return {'templates': templates} + + def analyze(self, file, name: str) -> Dict[str, Any]: + """템플늿 파음 분석 및 저장""" + filename = file.filename + ext = Path(filename).suffix.lower() + + if ext not in ['.hwpx', '.hwp', '.pdf']: + return {'error': f'지원하지 않는 파음 형식: {ext}'} + + template_id = str(uuid.uuid4())[:8] + template_dir = self.templates_dir / template_id + template_dir.mkdir(exist_ok=True) + + try: + original_path = template_dir / f'original{ext}' + file.save(str(original_path)) + + if ext == '.hwpx': + style_data = self._analyze_hwpx(original_path, template_dir) + else: + style_data = self._analyze_fallback(ext) + + if 'error' in style_data: + shutil.rmtree(template_dir) + return style_data + + # 특징 추출 + features = self._extract_features(style_data) + + # 메타 저장 + meta = { + 'id': template_id, + 'name': name, + 'original_file': filename, + 'file_type': ext, + 'features': features, + 'created_at': datetime.now().isoformat() + } + (template_dir / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # 슀타음 저장 + (template_dir / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8' + ) + + # CSS 저장 + css = style_data.get('css', '') + css_dir = template_dir / 'css' + css_dir.mkdir(exist_ok=True) + (css_dir / 'template.css').write_text(css, encoding='utf-8') + + return { + 'success': True, + 'template': { + 'id': template_id, + 'name': name, + 'features': features, + 'created_at': meta['created_at'] + } + } + except Exception as e: + if template_dir.exists(): + shutil.rmtree(template_dir) + raise e + + def delete(self, template_id: str) -> Dict[str, Any]: + """템플늿 삭제""" + template_dir = self.templates_dir / template_id + if not template_dir.exists(): + return {'error': '템플늿을 찟을 수 없습니닀'} + shutil.rmtree(template_dir) + return {'success': True, 'deleted': template_id} + + def get_style(self, template_id: str) -> Optional[Dict[str, Any]]: + """템플늿 슀타음 반환""" + style_path = self.templates_dir / template_id / 'style.json' + if not style_path.exists(): + return None + return json.loads(style_path.read_text(encoding='utf-8')) + + # ========================================================================= + # HWPX 분석 (핵심) + # ========================================================================= + + def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]: + """HWPX 분석 - 싀제 구조 정확히 추출""" + extract_dir = template_dir / 'extracted' + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + zf.extractall(extract_dir) + + result = { + 'version': 'v3', + 'fonts': {}, + 'colors': { + 'background': [], + 'border': [], + 'text': [] + }, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': {}, + 'css': '' + } + + # 1. header.xml 분석 + header_path = extract_dir / 'Contents' / 'header.xml' + if header_path.exists(): + self._parse_header(header_path, result) + + # 2. section0.xml 분석 + section_path = extract_dir / 'Contents' / 'section0.xml' + if section_path.exists(): + self._parse_section(section_path, result) + + # 3. 슀타음 요앜 생성 + result['style_summary'] = self._create_style_summary(result) + + # 4. CSS 생성 + result['css'] = self._generate_css(result) + + return result + + finally: + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + def _parse_header(self, header_path: Path, result: Dict): + """header.xml 파싱 - 폰튾, borderFill""" + tree = ET.parse(header_path) + root = tree.getroot() + + # 폰튾 + for fontface in root.findall('.//hh:fontface', self.NS): + if fontface.get('lang') == 'HANGUL': + for font in fontface.findall('hh:font', self.NS): + result['fonts'][font.get('id')] = font.get('face') + + # borderFill + for bf in root.findall('.//hh:borderFill', self.NS): + bf_id = bf.get('id') + bf_data = self._parse_border_fill(bf, result) + result['border_fills'][bf_id] = bf_data + + def _parse_border_fill(self, bf, result: Dict) -> Dict: + """개별 borderFill 파싱""" + bf_id = bf.get('id') + data = { + 'id': bf_id, + 'type': 'empty', + 'background': None, + 'image': None, + 'borders': {} + } + + # 읎믞지 배겜 + img_brush = bf.find('.//hc:imgBrush', self.NS) + if img_brush is not None: + img = img_brush.find('hc:img', self.NS) + if img is not None: + data['type'] = 'image' + data['image'] = { + 'ref': img.get('binaryItemIDRef'), + 'effect': img.get('effect') + } + + # 닚색 배겜 + win_brush = bf.find('.//hc:winBrush', self.NS) + if win_brush is not None: + face_color = self._normalize_color(win_brush.get('faceColor')) + if face_color and face_color != 'none': + if data['type'] == 'empty': + data['type'] = 'solid' + data['background'] = face_color + if face_color not in result['colors']['background']: + result['colors']['background'].append(face_color) + + # 4방향 테두늬 + for side in ['top', 'bottom', 'left', 'right']: + border = bf.find(f'hh:{side}Border', self.NS) + if border is not None: + border_type = border.get('type', 'NONE') + width = border.get('width', '0.1 mm') + color = self._normalize_color(border.get('color', '#000000')) + + data['borders'][side] = { + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'width_mm': self._parse_width(width), + 'color': color + } + + # 볎읎는 테두늬만 색상 수집 + if border_type != 'NONE': + if data['type'] == 'empty': + data['type'] = 'border_only' + if color and color not in result['colors']['border']: + result['colors']['border'].append(color) + + # 특수 테두늬 수집 + if border_type not in ['SOLID', 'NONE']: + result['special_borders'].append({ + 'bf_id': bf_id, + 'side': side, + 'type': border_type, + 'type_name': LINE_TYPES.get(border_type, border_type), + 'width': width, + 'color': color + }) + + return data + + def _parse_section(self, section_path: Path, result: Dict): + """section0.xml 파싱 - 표 구조""" + tree = ET.parse(section_path) + root = tree.getroot() + + border_fills = result['border_fills'] + + for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'): + table_data = { + 'rows': int(tbl.get('rowCnt', 0)), + 'cols': int(tbl.get('colCnt', 0)), + 'cells': [], + 'structure': { + 'header_row_style': None, + 'first_col_style': None, + 'body_style': None, + 'has_image_cells': False + } + } + + # 셀별 분석 + cell_by_position = {} + for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'): + cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr') + if cell_addr is None: + continue + + row = int(cell_addr.get('rowAddr', 0)) + col = int(cell_addr.get('colAddr', 0)) + bf_id = tc.get('borderFillIDRef') + bf_info = border_fills.get(bf_id, {}) + + # 텍슀튞 추출 + text = '' + for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'): + if t.text: + text += t.text + + cell_data = { + 'row': row, + 'col': col, + 'bf_id': bf_id, + 'bf_type': bf_info.get('type'), + 'background': bf_info.get('background'), + 'image': bf_info.get('image'), + 'text_preview': text[:30] if text else '' + } + + table_data['cells'].append(cell_data) + cell_by_position[(row, col)] = cell_data + + if bf_info.get('type') == 'image': + table_data['structure']['has_image_cells'] = True + + # 구조 분석: 헀더행, 첫엎 슀타음 + self._analyze_table_structure(table_data, cell_by_position, border_fills) + + result['tables'].append(table_data) + + def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict): + """표 구조 분석 - 헀더행/첫엎 슀타음 파악""" + rows = table_data['rows'] + cols = table_data['cols'] + + if rows == 0 or cols == 0: + return + + # 첫 행 (헀더) 분석 + header_styles = [] + for c in range(cols): + cell = cells.get((0, c)) + if cell: + header_styles.append(cell.get('bf_id')) + + if header_styles: + # 가장 많읎 쓰읞 슀타음 + most_common = Counter(header_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['header_row_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background'), + 'borders': bf.get('borders', {}) + } + + # 첫 ì—Ž 분석 (행 1부터) + first_col_styles = [] + for r in range(1, rows): + cell = cells.get((r, 0)) + if cell: + first_col_styles.append(cell.get('bf_id')) + + if first_col_styles: + most_common = Counter(first_col_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + if bf and bf.get('background'): + table_data['structure']['first_col_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') + } + + # 볞묞 셀 슀타음 (첫엎 제왞) + body_styles = [] + for r in range(1, rows): + for c in range(1, cols): + cell = cells.get((r, c)) + if cell: + body_styles.append(cell.get('bf_id')) + + if body_styles: + most_common = Counter(body_styles).most_common(1) + if most_common: + bf_id = most_common[0][0] + bf = border_fills.get(bf_id) + table_data['structure']['body_style'] = { + 'bf_id': bf_id, + 'background': bf.get('background') if bf else None + } + + def _create_style_summary(self, result: Dict) -> Dict: + """AI 프롬프튞용 슀타음 요앜""" + summary = { + '폰튾': list(result['fonts'].values())[:3], + '색상': { + '배겜색': result['colors']['background'], + '테두늬색': result['colors']['border'] + }, + '표_슀타음': [], + '특수_테두늬': [] + } + + # 표별 슀타음 요앜 + for i, tbl in enumerate(result['tables']): + tbl_summary = { + '표번혞': i + 1, + '크Ʞ': f"{tbl['rows']}행 × {tbl['cols']}ì—Ž", + '읎믞지셀': tbl['structure']['has_image_cells'] + } + + header = tbl['structure'].get('header_row_style') + if header: + tbl_summary['헀더행'] = f"배겜={header.get('background')}" + + first_col = tbl['structure'].get('first_col_style') + if first_col: + tbl_summary['첫엎'] = f"배겜={first_col.get('background')}" + + body = tbl['structure'].get('body_style') + if body: + tbl_summary['볞묞'] = f"배겜={body.get('background') or '없음'}" + + summary['표_슀타음'].append(tbl_summary) + + # 특수 테두늬 요앜 + seen = set() + for sb in result['special_borders']: + key = f"{sb['type_name']} {sb['width']} {sb['color']}" + if key not in seen: + seen.add(key) + summary['특수_테두늬'].append(key) + + return summary + + def _generate_css(self, result: Dict) -> str: + """CSS 생성 - 싀제 구조 반영""" + fonts = list(result['fonts'].values())[:2] + font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'" + + bg_colors = result['colors']['background'] + header_bg = bg_colors[0] if bg_colors else '#D6D6D6' + + # 특수 테두늬에서 2쀑선 ì°Ÿêž° + double_border = None + for sb in result['special_borders']: + if 'DOUBLE' in sb['type']: + double_border = sb + break + + css = f"""/* 템플늿 슀타음 v3 - HWPX 구조 êž°ë°˜ */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +:root {{ + --font-primary: 'Noto Sans KR', {font_family}, sans-serif; + --color-header-bg: {header_bg}; + --color-border: #000000; +}} + +body {{ + font-family: var(--font-primary); + font-size: 10pt; + line-height: 1.6; + color: #000000; +}} + +.sheet {{ + width: 210mm; + min-height: 297mm; + padding: 20mm; + margin: 10px auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +}} + +@media print {{ + .sheet {{ margin: 0; box-shadow: none; page-break-after: always; }} +}} + +/* 표 Ʞ볞 */ +table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 9pt; +}} + +th, td {{ + border: 0.12mm solid var(--color-border); + padding: 6px 8px; + vertical-align: middle; +}} + +/* 헀더 행 */ +thead th, tr:first-child th, tr:first-child td {{ + background-color: var(--color-header-bg); + font-weight: bold; + text-align: center; +}} + +/* 첫 ì—Ž (구분 ì—Ž) - 배겜색 */ +td:first-child {{ + background-color: var(--color-header-bg); + text-align: center; + font-weight: 500; +}} + +/* 볞묞 셀 - 배겜 없음 */ +td:not(:first-child) {{ + background-color: transparent; +}} + +/* 2쀑선 테두늬 (헀더 하당) */ +thead tr:last-child th, +thead tr:last-child td, +tr:first-child th, +tr:first-child td {{ + border-bottom: 0.5mm double var(--color-border); +}} +""" + return css + + # ========================================================================= + # 유틞늬티 + # ========================================================================= + + def _normalize_color(self, color: str) -> str: + """ARGB 8자늬 → RGB 6자늬""" + if not color or color == 'none': + return color + color = color.strip() + # #AARRGGBB → #RRGGBB + if color.startswith('#') and len(color) == 9: + return '#' + color[3:] + return color + + def _parse_width(self, width_str: str) -> float: + """너비 묞자엎 → mm""" + if not width_str: + return 0.1 + try: + return float(width_str.split()[0]) + except: + return 0.1 + + def _extract_features(self, data: Dict) -> List[str]: + """특징 목록""" + features = [] + + fonts = list(data.get('fonts', {}).values()) + if fonts: + features.append(f"폰튾: {', '.join(fonts[:2])}") + + bg_colors = data.get('colors', {}).get('background', []) + if bg_colors: + features.append(f"배겜색: {', '.join(bg_colors[:2])}") + + tables = data.get('tables', []) + if tables: + has_img = any(t['structure']['has_image_cells'] for t in tables) + if has_img: + features.append("읎믞지 배겜 셀") + + special = data.get('special_borders', []) + if special: + types = set(s['type_name'] for s in special) + features.append(f"특수 테두늬: {', '.join(list(types)[:2])}") + + return features if features else ['Ʞ볞 템플늿'] + + def _analyze_fallback(self, ext: str) -> Dict: + """HWP, PDF Ʞ볞 분석""" + return { + 'version': 'v3', + 'fonts': {'0': '맑은 고딕'}, + 'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']}, + 'border_fills': {}, + 'tables': [], + 'special_borders': [], + 'style_summary': { + '폰튾': ['맑은 고딕'], + '색상': {'배겜색': [], '테두늬색': ['#000000']}, + '표_슀타음': [], + '특수_테두늬': [] + }, + 'css': self._get_default_css(), + 'note': f'{ext} 파음은 Ʞ볞 분석만 지원. HWPX 권장.' + } + + def _get_default_css(self) -> str: + return """/* Ʞ볞 슀타음 */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap'); + +body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; } +.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; } +table { width: 100%; border-collapse: collapse; } +th, td { border: 0.5pt solid #000; padding: 8px; } +th { background: #D6D6D6; } +""" \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/template/prompts/analyze_template.txt b/03. Code/geulbeot_9th/handlers/template/prompts/analyze_template.txt new file mode 100644 index 0000000..e6fe8cf --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/template/prompts/analyze_template.txt @@ -0,0 +1,28 @@ +당신은 묞서 템플늿 분석 전묞가입니닀. + +죌얎진 HWPX/HWP/PDF 템플늿의 구조륌 분석하여 닀음 정볎륌 추출핎죌섞요: + +1. 제목 슀타음 (H1~H6) + - 폰튞명, 크Ʞ(pt), 굵Ʞ, 색상 + - 정렬 방식 + - 번혞 첎계 (제1장, 1.1, 가. 등) + +2. 볞묞 슀타음 + - Ʞ볞 폰튾, 크Ʞ, 쀄간격 + - 듀여쓰Ʞ + +3. 표 슀타음 + - 헀더 배겜색 + - 테두늬 슀타음 (선 두께, 색상) + - 읎쀑선 사용 여부 + +4. 귞늌/캡션 슀타음 + - 캡션 위치 (상/하) + - 캡션 형식 + +5. 페읎지 구성 + - 표지 유묎 + - 목찚 유묎 + - 뚞늬말/ꌬ늬말 + +분석 결곌륌 JSON 형식윌로 출력핎죌섞요. \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/template_manager.py b/03. Code/geulbeot_9th/handlers/template_manager.py new file mode 100644 index 0000000..8894bd7 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/template_manager.py @@ -0,0 +1,1010 @@ +# -*- coding: utf-8 -*- +""" +템플늿 ꎀ늬자 (Template Manager) v5.2 +- 템플늿의 독늜적 CRUD (생성/조회/삭제/교첎) +- 묞서 유형(DocType)곌 분늬된 저장 구조 +- HWPX에서 템플늿 추출 → templates/user/templates/{tpl_id}/ 에 저장 + +★ v5.2 변겜: + - _build_body_html() 재섀계: content_order êž°ë°˜ 볞묞 생성 + → 묞닚·읎믞지·표륌 원볞 순서 귞대로 HTML 조늜 + → content_order 없윌멎 Ʞ졎 legacy 방식 자동 fallback + - _build_title_block_html() 분늬 (재사용성) + +★ 저장 구조: +templates/user/ +├── doc_types/{type_id}/ +│ ├── config.json ← 유형 정볎 (맥띜/구조/가읎드) +│ └── template_id: "tpl_xxx" ← ì–Žë–€ 템플늿 찞조하는지 +│ +└── templates/{tpl_id}/ + ├── template.html ← HTML 곚격 + placeholder + ├── style.json ← 테두늬/폰튾/색상/여백/borderFill + └── meta.json ← 읎늄, 출처, 생성음 + +★ 사용 흐멄: +1) "템플늿 추가" → extract_and_save(hwpx_path, name) → tpl_id +2) "묞서 유형 추가" → doc_type_analyzer가 낎부적윌로 extract_and_save 혞출 +3) "템플늿 교첎" → change_template(type_id, new_tpl_id) +4) "묞서 생성" → load_template(tpl_id) → template.html + style.json +""" + +import json +import time +import shutil +from pathlib import Path +from typing import Optional + + +class TemplateManager: + """템플늿 독늜 ꎀ늬""" + + # Ʞ볞 겜로 + TEMPLATES_USER = Path('templates/user/templates') + TEMPLATES_DEFAULT = Path('templates/default/templates') + DOC_TYPES_USER = Path('templates/user/doc_types') + + def __init__(self, base_path: str = None): + if base_path: + self.TEMPLATES_USER = Path(base_path) / 'user' / 'templates' + self.TEMPLATES_DEFAULT = Path(base_path) / 'default' / 'templates' + self.DOC_TYPES_USER = Path(base_path) / 'user' / 'doc_types' + + # ================================================================ + # 핵심 API + # ================================================================ + + def extract_and_save(self, parsed: dict, name: str, + source_file: str = "", description: str = "") -> dict: + """ + HWPX 파싱 결곌에서 템플늿 추출 후 저장 + + Args: + parsed: HWPX 파서 결곌 (raw_xml, tables, section_xml, header_xml, footer_xml) + name: 템플늿 읎늄 (예: "GPD 발표Ʞ획서 양식") + source_file: 원볞 파음명 + description: 섀명 + + Returns: + {"success": True, "template_id": "tpl_xxx", "path": "...", "template_info": {...}} + """ + from .doc_template_analyzer import DocTemplateAnalyzer + + try: + analyzer = DocTemplateAnalyzer() + + # ① 구조 추출 (template_info) + template_info = analyzer.analyze(parsed) + + # ①-b semantic_map 생성 (표 역할 분류, 섹션 감지) + from . import semantic_mapper + semantic_map = semantic_mapper.generate(template_info, parsed) + + # ② HTML 생성 (semantic_map윌로 표 필터링) + template_html = self._generate_basic_html(template_info, parsed, semantic_map) + + # 저장 + tpl_id = f"tpl_{int(time.time())}" + tpl_path = self.TEMPLATES_USER / tpl_id + tpl_path.mkdir(parents=True, exist_ok=True) + + # template.html + (tpl_path / 'template.html').write_text(template_html, encoding='utf-8') + + # style.json (template_info + 추출된 슀타음) + style_data = { + "version": "v4", + "source": "doc_template_analyzer", + "template_info": template_info, + "css": "", # 추후 컀슀텀 CSS 였버띌읎드용 + "fonts": {}, + "colors": self._extract_colors(template_info), + "border_fills": template_info.get("border_fills", {}), + "tables": [], + "style_summary": {} + } + (tpl_path / 'style.json').write_text( + json.dumps(style_data, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # meta.json + meta = { + "id": tpl_id, + "name": name, + "original_file": source_file, + "file_type": Path(source_file).suffix if source_file else ".hwpx", + "description": description, + "features": self._summarize_features(template_info, semantic_map), + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": "doc_template_analyzer" + } + (tpl_path / 'meta.json').write_text( + json.dumps(meta, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + # semantic_map.json + (tpl_path / 'semantic_map.json').write_text( + json.dumps(semantic_map, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "template_id": tpl_id, + "path": str(tpl_path), + "template_info": template_info, + "semantic_map": semantic_map, + "meta": meta + } + + except Exception as e: + import traceback + return {"error": str(e), "trace": traceback.format_exc()} + + def load_template(self, tpl_id: str) -> dict: + """ + 템플늿 로드 (template.html + style.json) + + Returns: + {"html": "...", "style": {...}, "meta": {...}} + """ + # 사용자 템플늿 → Ʞ볞 템플늿 순서로 탐색 + for base in [self.TEMPLATES_USER, self.TEMPLATES_DEFAULT]: + tpl_path = base / tpl_id + if tpl_path.exists(): + result = {} + + html_file = tpl_path / 'template.html' + if html_file.exists(): + result["html"] = html_file.read_text(encoding='utf-8') + + style_file = tpl_path / 'style.json' + if style_file.exists(): + result["style"] = json.loads(style_file.read_text(encoding='utf-8')) + + meta_file = tpl_path / 'meta.json' + if meta_file.exists(): + result["meta"] = json.loads(meta_file.read_text(encoding='utf-8')) + + result["template_id"] = tpl_id + result["path"] = str(tpl_path) + return result + + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + def list_templates(self) -> list: + """몚든 템플늿 목록 조회""" + templates = [] + + for base, is_default in [(self.TEMPLATES_DEFAULT, True), (self.TEMPLATES_USER, False)]: + if not base.exists(): + continue + for folder in sorted(base.iterdir()): + if not folder.is_dir(): + continue + meta_file = folder / 'meta.json' + if meta_file.exists(): + try: + meta = json.loads(meta_file.read_text(encoding='utf-8')) + meta["is_default"] = is_default + templates.append(meta) + except: + templates.append({ + "id": folder.name, + "name": folder.name, + "is_default": is_default + }) + + return templates + + def delete_template(self, tpl_id: str) -> dict: + """템플늿 삭제 (사용자 템플늿만)""" + tpl_path = self.TEMPLATES_USER / tpl_id + + if not tpl_path.exists(): + return {"error": f"템플늿을 찟을 수 없습니닀: {tpl_id}"} + + # 읎 템플늿을 찞조하는 DocType읎 있는지 확읞 + referencing = self._find_referencing_doc_types(tpl_id) + if referencing: + names = ', '.join(r['name'] for r in referencing[:3]) + return { + "error": f"읎 템플늿을 사용 쀑읞 묞서 유형읎 있습니닀: {names}", + "referencing_types": referencing + } + + shutil.rmtree(tpl_path) + return {"success": True, "deleted": tpl_id} + + def change_template(self, type_id: str, new_tpl_id: str) -> dict: + """ + 묞서 유형의 템플늿 교첎 + + Args: + type_id: 묞서 유형 ID + new_tpl_id: 새 템플늿 ID + """ + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + # 새 템플늿 졎재 확읞 + new_tpl = self.load_template(new_tpl_id) + if "error" in new_tpl: + return new_tpl + + # config 업데읎튞 + config = json.loads(config_path.read_text(encoding='utf-8')) + old_tpl_id = config.get("template_id", "") + config["template_id"] = new_tpl_id + config["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ") + + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding='utf-8' + ) + + return { + "success": True, + "type_id": type_id, + "old_template_id": old_tpl_id, + "new_template_id": new_tpl_id + } + + def get_template_for_doctype(self, type_id: str) -> dict: + """묞서 유형에 연결된 템플늿 로드""" + config_path = self.DOC_TYPES_USER / type_id / 'config.json' + + if not config_path.exists(): + # default에서도 탐색 + config_path = self.TEMPLATES_DEFAULT.parent / 'doc_types' / type_id / 'config.json' + + if not config_path.exists(): + return {"error": f"묞서 유형을 찟을 수 없습니닀: {type_id}"} + + config = json.loads(config_path.read_text(encoding='utf-8')) + tpl_id = config.get("template_id") + + if not tpl_id: + # ★ 하위 혾환: template_id가 없윌멎 같은 폎더의 template.html 사용 + legacy_path = config_path.parent / 'template.html' + if legacy_path.exists(): + return { + "html": legacy_path.read_text(encoding='utf-8'), + "style": {}, + "meta": {"id": type_id, "name": "레거시 템플늿"}, + "template_id": None, + "legacy": True + } + return {"error": "연결된 템플늿읎 없습니닀"} + + return self.load_template(tpl_id) + + # ================================================================ + # 낎부 유틞 + # ================================================================ + + def _find_referencing_doc_types(self, tpl_id: str) -> list: + """특정 템플늿을 찞조하는 DocType 목록""" + result = [] + if not self.DOC_TYPES_USER.exists(): + return result + + for folder in self.DOC_TYPES_USER.iterdir(): + config_file = folder / 'config.json' + if config_file.exists(): + try: + config = json.loads(config_file.read_text(encoding='utf-8')) + if config.get("template_id") == tpl_id: + result.append({ + "id": config.get("id", folder.name), + "name": config.get("name", folder.name) + }) + except: + pass + return result + + + def _generate_basic_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """tools 추출 결곌 + style_generator → template.html 생성""" + # ① CSS 생성 (style_generator) + from . import style_generator + css = style_generator.generate_css(template_info, semantic_map) + + # ② 헀더 HTML + header_html = self._build_header_html(template_info.get("header")) + + # ③ 푾터 HTML + footer_html = self._build_footer_html(template_info.get("footer")) + + # ④ 볞묞 HTML (섹션 + 표) + body_html = self._build_body_html(template_info, parsed, semantic_map) + + # â‘€ 조늜 + html = f""" + + + +Template + + + +
                                        + +{header_html} + +{body_html} + +{footer_html} + +
                                        + +""" + return html + + # ── 볎조 메서드듀 ── + def _build_header_html(self, header_info: dict | None) -> str: + """header tools 추출값 → HTML + placeholder""" + if not header_info or not header_info.get("exists"): + return "" + + html = '
                                        \n' + + if header_info.get("type") == "table" and header_info.get("table"): + tbl = header_info["table"] + rows = tbl.get("rows", []) + col_pcts = tbl.get("colWidths_pct", []) + + # ★ 추가: colWidths_pct 없윌멎 셀 width_hu에서 계산 + if not col_pcts and rows: + widths = [c.get("width_hu", 0) for c in rows[0]] + total = sum(widths) + if total > 0: + col_pcts = [round(w / total * 100) for w in widths] + + html += '\n' + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + lines = cell.get("lines", []) + cell_text = cell.get("text", "").strip() # ★ 추가 + ph_name = f"HEADER_R{r_idx+1}_C{c_idx+1}" + + # ★ 수정: 텍슀튞 없는 셀은 비움 + if not cell_text and not lines: + content = "" + elif len(lines) > 1: + # 멀티띌읞 셀 → 각 띌읞별 placeholder + line_phs = [] + for l_idx in range(len(lines)): + line_phs.append(f"{{{{{ph_name}_LINE_{l_idx+1}}}}}") + content = "
                                        ".join(line_phs) + else: + content = f"{{{{{ph_name}}}}}" + + # colSpan/rowSpan + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + if cell.get("colSpan", 1) > 1: + attrs += f' colspan="{cell["colSpan"]}"' + if cell.get("rowSpan", 1) > 1: + attrs += f' rowspan="{cell["rowSpan"]}"' + + html += f' {content}\n' + html += '\n' + + html += '
                                        \n' + else: + # 텍슀튞형 헀더 + texts = header_info.get("texts", []) + for i in range(max(len(texts), 1)): + html += f'
                                        {{{{{f"HEADER_TEXT_{i+1}"}}}}}
                                        \n' + + html += '
                                        ' + return html + + def _build_footer_html(self, footer_info: dict | None) -> str: + """footer tools 추출값 → HTML + placeholder""" + if not footer_info or not footer_info.get("exists"): + return "" + + html = '' + return html + + def _build_body_html(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """볞묞 영역 HTML 생성. + + ★ v5.2: content_order가 있윌멎 원볞 순서 귞대로 조늜. + content_order 없윌멎 Ʞ졎 섹션+표 방식 (하위 혾환). + """ + content_order = template_info.get("content_order") + + if content_order and self._has_paragraph_content(content_order): + return self._build_body_from_content_order( + template_info, content_order, semantic_map + ) + else: + return self._build_body_legacy( + template_info, parsed, semantic_map + ) + + # ── content_order êž°ë°˜ 볞묞 생성 (v5.2+) ── + + def _has_paragraph_content(self, content_order: list) -> bool: + """content_order에 묞닚읎 있는지 (표만 있윌멎 legacy 사용)""" + return any( + c['type'] == 'paragraph' for c in content_order + ) + + def _build_body_from_content_order(self, template_info: dict, + content_order: list, + semantic_map: dict = None) -> str: + """content_order êž°ë°˜ — 원볞 묞서 순서 귞대로 HTML 조늜. + + 윘텐잠 유형별 처늬: + paragraph →

                                        {{CONTENT_n}}

                                        + table → data-table placeholder (title_table 제왞) + image →
                                        {{IMAGE_n}}
                                        + empty → 생략 (연속 빈 묞닚 의믞 없음) + """ + import re + + tables = template_info.get("tables", []) + + # semantic_map에서 title/body 읞덱슀 + title_table_idx = None + body_table_indices = [] + if semantic_map: + title_table_idx = semantic_map.get("title_table") + body_table_indices = semantic_map.get("body_tables", []) + else: + body_table_indices = [t["index"] for t in tables] + + # ★ v5.4: content_order 순서대로 표 맀칭 + # content_order.table_idx는 section 볞묞에서 만난 순서읎지만, + # tables 늬슀튞 순서와 닀륌 수 있윌므로 순찚 컀서 방식윌로 맀칭 + # header/footer/title 제왞한 순수 body_table만 추출 + exclude_indices = set() + if semantic_map: + # header_table, footer_table 제왞 + for idx_key, role_info in semantic_map.get("table_roles", {}).items(): + role = role_info.get("role", "") + if role in ("header_table", "footer_table"): + try: + exclude_indices.add(int(idx_key)) + except (ValueError, TypeError): + pass + # title_table도 제왞 (별도 처늬됚) + if title_table_idx is not None: + exclude_indices.add(title_table_idx) + + body_visible_tables = [ + t for t in tables + if t["index"] not in exclude_indices + ] + + # 순찚 맀칭을 위한 컀서 + body_tbl_cursor = 0 + + body_parts = [] + + # ── 제목 랔록 (title_table읎 있윌멎) ── + if title_table_idx is not None: + title_tbl = next( + (t for t in tables if t["index"] == title_table_idx), None + ) + if title_tbl: + body_parts.append( + self._build_title_block_html(title_tbl) + ) + + # ── content_order 순회 ── + para_num = 0 # 묞닚 placeholder 번혞 + tbl_num = 0 # 데읎터 표 번혞 (1-based) + img_num = 0 # 읎믞지 placeholder 번혞 + in_section = False + section_num = 0 + + # 섹션 제목 팹턮 + sec_patterns = [ + re.compile(r'^\d+\.\s+\S'), + re.compile(r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S'), + re.compile(r'^제\s*\d+\s*[장절항]\s*\S'), + ] + + def _is_section_title(text: str) -> bool: + return any(p.match(text) for p in sec_patterns) + + for item in content_order: + itype = item['type'] + + # ── 빈 묞닚: 생략 ── + if itype == 'empty': + continue + + # ── 표: title_table은 읎믞 처늬, body_table만 ── + # ★ v5.4: 순찚 컀서 방식윌로 맀칭 (table_idx 의졎 제거) + if itype == 'table': + # body_visible_tables에서 닀음 표 가젞였Ʞ + if body_tbl_cursor >= len(body_visible_tables): + continue # 더 읎상 표가 없음 + + tbl_data = body_visible_tables[body_tbl_cursor] + body_tbl_cursor += 1 + + # body 데읎터 표가 아니멎 걎너뛰Ʞ + if tbl_data["index"] not in body_table_indices: + continue + + tbl_num += 1 + col_cnt = item.get('colCnt', '3') + try: + col_cnt = int(col_cnt) + except (ValueError, TypeError): + col_cnt = 3 + + # semantic_map에서 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(tbl_data["index"], + _roles.get(str(tbl_data["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + rows = tbl_data.get("rows", []) + header_row_data = rows[0] if rows else None + col_pcts = tbl_data.get("colWidths_pct", []) + + body_parts.append( + self._build_table_placeholder( + tbl_num, actual_col_cnt, col_pcts, + header_row=header_row_data + ) + ) + continue + + # ── 읎믞지 ── + if itype == 'image': + img_num += 1 + ppr = item.get('paraPrIDRef', '0') + caption = item.get('text', '') + ref = item.get('binaryItemIDRef', '') + + img_html = f'
                                        \n' + img_html += f' {{{{IMAGE_{img_num}}}}}\n' + if caption: + img_html += f'

                                        {{{{IMAGE_{img_num}_CAPTION}}}}

                                        \n' + img_html += '
                                        ' + body_parts.append(img_html) + continue + + # ── 묞닚 ── + if itype == 'paragraph': + text = item.get('text', '') + ppr = item.get('paraPrIDRef', '0') + cpr = item.get('charPrIDRef', '0') + + # 섹션 제목 감지 + if _is_section_title(text): + # 읎전 섹션 ë‹«êž° + if in_section: + body_parts.append('\n') + + section_num += 1 + in_section = True + body_parts.append( + f'
                                        \n' + f'

                                        ' + f'{{{{SECTION_{section_num}_TITLE}}}}

                                        ' + ) + continue + + # 음반 묞닚 + para_num += 1 + + # runs가 여러 개멎 닀쀑 span + runs = item.get('runs', []) + if len(runs) > 1: + spans = [] + for r_idx, run in enumerate(runs): + r_cpr = run.get('charPrIDRef', cpr) + spans.append( + f'' + f'{{{{PARA_{para_num}_RUN_{r_idx+1}}}}}' + ) + inner = ''.join(spans) + else: + inner = ( + f'' + f'{{{{PARA_{para_num}}}}}' + ) + + body_parts.append( + f'

                                        {inner}

                                        ' + ) + + # 마지막 섹션 ë‹«êž° + if in_section: + body_parts.append('
                                        \n') + + return "\n\n".join(body_parts) + + def _build_title_block_html(self, title_tbl: dict) -> str: + """제목표 → title-block HTML (Ʞ졎 로직 분늬)""" + rows = title_tbl.get("rows", []) + col_pcts = title_tbl.get("colWidths_pct", []) + + html = '
                                        \n\n' + + if col_pcts: + html += '\n' + for pct in col_pcts: + html += f' \n' + html += '\n' + + for r_idx, row in enumerate(rows): + html += '\n' + for c_idx, cell in enumerate(row): + attrs = "" + bf_ref = cell.get("borderFillIDRef") + if bf_ref: + attrs += f' class="bf-{bf_ref}"' + cs = cell.get("colSpan", 1) + if cs > 1: + attrs += f' colspan="{cs}"' + rs = cell.get("rowSpan", 1) + if rs > 1: + attrs += f' rowspan="{rs}"' + + cell_text = cell.get("text", "").strip() + if cell_text: + ph_name = f"TITLE_R{r_idx+1}_C{c_idx+1}" + html += f' {{{{{ph_name}}}}}\n' + else: + html += f' \n' + html += '\n' + + html += '
                                        \n
                                        \n' + return html + + # ── Ʞ졎 섹션+표 방식 (하위 혾환) ── + + def _build_body_legacy(self, template_info: dict, parsed: dict, + semantic_map: dict = None) -> str: + """content_order 없을 때 — Ʞ졎 v5.1 방식 유지""" + body_parts = [] + tables = template_info.get("tables", []) + + # ── semantic_map읎 있윌멎 활용 ── + if semantic_map: + body_table_indices = semantic_map.get("body_tables", []) + title_idx = semantic_map.get("title_table") + else: + # semantic_map 없윌멎 전첎 표 사용 (하위 혾환) + body_table_indices = [t["index"] for t in tables] + title_idx = None + + # ── 제목 랔록 ── + if title_idx is not None: + title_tbl = next((t for t in tables if t["index"] == title_idx), None) + if title_tbl: + body_parts.append(self._build_title_block_html(title_tbl)) + + # ── 볞묞 데읎터 표만 필터링 ── + body_tables = [t for t in tables if t["index"] in body_table_indices] + + # ── 섹션 감지 ── + section_titles = self._detect_section_titles(parsed) + + if not section_titles and not body_tables: + # 구조 정볎 부족 → Ʞ볞 1섹션 + body_parts.append( + '
                                        \n' + '
                                        {{SECTION_1_TITLE}}
                                        \n' + '
                                        {{SECTION_1_CONTENT}}
                                        \n' + '
                                        ' + ) + else: + sec_count = max(len(section_titles), 1) + tbl_idx = 0 + + for s in range(sec_count): + s_num = s + 1 + body_parts.append( + f'
                                        \n' + f'
                                        {{{{SECTION_{s_num}_TITLE}}}}
                                        \n' + f'
                                        {{{{SECTION_{s_num}_CONTENT}}}}
                                        \n' + ) + + # 읎 섹션에 표 배분 + if tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + + # semantic_map에서 싀제 col_headers 가젞였Ʞ + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + + # 헀더행 셀 데읎터 (bf_id 포핚) + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data # ★ 헀더행 전달 + ) + ) + tbl_idx += 1 + + body_parts.append('
                                        \n') + + # 낚은 표 + while tbl_idx < len(body_tables): + t = body_tables[tbl_idx] + col_cnt = t.get("colCnt", 3) + _roles = semantic_map.get("table_roles", {}) if semantic_map else {} + tbl_role = _roles.get(t["index"], _roles.get(str(t["index"]), {})) + col_headers = tbl_role.get("col_headers", []) + actual_col_cnt = len(col_headers) if col_headers else col_cnt + rows = t.get("rows", []) + header_row_data = rows[0] if rows else None + body_parts.append( + self._build_table_placeholder( + tbl_idx + 1, actual_col_cnt, + t.get("colWidths_pct", []), + header_row=header_row_data + ) + ) + tbl_idx += 1 + + return "\n".join(body_parts) + + def _build_table_placeholder(self, tbl_num: int, col_cnt: int, + col_pcts: list = None, + header_row: list = None) -> str: + """표 1개의 placeholder HTML 생성 + + Args: + tbl_num: 표 번혞 (1-based) + col_cnt: ì—Ž 수 + col_pcts: ì—Ž 너비 % 늬슀튞 + header_row: 헀더행 셀 늬슀튞 [{bf_id, colSpan, ...}, ...] + """ + # colgroup + colgroup = "" + num_cols = len(col_pcts) if col_pcts else col_cnt + if num_cols > 0: + colgroup = "
                                        {{{{TABLE_{tbl_num}_H_C{c+1}}}}}
                                        \n' + f'{colgroup}' + f'\n' + f' \n{header_row_html}\n \n' + f'\n' + f'\n' + f' {{{{TABLE_{tbl_num}_BODY}}}}\n' + f'\n' + f'
                                        ' + ) + + def _detect_section_titles(self, parsed: dict) -> list: + """parsed 텍슀튞에서 섹션 제목 팹턮 탐색""" + import re + titles = [] + + # parsed에서 텍슀튞 추출 + paragraphs = parsed.get("paragraphs", []) + if not paragraphs: + # raw_xml에서 태귞 텍슀튞 추출 시도 + section_xml = "" + raw_xml = parsed.get("raw_xml", {}) + for key, val in raw_xml.items(): + if "section" in key.lower(): + section_xml = val if isinstance(val, str) else "" + break + if not section_xml: + section_xml = parsed.get("section_xml", "") + + if section_xml: + t_matches = re.findall(r'([^<]+)', section_xml) + paragraphs = [t.strip() for t in t_matches if t.strip()] + + # 섹션 제목 팹턮 + patterns = [ + r'^(\d+)\.\s+\S', # "1. 제목" + r'^[ⅠⅡⅢⅣ⅀Ⅵ⅊Ⅷ⅚Ⅹ]\.\s*\S', # "Ⅰ. 제목" + r'^제\s*\d+\s*[장절항]\s*\S', # "제1장 제목" + ] + + for text in paragraphs: + if isinstance(text, dict): + text = text.get("text", "") + text = str(text).strip() + if not text: + continue + for pat in patterns: + if re.match(pat, text): + titles.append(text) + break + + return titles + + def _extract_colors(self, template_info: dict) -> dict: + """template_info에서 색상 정볎 추출""" + colors = {"background": [], "border": [], "text": []} + + bf = template_info.get("border_fills", {}) + for fill_id, fill_data in bf.items(): + # ★ background í‚€ 사용 (bg → background) + bg = fill_data.get("background", fill_data.get("bg", "")) + if bg and bg.lower() not in ("", "none", "transparent") \ + and bg not in colors["background"]: + colors["background"].append(bg) + + # ★ css dict에서 border 색상 추출 + css_dict = fill_data.get("css", {}) + for prop, val in css_dict.items(): + if "border" in prop and val and val != "none": + # "0.12mm solid #999999" → "#999999" + parts = val.split() + if len(parts) >= 3: + c = parts[-1] + if c.startswith("#") and c not in colors["border"]: + colors["border"].append(c) + + # fallback: 직접 side í‚€ (top/bottom/left/right) + for side_key in ("top", "bottom", "left", "right"): + side = fill_data.get(side_key, {}) + if isinstance(side, dict): + c = side.get("color", "") + if c and c not in colors["border"]: + colors["border"].append(c) + + return colors + + def _summarize_features(self, template_info: dict, + semantic_map: dict = None) -> list: + """template_info에서 특징 요앜""" + features = [] + + header = template_info.get("header", {}) + footer = template_info.get("footer", {}) + tables = template_info.get("tables", []) + + # 폰튾 (fonts 구조: {"HANGUL": [{"face": "맑은 고딕"}], ...}) + fonts = template_info.get("fonts", {}) + hangul = fonts.get("HANGUL", []) + if hangul and isinstance(hangul, list) and len(hangul) > 0: + features.append(f"폰튾: {hangul[0].get('face', '?')}") + + # 뚞늿말 (header.table.colCnt) + if header.get("exists"): + col_cnt = header.get("table", {}).get("colCnt", "?") + features.append(f"뚞늿말: {col_cnt}ì—Ž") + + # ꌬ늿말 (footer.table.colCnt) + if footer.get("exists"): + col_cnt = footer.get("table", {}).get("colCnt", "?") + features.append(f"ꌬ늿말: {col_cnt}ì—Ž") + + # 표 — semantic_map읎 있윌멎 데읎터 표만 + if semantic_map and semantic_map.get("body_tables"): + for idx in semantic_map["body_tables"]: + t = next((tb for tb in tables if tb["index"] == idx), None) + if t: + features.append( + f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}" + ) + elif tables: + t = tables[0] + features.append(f"표: {t.get('rowCnt', '?')}x{t.get('colCnt', '?')}") + + return features \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/__init__.py b/03. Code/geulbeot_9th/handlers/tools/__init__.py new file mode 100644 index 0000000..14b8b13 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +HWPX 템플늿 추출 도구 몚음 + +각 몚듈은 HWPX XML에서 특정 항목을 윔드 Ʞ반윌로 추출한닀. +- 추출 싀팚 시 None 반환 (디폎튞값 절대 생성 안 핹) +- 몚든 닚위 변환은 hwpx_utils 사용 +- hwpx_domain_guide.md Ʞ쀀 쀀수 + +몚듈 목록: + page_setup : §7 용지/여백 (pagePr + margin) + font : §3 Ꞁꌎ (fontface → font) + char_style : §4 Ꞁ자 몚양 (charPr) + para_style : §5 묞닚 몚양 (paraPr) + border_fill : §2 테두늬/배겜 (borderFill) + table : §6 표 (tbl, tc) + header_footer: §8 뚞늬말/ꌬ늬말 (headerFooter) + section : §9 구역 정의 (secPr) + style_def : 슀타음 정의 (styles) + numbering : 번혞맀ꞰꞰ/Ꞁ뚞늬표 + image : 읎믞지/귞늬Ʞ 객첎 + content_order: 볞묞 윘텐잠 순서 (section*.xml) +""" + +from . import page_setup +from . import font +from . import char_style +from . import para_style +from . import border_fill +from . import table +from . import header_footer +from . import section +from . import style_def +from . import numbering +from . import image +from . import content_order + +__all__ = [ + "page_setup", + "font", + "char_style", + "para_style", + "border_fill", + "table", + "header_footer", + "section", + "style_def", + "numbering", + "image", + "content_order" +] \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/border_fill.py b/03. Code/geulbeot_9th/handlers/tools/border_fill.py new file mode 100644 index 0000000..1f72936 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/border_fill.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +""" +§2 테두늬/배겜(BorderFill) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import BORDER_TYPE_TO_CSS, hwpx_border_to_css + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§2 borderFill 전첎 추출 → id별 dict. + + Returns: + { + 3: { + "id": 3, + "left": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "right": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "top": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "bottom": {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}, + "diagonal": {"type": "SOLID", "width": "0.1 mm", "color": "#000000"}, + "background": "#EDEDED", # fillBrush faceColor + "css": { # 펞의: 믞늬 변환된 CSS + "border-left": "0.12mm solid #000000", + ... + "background-color": "#EDEDED", + } + }, + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = {} + for attrs_str, inner in blocks: + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if not id_m: + continue + bf_id = int(id_m.group(1)) + + item = {"id": bf_id} + + # 4방향 + diagonal + for side, tag in [ + ("left", "leftBorder"), + ("right", "rightBorder"), + ("top", "topBorder"), + ("bottom", "bottomBorder"), + ("diagonal", "diagonal"), + ]: + # 태귞 전첎륌 뚌저 ì°Ÿê³ , 속성을 개별 추출 (순서 묎ꎀ) + tag_m = re.search(rf'', inner) + if tag_m: + tag_attrs = tag_m.group(1) + t = re.search(r'\btype="([^"]+)"', tag_attrs) + w = re.search(r'\bwidth="([^"]+)"', tag_attrs) + c = re.search(r'\bcolor="([^"]+)"', tag_attrs) + item[side] = { + "type": t.group(1) if t else "NONE", + "width": w.group(1).replace(" ", "") if w else "0.12mm", + "color": c.group(1) if c else "#000000", + } + + # 배겜 (fillBrush > winBrush faceColor) + bg_m = re.search( + r']*\bfaceColor="([^"]+)"', inner + ) + if bg_m: + face = bg_m.group(1) + if face and face.lower() != "none": + item["background"] = face + + # CSS 펞의 변환 + css = {} + for side in ["left", "right", "top", "bottom"]: + border_data = item.get(side) + if border_data: + css[f"border-{side}"] = hwpx_border_to_css(border_data) + else: + css[f"border-{side}"] = "none" + # border_data가 없윌멎 CSS에도 넣지 않음 + + if "background" in item: + css["background-color"] = item["background"] + + if css: + item["css"] = css + + result[bf_id] = item + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/char_style.py b/03. Code/geulbeot_9th/handlers/tools/char_style.py new file mode 100644 index 0000000..52b9c9f --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/char_style.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +§4 Ꞁ자 몚양(CharShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import charsize_to_pt + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§4 charPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "bold": False, + "italic": False, + "underline": "NONE", + "strikeout": "NONE", + "fontRef": {"hangul": 7, "latin": 6, ...}, + "ratio": {"hangul": 100, "latin": 100, ...}, + "spacing": {"hangul": 0, "latin": 0, ...}, + "borderFillIDRef": 2, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + # charPr 랔록 추출 (self-closing읎 아닌 랔록) + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # 속성 파싱 + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + height_m = re.search(r'\bheight="(\d+)"', attrs_str) + if height_m: + item["height_pt"] = charsize_to_pt(int(height_m.group(1))) + + color_m = re.search(r'\btextColor="([^"]+)"', attrs_str) + if color_m: + item["textColor"] = color_m.group(1) + + shade_m = re.search(r'\bshadeColor="([^"]+)"', attrs_str) + if shade_m and shade_m.group(1) != "none": + item["shadeColor"] = shade_m.group(1) + + bf_m = re.search(r'\bborderFillIDRef="(\d+)"', attrs_str) + if bf_m: + item["borderFillIDRef"] = int(bf_m.group(1)) + + # bold / italic (태귞 졎재 여부로 판당) + item["bold"] = bool(re.search(r'', inner)) + item["italic"] = bool(re.search(r'', inner)) + + # fontRef + fr = re.search(r'', inner) + if fr: + item["fontRef"] = _parse_lang_attrs(fr.group(1)) + + # ratio + ra = re.search(r'', inner) + if ra: + item["ratio"] = _parse_lang_attrs(ra.group(1)) + + # spacing + sp = re.search(r'', inner) + if sp: + item["spacing"] = _parse_lang_attrs(sp.group(1)) + + # underline + ul = re.search(r']*\btype="([^"]+)"', inner) + if ul: + item["underline"] = ul.group(1) + + # strikeout + so = re.search(r']*\bshape="([^"]+)"', inner) + if so: + item["strikeout"] = so.group(1) + + result.append(item) + + return result if result else None + + +def _parse_lang_attrs(attrs_str: str) -> dict: + """hangul="7" latin="6" ... → {"hangul": 7, "latin": 6, ...}""" + pairs = re.findall(r'(\w+)="(-?\d+)"', attrs_str) + return {k: int(v) for k, v in pairs} + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/content_order.py b/03. Code/geulbeot_9th/handlers/tools/content_order.py new file mode 100644 index 0000000..2252b02 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/content_order.py @@ -0,0 +1,550 @@ +# -*- coding: utf-8 -*- +""" +content_order.py — HWPX section*.xml 볞묞 윘텐잠 순서 추출 + +Ʞ졎 12개 tool읎 header.xml의 "정의(definition)"륌 추출하는 반멎, +읎 tool은 section0.xml의 "볞묞(content)" 순서륌 추출한닀. + +추출 결곌는 template_manager._build_body_html()읎 +원볞 순서 귞대로 HTML을 조늜하는 데 사용된닀. + +윘텐잠 유형: + - paragraph : 음반 텍슀튞 묞닚 + - table : 표 () + - image : 읎믞지 () + - empty : 빈 묞닚 (쀄바꿈 역할) + +ì°žì¡°: hwpx_domain_guide.md §6(표), §7(볞묞 구조) +""" + +import re +import logging + +logger = logging.getLogger(__name__) + +# ================================================================ +# 넀임슀페읎슀 +# ================================================================ +# HWPX는 여러 넀임슀페읎슀륌 사용한닀. +# section*.xml: hp: (볞묞), ha: (속성) +# header.xml: hh: (헀더 정의) +# 싀제 파음에서 넀임슀페읎슀 URI가 닀륌 수 있윌므로 로컬명 êž°ë°˜ 탐색도 병행한닀. + +DEFAULT_NS = { + 'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph', + 'ha': 'http://www.hancom.co.kr/hwpml/2011/attributes', + 'hh': 'http://www.hancom.co.kr/hwpml/2011/head', + 'hc': 'http://www.hancom.co.kr/hwpml/2011/core', +} + + +# ================================================================ +# 공개 API +# ================================================================ + +def extract(raw_xml, parsed, ns=None): + """section*.xml에서 볞묞 윘텐잠 순서륌 추출한닀. + + Args: + raw_xml (dict): 원볞 XML 묞자엎 딕셔너늬. + raw_xml.get("section0") 등윌로 section XML에 ì ‘ê·Œ. + parsed (dict): processor.py가 HWPX륌 파싱한 전첎 결곌 dict. + parsed.get("section_xml") 등윌로 parsed Element에 ì ‘ê·Œ. + ns (dict, optional): 넀임슀페읎슀 맀핑. None읎멎 자동 감지. + + Returns: + list[dict]: 윘텐잠 순서 늬슀튞. 각 항목은 닀음 킀륌 포핚: + - type: "paragraph" | "table" | "image" | "empty" + - index: 전첎 순서 낮 읞덱슀 (0부터) + - paraPrIDRef: 묞닚몚양 ì°žì¡° ID (str or None) + - styleIDRef: 슀타음 ì°žì¡° ID (str or None) + + type별 추가 í‚€ (아래 ì°žì¡°) + 추출 싀팚 시 None 반환 (analyzer가 결곌에서 제왞핚). + """ + # ── section XML ì°Ÿêž° ── + # raw_xml dict에서 section 원볞 묞자엎 추출 + section_raw = None + if isinstance(raw_xml, dict): + # í‚€ 읎늄은 프로젝튞마닀 닀륌 수 있음: section0, section_xml 등 + for key in ['section0', 'section_xml', 'section0.xml']: + if key in raw_xml: + section_raw = raw_xml[key] + break + # 못 찟윌멎 "section"윌로 시작하는 첫 번짞 í‚€ + if section_raw is None: + for key, val in raw_xml.items(): + if key.startswith('section') and isinstance(val, str): + section_raw = val + break + elif isinstance(raw_xml, str): + section_raw = raw_xml + + # parsed dict에서 section Element 또는 묞자엎 추출 + section_parsed = None + if isinstance(parsed, dict): + for key in ['section_xml', 'section0', 'section_parsed', 'section0_parsed']: + val = parsed.get(key) + if val is None: + continue + if isinstance(val, str): + # 묞자엎읎멎 section_raw로 활용 (table.py와 동음) + if section_raw is None: + section_raw = val + elif not isinstance(val, dict): + # Element 객첎로 추정 + section_parsed = val + break + # fallback: raw_xml 묞자엎을 직접 파싱 + if section_parsed is None and section_raw: + import xml.etree.ElementTree as ET + try: + section_parsed = ET.fromstring(section_raw) + except ET.ParseError: + logger.warning("section XML 파싱 싀팚") + return None + else: + # parsed 자첎가 Element음 수 있음 (직접 혞출 시) + section_parsed = parsed + + if section_parsed is None: + logger.warning("section XML을 찟을 수 없음 — content_order 추출 생략") + return None + + if ns is None: + ns = _detect_namespaces(section_raw or '', section_parsed) + + # 엘늬뚌튞 수집 — secPr 낎부는 제왞 + paragraphs = _collect_body_paragraphs(section_parsed, ns) + + content_order = [] + table_idx = 0 + image_idx = 0 + + for p_elem in paragraphs: + para_pr_id = _get_attr(p_elem, 'paraPrIDRef') + style_id = _get_attr(p_elem, 'styleIDRef') + + base = { + 'index': len(content_order), + 'paraPrIDRef': para_pr_id, + 'styleIDRef': style_id, + } + + # ── (1) 표 확읞 ── + tbl = _find_element(p_elem, 'tbl', ns) + if tbl is not None: + tbl_info = _extract_table_info(tbl, ns) + content_order.append({ + **base, + 'type': 'table', + 'table_idx': table_idx, + **tbl_info, + }) + table_idx += 1 + continue + + # ── (2) 읎믞지 확읞 ── + pic = _find_element(p_elem, 'pic', ns) + if pic is not None: + img_info = _extract_image_info(pic, p_elem, ns) + + # ★ 읞띌읞 아읎윘읎멎 텍슀튞와 합쳐서 paragraph로 처늬 + if img_info.get('is_inline_icon') and img_info.get('text'): + content_order.append({ + **base, + 'type': 'paragraph', + 'text': img_info['text'].strip(), + 'charPrIDRef': None, + 'has_icon': True, + }) + continue + + # 음반 읎믞지 + content_order.append({ + **base, + 'type': 'image', + 'image_idx': image_idx, + **img_info, + }) + image_idx += 1 + continue + + # ── (3) 텍슀튞 묞닚 / 빈 묞닚 ── + text = _collect_text(p_elem, ns) + runs_info = _extract_runs_info(p_elem, ns) + + if not text.strip(): + content_order.append({ + **base, + 'type': 'empty', + }) + else: + content_order.append({ + **base, + 'type': 'paragraph', + 'text': text, + 'charPrIDRef': runs_info.get('first_charPrIDRef'), + 'runs': runs_info.get('runs', []), + }) + + logger.info( + "content_order 추출 완료: %d items " + "(paragraphs=%d, tables=%d, images=%d, empty=%d)", + len(content_order), + sum(1 for c in content_order if c['type'] == 'paragraph'), + table_idx, + image_idx, + sum(1 for c in content_order if c['type'] == 'empty'), + ) + + return content_order + + +# ================================================================ +# 볞묞 수집 — secPr 낎부 제왞 +# ================================================================ + +def _collect_body_paragraphs(root, ns): + """ 직계 만 수집한닀. + + secPr, headerFooter 낎부의 는 볞묞읎 아니므로 제왞. + subList 낎부(셀 안 묞닚)도 제왞 — 표는 통짞로 하나의 항목. + """ + paragraphs = [] + + # 방법 1: sec 직계 자식 쀑 p 태귞만 + sec = _find_element(root, 'sec', ns) + if sec is None: + # 룚튞 자첎가 sec음 수 있음 + sec = root + + for child in sec: + tag = _local_tag(child) + if tag == 'p': + paragraphs.append(child) + + # 직계 자식에서 못 찟았윌멎 fallback: 전첎 탐색 (but secPr/subList 제왞) + if not paragraphs: + paragraphs = _collect_paragraphs_fallback(root, ns) + + return paragraphs + + +def _collect_paragraphs_fallback(root, ns): + """fallback: 전첎에서 륌 찟되, secPr/headerFooter/subList 낎부는 제왞""" + skip_tags = {'secPr', 'headerFooter', 'subList', 'tc'} + result = [] + + def _walk(elem, skip=False): + if skip: + return + tag = _local_tag(elem) + if tag in skip_tags: + return + if tag == 'p': + # 부몚가 sec읎거나 룚튞 직계읞 겜우만 + result.append(elem) + return # p 낎부의 하위 p는 수집하지 않음 + for child in elem: + _walk(child) + + _walk(root) + return result + + +# ================================================================ +# 표 정볎 추출 +# ================================================================ + +def _extract_table_info(tbl, ns): + """ 에서 Ʞ볞 메타 정볎 추출""" + info = { + 'rowCnt': _get_attr(tbl, 'rowCnt'), + 'colCnt': _get_attr(tbl, 'colCnt'), + 'borderFillIDRef': _get_attr(tbl, 'borderFillIDRef'), + } + + # ì—Ž 너비 + col_sz = _find_element(tbl, 'colSz', ns) + if col_sz is not None: + width_list_elem = _find_element(col_sz, 'widthList', ns) + if width_list_elem is not None and width_list_elem.text: + info['colWidths'] = width_list_elem.text.strip().split() + + return info + + +# ================================================================ +# 읎믞지 정볎 추출 +# ================================================================ + +def _extract_image_info(pic, p_elem, ns): + """ 에서 읎믞지 ì°žì¡° 정볎 추출""" + info = { + 'binaryItemIDRef': None, + 'text': '', + 'is_inline_icon': False, # ★ 추가 + } + + # img 태귞에서 binaryItemIDRef + img = _find_element(pic, 'img', ns) + if img is not None: + info['binaryItemIDRef'] = _get_attr(img, 'binaryItemIDRef') + + # ★ pos 태귞에서 treatAsChar 확읞 — 읞띌읞 아읎윘 판별 + pos = _find_element(pic, 'pos', ns) + if pos is not None: + treat_as_char = _get_attr(pos, 'treatAsChar') + if treat_as_char == '1': + info['is_inline_icon'] = True + + # imgRect에서 크Ʞ 정볎 + img_rect = _find_element(pic, 'imgRect', ns) + if img_rect is not None: + info['imgRect'] = { + 'x': _get_attr(img_rect, 'x'), + 'y': _get_attr(img_rect, 'y'), + 'w': _get_attr(img_rect, 'w'), + 'h': _get_attr(img_rect, 'h'), + } + + # 같은 묞닚 낮 텍슀튞 (pic 바깥의 runë“€) + info['text'] = _collect_text_outside(p_elem, pic, ns) + + return info + + +# ================================================================ +# 텍슀튞 수집 +# ================================================================ + +def _collect_text(p_elem, ns): + """ 낮 몚든 텍슀튞륌 순서대로 합칚 + + 죌의: t.tail은 XML 듀여쓰Ʞ 공백읎므로 수집하지 않는닀. + HWPX에서 싀제 텍슀튞는 항상 ... 안에 있닀. + """ + parts = [] + for t in _find_all_elements(p_elem, 't', ns): + if t.text: + parts.append(t.text) + return ''.join(parts) + + +def _collect_text_outside(p_elem, exclude_elem, ns): + """p_elem 낎에서 exclude_elem(예: pic) 바깥의 텍슀튞만 수집""" + parts = [] + + def _walk(elem): + if elem is exclude_elem: + return + tag = _local_tag(elem) + if tag == 't' and elem.text: + parts.append(elem.text) + for child in elem: + _walk(child) + + _walk(p_elem) + return ''.join(parts) + + +# ================================================================ +# Run 정볎 추출 +# ================================================================ + +def _extract_runs_info(p_elem, ns): + """ 낮 듀의 charPrIDRef와 텍슀튞 추출 + + Returns: + { + 'first_charPrIDRef': str or None, + 'runs': [ + {'charPrIDRef': '8', 'text': '1. SamanPro...'}, + {'charPrIDRef': '24', 'text': '포장섀계...'}, + ] + } + """ + runs = [] + first_char_pr = None + + for run_elem in _find_direct_runs(p_elem, ns): + char_pr = _get_attr(run_elem, 'charPrIDRef') + if first_char_pr is None and char_pr is not None: + first_char_pr = char_pr + + text_parts = [] + for t in _find_all_elements(run_elem, 't', ns): + if t.text: + text_parts.append(t.text) + + if text_parts: + runs.append({ + 'charPrIDRef': char_pr, + 'text': ''.join(text_parts), + }) + + return { + 'first_charPrIDRef': first_char_pr, + 'runs': runs, + } + + +def _find_direct_runs(p_elem, ns): + """ 직계 만 찟음 (subList 낎부 제왞)""" + results = [] + for child in p_elem: + tag = _local_tag(child) + if tag == 'run': + results.append(child) + return results + + +# ================================================================ +# 넀임슀페읎슀 감지 +# ================================================================ + +def _detect_namespaces(raw_xml, parsed): + """XML에서 싀제 사용된 넀임슀페읎슀 URI륌 감지한닀. + + HWPX 버전에 따띌 넀임슀페읎슀 URI가 닀륌 수 있닀: + - 2011 버전: http://www.hancom.co.kr/hwpml/2011/paragraph + - 2016 버전: http://www.hancom.co.kr/hwpml/2016/paragraph (음부) + """ + ns = dict(DEFAULT_NS) + + if raw_xml: + # xmlns:hp="..." 팚턎윌로 싀제 URI 추출 + for prefix in ['hp', 'ha', 'hh', 'hc']: + pattern = rf'xmlns:{prefix}="([^"]+)"' + match = re.search(pattern, raw_xml) + if match: + ns[prefix] = match.group(1) + + return ns + + +# ================================================================ +# XML 유틞늬티 — 넀임슀페읎슀 불가지론적 탐색 +# ================================================================ + +def _local_tag(elem): + """'{namespace}localname' → 'localname'""" + tag = elem.tag + if '}' in tag: + return tag.split('}', 1)[1] + return tag + + +def _get_attr(elem, attr_name): + """속성값 가젞였Ʞ. 넀임슀페읎슀 유묎 몚두 시도.""" + # 직접 속성명 + val = elem.get(attr_name) + if val is not None: + return val + + # 넀임슀페읎슀 접두사가 붙은 속성 시도 + for full_attr in elem.attrib: + if full_attr.endswith(attr_name): + return elem.attrib[full_attr] + + return None + + +def _find_element(parent, local_name, ns): + """자식 쀑 로컬명읎 음치하는 첫 번짞 엘늬뚌튞륌 찟는닀. + + 넀임슀페읎슀 prefix 시도 후, 싀팚하멎 로컬명 직접 비교. + """ + # 1ì°š: 넀임슀페읎슀 prefix로 탐색 + for prefix in ['hp', 'hh', 'hc', 'ha']: + uri = ns.get(prefix, '') + found = parent.find(f'{{{uri}}}{local_name}') + if found is not None: + return found + + # 2ì°š: 직계 자식 로컬명 비교 + for child in parent: + if _local_tag(child) == local_name: + return child + + # 3ì°š: 재귀 탐색 (1닚계만) + for child in parent: + for grandchild in child: + if _local_tag(grandchild) == local_name: + return grandchild + + return None + + +def _find_all_elements(parent, local_name, ns): + """하위 전첎에서 로컬명읎 음치하는 몚든 엘늬뚌튞륌 찟는닀.""" + results = [] + + def _walk(elem): + if _local_tag(elem) == local_name: + results.append(elem) + for child in elem: + _walk(child) + + _walk(parent) + return results + + +# ================================================================ +# 펞의 핚수 +# ================================================================ + +def summarize(content_order): + """content_order 늬슀튞륌 사람읎 읜Ʞ 쉬욎 요앜윌로 변환""" + lines = [] + for item in content_order: + idx = item['index'] + t = item['type'] + + if t == 'paragraph': + text_preview = item['text'][:50] + if len(item['text']) > 50: + text_preview += '...' + lines.append( + f"[{idx:3d}] P paraPr={item['paraPrIDRef']:<4s} " + f"charPr={item.get('charPrIDRef', '-'):<4s} " + f"\"{text_preview}\"" + ) + elif t == 'table': + lines.append( + f"[{idx:3d}] T table_idx={item['table_idx']} " + f"({item.get('rowCnt', '?')}×{item.get('colCnt', '?')})" + ) + elif t == 'image': + ref = item.get('binaryItemIDRef', '?') + caption = item.get('text', '')[:30] + lines.append( + f"[{idx:3d}] I image_idx={item['image_idx']} " + f"ref={ref} \"{caption}\"" + ) + elif t == 'empty': + lines.append(f"[{idx:3d}] _ (empty)") + + return '\n'.join(lines) + + +def get_stats(content_order): + """content_order 통계 반환""" + type_map = { + 'paragraph': 'paragraphs', + 'table': 'tables', + 'image': 'images', + 'empty': 'empty', + } + stats = { + 'total': len(content_order), + 'paragraphs': 0, + 'tables': 0, + 'images': 0, + 'empty': 0, + } + for item in content_order: + key = type_map.get(item['type']) + if key: + stats[key] += 1 + return stats \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/font.py b/03. Code/geulbeot_9th/handlers/tools/font.py new file mode 100644 index 0000000..a4ea867 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/font.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +§3 Ꞁꌎ(FaceName) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§3 fontface에서 얞얎별 Ꞁꌎ 정의 추출. + + Returns: + { + "HANGUL": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "LATIN": [{"id": 0, "face": "돋움", "type": "TTF"}, ...], + "HANJA": [...], + ... + } + 또는 추출 싀팚 시 None + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # fontface 랔록을 lang별로 추출 + fontface_blocks = re.findall( + r']*\blang="([^"]+)"[^>]*>(.*?)', + header_xml, re.DOTALL + ) + + if not fontface_blocks: + return None + + for lang, block_content in fontface_blocks: + fonts = [] + font_matches = re.finditer( + r']*' + r'\bid="(\d+)"[^>]*' + r'\bface="([^"]+)"[^>]*' + r'\btype="([^"]+)"', + block_content + ) + for fm in font_matches: + fonts.append({ + "id": int(fm.group(1)), + "face": fm.group(2), + "type": fm.group(3), + }) + + if fonts: + result[lang] = fonts + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """header.xml 묞자엎을 가젞옚닀.""" + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/header_footer.py b/03. Code/geulbeot_9th/handlers/tools/header_footer.py new file mode 100644 index 0000000..7dc9b30 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/header_footer.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +§8 뚞늬말/ꌬ늬말(HeaderFooter) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + 뚞늬말/ꌬ늬말 안에 표가 있는 겜우: + - 표의 셀에 닀쀑행 텍슀튞가 포핚될 수 있음 + - 각 셀의 colSpan, rowSpan, width, borderFillIDRef 등 추출 필요 + +secPr 낮 속성: + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract_header(raw_xml: dict, parsed: dict = None) -> dict | None: + """뚞늬말 구조 추출. + + Returns: + { + "exists": True, + "type": "table" | "text", + "hidden": False, + "table": { ... } | None, # 표가 있는 겜우 + "texts": ["부서명", ...], + } + """ + return _extract_hf(raw_xml, parsed, "header") + + +def extract_footer(raw_xml: dict, parsed: dict = None) -> dict | None: + """ꌬ늬말 구조 추출.""" + return _extract_hf(raw_xml, parsed, "footer") + + +def _extract_hf(raw_xml: dict, parsed: dict, hf_type: str) -> dict | None: + """header 또는 footer 추출 공통 로직""" + # 1) parsed에서 직접 제공된 header/footer XML + hf_xml = None + if parsed: + key = f"page_{hf_type}_xml" + hf_xml = parsed.get(key, "") + + # 2) section XML에서 headerFooter 랔록 탐색 + section_xml = _get_section_xml(raw_xml, parsed) + + if not hf_xml and section_xml: + # headerFooter 태귞에서 header/footer 구분 + hf_blocks = re.findall( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for attrs, inner in hf_blocks: + # type 속성윌로 구분 (HEADER / FOOTER) + type_m = re.search(r'\btype="([^"]+)"', attrs) + if type_m: + if type_m.group(1).upper() == hf_type.upper(): + hf_xml = inner + break + + if not hf_xml or not hf_xml.strip(): + return None # 핎당 뚞늬말/ꌬ늬말 없음 + + result = {"exists": True} + + # hidden 여부 + if section_xml: + hide_key = f"hideFirst{'Header' if hf_type == 'header' else 'Footer'}" + hide_m = re.search(rf'\b{hide_key}="(\d+)"', section_xml) + if hide_m: + result["hidden"] = bool(int(hide_m.group(1))) + + # 텍슀튞 추출 + texts = re.findall(r'([^<]*)', hf_xml) + clean_texts = [t.strip() for t in texts if t.strip()] + if clean_texts: + result["texts"] = clean_texts + + # 표 졎재 여부 + tbl_match = re.search( + r']*)>(.*?)', + hf_xml, re.DOTALL + ) + if tbl_match: + result["type"] = "table" + result["table"] = _parse_hf_table(tbl_match.group(1), tbl_match.group(2)) + else: + result["type"] = "text" + + return result + + +def _parse_hf_table(tbl_attrs: str, tbl_inner: str) -> dict: + """뚞늬말/ꌬ늬말 낮 표 파싱""" + table = {} + + # rowCnt, colCnt + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + table[attr] = int(m.group(1)) + + # ì—Ž 너비 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + widths = [int(w) for w in wl.group(1).strip().split()] + table["colWidths_hu"] = widths + total = sum(widths) or 1 + table["colWidths_pct"] = [round(w / total * 100) for w in widths] + except ValueError: + pass + + # 행/셀 + rows = [] + tr_blocks = re.findall(r']*>(.*?)', tbl_inner, re.DOTALL) + for tr in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr, re.DOTALL + ) + for tc in tc_blocks: + cell = _parse_hf_cell(tc.group(1), tc.group(2)) + cells.append(cell) + rows.append(cells) + + if rows: + table["rows"] = rows + + return table + + +def _parse_hf_cell(tc_attrs: str, tc_inner: str) -> dict: + """뚞늬말/ꌬ늬말 셀 파싱""" + cell = {} + + # borderFillIDRef + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + + # 셀 텍슀튞 (닀쀑행) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + + if lines: + cell["text"] = " ".join(lines) + cell["lines"] = lines + + return cell + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/image.py b/03. Code/geulbeot_9th/handlers/tools/image.py new file mode 100644 index 0000000..d989ccb --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/image.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +읎믞지/귞늬Ʞ 객첎(ShapeObject) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + + + + 또는 귞늬Ʞ 객첎: + + + ... + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """읎믞지/귞늬Ʞ 객첎 추출. + + Returns: + [ + { + "type": "image", + "binaryItemRef": "image1.JPG", + "width_hu": 28346, "height_hu": 14173, + "width_mm": 100.0, "height_mm": 50.0, + "offset": {"x": 0, "y": 0}, + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = [] + + # 랔록 + pic_blocks = re.finditer( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + for pm in pic_blocks: + pic_inner = pm.group(2) + item = {"type": "image"} + + # binaryItemRef + img = re.search(r']*\bbinaryItemIDRef="([^"]+)"', pic_inner) + if img: + item["binaryItemRef"] = img.group(1) + + # curSz (현재 크Ʞ) + csz = re.search( + r']*\bwidth="(\d+)"[^>]*\bheight="(\d+)"', + pic_inner + ) + if csz: + w, h = int(csz.group(1)), int(csz.group(2)) + item["width_hu"] = w + item["height_hu"] = h + item["width_mm"] = round(hwpunit_to_mm(w), 1) + item["height_mm"] = round(hwpunit_to_mm(h), 1) + + # offset + off = re.search( + r']*\bx="(-?\d+)"[^>]*\by="(-?\d+)"', + pic_inner + ) + if off: + item["offset"] = {"x": int(off.group(1)), "y": int(off.group(2))} + + result.append(item) + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/numbering.py b/03. Code/geulbeot_9th/handlers/tools/numbering.py new file mode 100644 index 0000000..b6e048d --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/numbering.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +번혞맀ꞰꞰ(Numbering) / Ꞁ뚞늬표(Bullet) 추출 + +HWPX 싀제 태귞 (header.xml): + + ^1. + ^2. + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """번혞맀ꞰꞰ + Ꞁ뚞늬표 정의 추출. + + Returns: + { + "numberings": [ + { + "id": 1, "start": 0, + "levels": [ + {"level": 1, "numFormat": "DIGIT", "pattern": "^1.", + "align": "LEFT"}, + {"level": 2, "numFormat": "HANGUL_SYLLABLE", "pattern": "^2."}, + ... + ] + } + ], + "bullets": [ + {"id": 1, "char": "-", "useImage": False} + ] + } + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + result = {} + + # ── 번혞맀ꞰꞰ ── + numbering_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if numbering_blocks: + nums = [] + for attrs, inner in numbering_blocks: + num = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + num["id"] = int(id_m.group(1)) + start_m = re.search(r'\bstart="(\d+)"', attrs) + if start_m: + num["start"] = int(start_m.group(1)) + + # paraHead 레벚듀 + levels = [] + heads = re.finditer( + r']*)>([^<]*)', + inner + ) + for h in heads: + h_attrs = h.group(1) + h_pattern = h.group(2).strip() + level = {} + + lv = re.search(r'\blevel="(\d+)"', h_attrs) + if lv: + level["level"] = int(lv.group(1)) + + fmt = re.search(r'\bnumFormat="([^"]+)"', h_attrs) + if fmt: + level["numFormat"] = fmt.group(1) + + al = re.search(r'\balign="([^"]+)"', h_attrs) + if al: + level["align"] = al.group(1) + + if h_pattern: + level["pattern"] = h_pattern + + if level: + levels.append(level) + + if levels: + num["levels"] = levels + nums.append(num) + + if nums: + result["numberings"] = nums + + # ── Ꞁ뚞늬표 ── + bullet_blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + if bullet_blocks: + bullets = [] + for attrs, inner in bullet_blocks: + bullet = {} + id_m = re.search(r'\bid="(\d+)"', attrs) + if id_m: + bullet["id"] = int(id_m.group(1)) + char_m = re.search(r'\bchar="([^"]*)"', attrs) + if char_m: + bullet["char"] = char_m.group(1) + img_m = re.search(r'\buseImage="(\d+)"', attrs) + if img_m: + bullet["useImage"] = bool(int(img_m.group(1))) + bullets.append(bullet) + + if bullets: + result["bullets"] = bullets + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/page_setup.py b/03. Code/geulbeot_9th/handlers/tools/page_setup.py new file mode 100644 index 0000000..b31994a --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/page_setup.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +""" +§7 용지 섀정 추출 (pagePr + margin) + +HWPX 싀제 태귞: + + + +디폎튞값 생성 안 핹. 추출 싀팚 시 None 반환. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm, mm_format, detect_paper_size + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§7 pagePr + margin에서 용지/여백 정볎 추출. + + Returns: + { + "paper": {"name": "A4", "width_mm": 210.0, "height_mm": 297.0, + "landscape": True/False}, + "margins": {"top": "10.0mm", "bottom": "10.0mm", + "left": "20.0mm", "right": "20.0mm", + "header": "15.0mm", "footer": "15.0mm", + "gutter": "0.0mm"} + } + 또는 추출 싀팚 시 None + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + result = {} + + # ── 용지 크Ʞ ───────────────────────────────── + page_match = re.search( + r']*' + r'\bwidth="(\d+)"[^>]*' + r'\bheight="(\d+)"', + section_xml + ) + if not page_match: + # 속성 순서가 닀륌 수 있음 + page_match = re.search( + r']*' + r'\bheight="(\d+)"[^>]*' + r'\bwidth="(\d+)"', + section_xml + ) + if page_match: + h_hu, w_hu = int(page_match.group(1)), int(page_match.group(2)) + else: + return None + else: + w_hu, h_hu = int(page_match.group(1)), int(page_match.group(2)) + + landscape_match = re.search( + r']*\blandscape="([^"]+)"', section_xml + ) + is_landscape = False + if landscape_match: + is_landscape = landscape_match.group(1) == "WIDELY" + + paper_name = detect_paper_size(w_hu, h_hu) + + result["paper"] = { + "name": paper_name, + "width_mm": round(hwpunit_to_mm(w_hu), 1), + "height_mm": round(hwpunit_to_mm(h_hu), 1), + "landscape": is_landscape, + } + + # ── 여백 ────────────────────────────────────── + margin_match = re.search(r'', section_xml) + if not margin_match: + return result # 용지 크Ʞ는 있윌나 여백은 없을 수 있음 + + attrs_str = margin_match.group(1) + margins = {} + for key in ["top", "bottom", "left", "right", "header", "footer", "gutter"]: + m = re.search(rf'\b{key}="(\d+)"', attrs_str) + if m: + margins[key] = mm_format(int(m.group(1))) + + if margins: + result["margins"] = margins + + return result + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + """section XML 묞자엎을 가젞옚닀.""" + # parsed에서 직접 제공 + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + + # raw_xml dict에서 section 파음 ì°Ÿêž° + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + + # raw_xml읎 묞자엎읎멎 귞대로 + if isinstance(raw_xml, str): + return raw_xml + + return None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/para_style.py b/03. Code/geulbeot_9th/handlers/tools/para_style.py new file mode 100644 index 0000000..2b6dd3a --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/para_style.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +""" +§5 묞닚 몚양(ParaShape) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + + + + + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§5 paraPr 전첎 목록 추출. + + Returns: + [ + { + "id": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": {"type": "NONE", "idRef": 0, "level": 0}, + "breakSetting": { + "widowOrphan": False, "keepWithNext": False, + "keepLines": False, "pageBreakBefore": False, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, "left_hu": 0, "right_hu": 0, + "before_hu": 0, "after_hu": 0, + }, + "lineSpacing": {"type": "PERCENT", "value": 130}, + "borderFillIDRef": 2, + "tabPrIDRef": 1, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + blocks = re.findall( + r']*)>(.*?)', + header_xml, re.DOTALL + ) + + if not blocks: + return None + + result = [] + for attrs_str, inner in blocks: + item = {} + + # id + id_m = re.search(r'\bid="(\d+)"', attrs_str) + if id_m: + item["id"] = int(id_m.group(1)) + + # tabPrIDRef + tab_m = re.search(r'\btabPrIDRef="(\d+)"', attrs_str) + if tab_m: + item["tabPrIDRef"] = int(tab_m.group(1)) + + # align + al = re.search(r']*\bhorizontal="([^"]+)"', inner) + if al: + item["align"] = al.group(1) + + val = re.search(r']*\bvertical="([^"]+)"', inner) + if val: + item["verticalAlign"] = val.group(1) + + # heading + hd = re.search( + r']*\btype="([^"]+)"[^>]*' + r'\bidRef="(\d+)"[^>]*\blevel="(\d+)"', inner + ) + if hd: + item["heading"] = { + "type": hd.group(1), + "idRef": int(hd.group(2)), + "level": int(hd.group(3)), + } + + # breakSetting + bs = re.search(r'', inner) + if bs: + bstr = bs.group(1) + item["breakSetting"] = { + "widowOrphan": _bool_attr(bstr, "widowOrphan"), + "keepWithNext": _bool_attr(bstr, "keepWithNext"), + "keepLines": _bool_attr(bstr, "keepLines"), + "pageBreakBefore": _bool_attr(bstr, "pageBreakBefore"), + "lineWrap": _str_attr(bstr, "lineWrap"), + "breakLatinWord": _str_attr(bstr, "breakLatinWord"), + "breakNonLatinWord": _str_attr(bstr, "breakNonLatinWord"), + } + + # margin (hp:case 랔록 낮 첫 번짞 사용 — HwpUnitChar case 우선) + case_block = re.search( + r']*required-namespace="[^"]*HwpUnitChar[^"]*"[^>]*>' + r'(.*?)', + inner, re.DOTALL + ) + margin_src = case_block.group(1) if case_block else inner + + margin = {} + for tag, key in [ + ("intent", "indent_hu"), + ("left", "left_hu"), + ("right", "right_hu"), + ("prev", "before_hu"), + ("next", "after_hu"), + ]: + m = re.search( + rf']*\bvalue="(-?\d+)"', margin_src + ) + if m: + margin[key] = int(m.group(1)) + + if margin: + item["margin"] = margin + + # lineSpacing + ls = re.search( + r']*\btype="([^"]+)"[^>]*\bvalue="(\d+)"', + margin_src + ) + if ls: + item["lineSpacing"] = { + "type": ls.group(1), + "value": int(ls.group(2)), + } + + # borderFillIDRef + bf = re.search(r']*\bborderFillIDRef="(\d+)"', inner) + if bf: + item["borderFillIDRef"] = int(bf.group(1)) + + result.append(item) + + return result if result else None + + +def _bool_attr(s: str, name: str) -> bool | None: + m = re.search(rf'\b{name}="(\d+)"', s) + return bool(int(m.group(1))) if m else None + + +def _str_attr(s: str, name: str) -> str | None: + m = re.search(rf'\b{name}="([^"]+)"', s) + return m.group(1) if m else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/section.py b/03. Code/geulbeot_9th/handlers/tools/section.py new file mode 100644 index 0000000..c93e2b0 --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/section.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +§9 구역 정의(Section) 추출 + +HWPX 싀제 태귞 (section0.xml): + + + + + + + + + +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> dict | None: + """§9 구역 속성 추출. + + Returns: + { + "textDirection": "HORIZONTAL", + "hideFirstHeader": False, + "hideFirstFooter": False, + "pageNum": {"pos": "BOTTOM_CENTER", "formatType": "DIGIT", + "sideChar": "-"}, + "startNum": {"page": 0}, + "colDef": None, + } + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + sec_match = re.search( + r']*)>(.*?)', + section_xml, re.DOTALL + ) + if not sec_match: + return None + + attrs_str = sec_match.group(1) + inner = sec_match.group(2) + + result = {} + + # textDirection + td = re.search(r'\btextDirection="([^"]+)"', attrs_str) + if td: + result["textDirection"] = td.group(1) + + # visibility + vis = re.search(r'', inner) + if vis: + v = vis.group(1) + for attr in ["hideFirstHeader", "hideFirstFooter", + "hideFirstMasterPage", "hideFirstPageNum", + "hideFirstEmptyLine"]: + m = re.search(rf'\b{attr}="(\d+)"', v) + if m: + result[attr] = bool(int(m.group(1))) + + # startNum + sn = re.search(r'', inner) + if sn: + sns = sn.group(1) + start = {} + pso = re.search(r'\bpageStartsOn="([^"]+)"', sns) + if pso: + start["pageStartsOn"] = pso.group(1) + pg = re.search(r'\bpage="(\d+)"', sns) + if pg: + start["page"] = int(pg.group(1)) + if start: + result["startNum"] = start + + # pageNum + pn = re.search(r'', inner) + if pn: + pns = pn.group(1) + pagenum = {} + for attr in ["pos", "formatType", "sideChar"]: + m = re.search(rf'\b{attr}="([^"]*)"', pns) + if m: + pagenum[attr] = m.group(1) + if pagenum: + result["pageNum"] = pagenum + + # colDef (당 섀정) + cd = re.search(r']*)>(.*?)', inner, re.DOTALL) + if cd: + cds = cd.group(1) + coldef = {} + cnt = re.search(r'\bcount="(\d+)"', cds) + if cnt: + coldef["count"] = int(cnt.group(1)) + layout = re.search(r'\blayout="([^"]+)"', cds) + if layout: + coldef["layout"] = layout.group(1) + if coldef: + result["colDef"] = coldef + + return result if result else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/style_def.py b/03. Code/geulbeot_9th/handlers/tools/style_def.py new file mode 100644 index 0000000..f055bdd --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/style_def.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +슀타음 정의(Style) 추출 + +HWPX 싀제 태귞 (header.xml): + + + + + +charPrIDRef → charPr(Ꞁ자몚양), paraPrIDRef → paraPr(묞닚몚양) 연결. +디폎튞값 생성 안 핹. +""" + +import re + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """슀타음 정의 추출. + + Returns: + [ + { + "id": 0, "type": "PARA", + "name": "바탕Ꞁ", "engName": "Normal", + "paraPrIDRef": 3, "charPrIDRef": 0, + "nextStyleIDRef": 0, + }, + ... + ] + """ + header_xml = _get_header_xml(raw_xml, parsed) + if not header_xml: + return None + + styles = re.findall(r'', header_xml) + if not styles: + return None + + result = [] + for s in styles: + item = {} + for attr in ["id", "paraPrIDRef", "charPrIDRef", "nextStyleIDRef"]: + m = re.search(rf'\b{attr}="(\d+)"', s) + if m: + item[attr] = int(m.group(1)) + + for attr in ["type", "name", "engName"]: + m = re.search(rf'\b{attr}="([^"]*)"', s) + if m: + item[attr] = m.group(1) + + result.append(item) + + return result if result else None + + +def _get_header_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("header_xml"): + return parsed["header_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "header" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/handlers/tools/table.py b/03. Code/geulbeot_9th/handlers/tools/table.py new file mode 100644 index 0000000..d1f160c --- /dev/null +++ b/03. Code/geulbeot_9th/handlers/tools/table.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +§6 표(Table) 구조 추출 + +HWPX 싀제 태귞 (section0.xml): + + 8504 8504 8504 + 또는 ì—Ž 수에 맞는 hp:colSz 형태 + + + + + + + + 셀 텍슀튞 + + + + + +디폎튞값 생성 안 핹. +""" + +import re + +from domain.hwpx.hwpx_utils import hwpunit_to_mm + + +def extract(raw_xml: dict, parsed: dict = None) -> list | None: + """§6 몚든 표 추출. + + Returns: + [ + { + "index": 0, + "rowCnt": 5, "colCnt": 3, + "repeatHeader": True, + "pageBreak": "CELL", + "colWidths_hu": [8504, 8504, 8504], + "colWidths_pct": [33, 34, 33], + "rows": [ + [ # row 0 + { + "colAddr": 0, "rowAddr": 0, + "colSpan": 2, "rowSpan": 1, + "width_hu": 17008, "height_hu": 2400, + "borderFillIDRef": 5, + "cellMargin": {"left": 510, "right": 510, + "top": 142, "bottom": 142}, + "text": "셀 텍슀튞", + "lines": ["셀 텍슀튞"], + }, + ... + ], + ... + ], + }, + ... + ] + """ + section_xml = _get_section_xml(raw_xml, parsed) + if not section_xml: + return None + + # tbl 랔록 전첎 추출 + tbl_blocks = _find_tbl_blocks(section_xml) + if not tbl_blocks: + return None + + result = [] + for idx, (tbl_attrs, tbl_inner) in enumerate(tbl_blocks): + tbl = {"index": idx} + + # 표 속성 + for attr in ["rowCnt", "colCnt"]: + m = re.search(rf'\b{attr}="(\d+)"', tbl_attrs) + if m: + tbl[attr] = int(m.group(1)) + + rh = re.search(r'\brepeatHeader="(\d+)"', tbl_attrs) + if rh: + tbl["repeatHeader"] = bool(int(rh.group(1))) + + pb = re.search(r'\bpageBreak="([^"]+)"', tbl_attrs) + if pb: + tbl["pageBreak"] = pb.group(1) + + # 행/셀 (ì—Ž 너비볎닀 뚌저 — 첫 행에서 ì—Ž 너비 추출 가능) + rows = _extract_rows(tbl_inner) + if rows: + tbl["rows"] = rows + + # ì—Ž 너비 + col_widths = _extract_col_widths(tbl_inner) + if not col_widths and rows: + # colSz 없윌멎 행 데읎터에서 추출 (colspan ê³ ë €) + col_cnt = tbl.get("colCnt", 0) + col_widths = _col_widths_from_rows(rows, col_cnt) + if not col_widths: + col_widths = _col_widths_from_first_row(rows[0]) + if col_widths: + tbl["colWidths_hu"] = col_widths + total = sum(col_widths) or 1 + tbl["colWidths_pct"] = [round(w / total * 100) for w in col_widths] + + result.append(tbl) + + return result if result else None + + +def _find_tbl_blocks(xml: str) -> list: + """쀑첩 표륌 고렀하여 최상위 tbl 랔록 추출""" + blocks = [] + start = 0 + while True: + # ]*)>', xml[start:]) + if not m: + break + + attrs = m.group(1) + tag_start = start + m.start() + content_start = start + m.end() + + # 쀑첩 칎욎튞로 닫는 태귞 ì°Ÿêž° + depth = 1 + pos = content_start + while depth > 0 and pos < len(xml): + open_m = re.search(r'', xml[pos:]) + + if close_m is None: + break + + if open_m and open_m.start() < close_m.start(): + depth += 1 + pos += open_m.end() + else: + depth -= 1 + if depth == 0: + inner = xml[content_start:pos + close_m.start()] + blocks.append((attrs, inner)) + pos += close_m.end() + + start = pos + + return blocks + + +def _extract_col_widths(tbl_inner: str) -> list | None: + """ì—Ž 너비 HWPUNIT 추출""" + # 팹턮 1: 8504 8504 8504 + wl = re.search(r'([^<]+)', tbl_inner) + if wl: + try: + return [int(w) for w in wl.group(1).strip().split()] + except ValueError: + pass + + # 팹턮 2: 개별 colSz 태귞 + cols = re.findall(r']*\bwidth="(\d+)"', tbl_inner) + if cols: + return [int(c) for c in cols] + + return None + + +def _extract_rows(tbl_inner: str) -> list: + """tr/tc 파싱하여 2D 셀 ë°°ì—Ž 반환""" + rows = [] + + tr_blocks = re.findall( + r']*>(.*?)', tbl_inner, re.DOTALL + ) + + for tr_inner in tr_blocks: + cells = [] + tc_blocks = re.finditer( + r']*)>(.*?)', tr_inner, re.DOTALL + ) + + for tc_match in tc_blocks: + tc_attrs = tc_match.group(1) + tc_inner = tc_match.group(2) + cell = _parse_cell(tc_attrs, tc_inner) + cells.append(cell) + + rows.append(cells) + + return rows + + +def _parse_cell(tc_attrs: str, tc_inner: str) -> dict: + """개별 셀 파싱""" + cell = {} + + # borderFillIDRef on tc tag + bf = re.search(r'\bborderFillIDRef="(\d+)"', tc_attrs) + if bf: + cell["borderFillIDRef"] = int(bf.group(1)) + + # header flag + hd = re.search(r'\bheader="(\d+)"', tc_attrs) + if hd: + cell["isHeader"] = bool(int(hd.group(1))) + + # cellAddr + addr = re.search( + r']*\bcolAddr="(\d+)"[^>]*\browAddr="(\d+)"', + tc_inner + ) + if addr: + cell["colAddr"] = int(addr.group(1)) + cell["rowAddr"] = int(addr.group(2)) + + # cellSpan + span = re.search(r'', tc_inner) + if span: + cs = re.search(r'\bcolSpan="(\d+)"', span.group(1)) + rs = re.search(r'\browSpan="(\d+)"', span.group(1)) + if cs: + cell["colSpan"] = int(cs.group(1)) + if rs: + cell["rowSpan"] = int(rs.group(1)) + + # cellSz + sz = re.search(r'', tc_inner) + if sz: + w = re.search(r'\bwidth="(\d+)"', sz.group(1)) + h = re.search(r'\bheight="(\d+)"', sz.group(1)) + if w: + cell["width_hu"] = int(w.group(1)) + if h: + cell["height_hu"] = int(h.group(1)) + + # cellMargin + cm = re.search(r'', tc_inner) + if cm: + margin = {} + for side in ["left", "right", "top", "bottom"]: + m = re.search(rf'\b{side}="(\d+)"', cm.group(1)) + if m: + margin[side] = int(m.group(1)) + if margin: + cell["cellMargin"] = margin + + # 셀 텍슀튞 + texts = re.findall(r'([^<]*)', tc_inner) + all_text = " ".join(t.strip() for t in texts if t.strip()) + if all_text: + cell["text"] = all_text + + # ★ v2: 셀 낮 run의 charPrIDRef 추출 (슀타음 연결용) + run_cprs = re.findall(r']*\bcharPrIDRef="(\d+)"', tc_inner) + if run_cprs: + cell["charPrIDRefs"] = [int(c) for c in run_cprs] + cell["primaryCharPrIDRef"] = int(run_cprs[0]) + + # ★ v2: 셀 낮 p의 paraPrIDRef, styleIDRef 추출 + para_pprs = re.findall(r']*\bparaPrIDRef="(\d+)"', tc_inner) + if para_pprs: + cell["paraPrIDRefs"] = [int(p) for p in para_pprs] + cell["primaryParaPrIDRef"] = int(para_pprs[0]) + + para_stys = re.findall(r']*\bstyleIDRef="(\d+)"', tc_inner) + if para_stys: + cell["styleIDRefs"] = [int(s) for s in para_stys] + + # 닀쀑행 (p 태귞 Ʞ쀀) + paras = re.findall(r']*>(.*?)', tc_inner, re.DOTALL) + lines = [] + for p in paras: + p_texts = re.findall(r'([^<]*)', p) + line = " ".join(t.strip() for t in p_texts if t.strip()) + if line: + lines.append(line) + if lines: + cell["lines"] = lines + + return cell + + +def _col_widths_from_first_row(first_row: list) -> list | None: + """첫 행 셀의 width_hu에서 ì—Ž 너비 추출 (colSz 없을 때 대첎)""" + widths = [] + for cell in first_row: + w = cell.get("width_hu") + if w: + widths.append(w) + return widths if widths else None + + +def _col_widths_from_rows(rows: list, col_cnt: int) -> list | None: + """★ v2: 몚든 행을 순회하여 colspan=1읞 행에서 정확한 ì—Ž 너비 추출. + + 첫 행에 colspan읎 있윌멎 ì—Ž 너비가 부정확하므로, + 몚든 엎읎 colspan=1읞 행을 ì°Ÿì•„ 사용. + """ + if not rows or not col_cnt: + return None + + # colspan=1읞 셀만 있는 행 ì°Ÿêž° (몚든 ì—Ž 졎재) + for row in rows: + # 읎 행의 몚든 셀읎 colspan=1읎고, 셀 수 == col_cnt읞지 + all_single = all(cell.get("colSpan", 1) == 1 for cell in row) + if all_single and len(row) == col_cnt: + widths = [] + for cell in sorted(row, key=lambda c: c.get("colAddr", 0)): + w = cell.get("width_hu") + if w: + widths.append(w) + if len(widths) == col_cnt: + return widths + + # 못 찟윌멎 첫 행 폎백 + return _col_widths_from_first_row(rows[0]) if rows else None + + +def _get_section_xml(raw_xml: dict, parsed: dict = None) -> str | None: + if parsed and parsed.get("section_xml"): + return parsed["section_xml"] + if isinstance(raw_xml, dict): + for name, content in raw_xml.items(): + if "section" in name.lower() and isinstance(content, str): + return content + return raw_xml if isinstance(raw_xml, str) else None \ No newline at end of file diff --git a/03. Code/geulbeot_9th/output/assets/1_1_1_img01.png b/03. Code/geulbeot_9th/output/assets/1_1_1_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_1_img01.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_1_img02.png b/03. Code/geulbeot_9th/output/assets/1_1_1_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_1_img02.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_1_img03.png b/03. Code/geulbeot_9th/output/assets/1_1_1_img03.png new file mode 100644 index 0000000..4b2f849 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_1_img03.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_2_img01.png b/03. Code/geulbeot_9th/output/assets/1_1_2_img01.png new file mode 100644 index 0000000..d04d8a1 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_2_img01.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_2_img02.png b/03. Code/geulbeot_9th/output/assets/1_1_2_img02.png new file mode 100644 index 0000000..6533ac1 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_2_img02.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_2_img03.png b/03. Code/geulbeot_9th/output/assets/1_1_2_img03.png new file mode 100644 index 0000000..347f9c7 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_2_img03.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_3_img01.png b/03. Code/geulbeot_9th/output/assets/1_1_3_img01.png new file mode 100644 index 0000000..f5a7ace Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_3_img01.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_1_3_img02.png b/03. Code/geulbeot_9th/output/assets/1_1_3_img02.png new file mode 100644 index 0000000..eb39b34 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_1_3_img02.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_2_1_img03.png b/03. Code/geulbeot_9th/output/assets/1_2_1_img03.png new file mode 100644 index 0000000..566898d Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_2_1_img03.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_2_2_img01.png b/03. Code/geulbeot_9th/output/assets/1_2_2_img01.png new file mode 100644 index 0000000..67f3c1f Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_2_2_img01.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_2_2_img02.png b/03. Code/geulbeot_9th/output/assets/1_2_2_img02.png new file mode 100644 index 0000000..a1caf43 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_2_2_img02.png differ diff --git a/03. Code/geulbeot_9th/output/assets/1_2_2_img03.png b/03. Code/geulbeot_9th/output/assets/1_2_2_img03.png new file mode 100644 index 0000000..031ea68 Binary files /dev/null and b/03. Code/geulbeot_9th/output/assets/1_2_2_img03.png differ diff --git a/03. Code/geulbeot_9th/prompts/step1_5_plan.txt b/03. Code/geulbeot_9th/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/03. Code/geulbeot_9th/prompts/step1_5_plan.txt @@ -0,0 +1,104 @@ +당신은 임원볎고용 묞서 구성 전묞가입니닀. +step1에서 추출된 JSON 구조륌 분석하여, 각 요소의 역할을 분류하고 페읎지 배치 계획을 수늜합니닀. + +## 입력 +- step1에서 추출된 JSON 구조 데읎터 + +## 출력 +- 페읎지별 배치 계획 JSON (섀명 없읎 JSON만 출력) + +--- + +## 배치 원칙 + +### 1페읎지 (볞묞) - "왜? 묎엇읎 묞제?" +- **lead-box**: 묞서 전첎의 핵심 명제/죌제 묞장 선정 +- **볞묞 섹션**: 녌늬, 귌거, 늬슀크, 죌의사항 쀑심 +- **bottom-box**: 묞서 전첎륌 ꎀ통하는 핵심 ê²°ë¡  (1~2묞장) + +### 2페읎지~ (첚부) - "얎떻게? 상섞 Ʞ쀀" +- **첚부 제목**: 핎당 페읎지 낎용을 대표하는 제목 +- **볞묞 섹션**: 프로섞슀, 절찚, 표, 첎크늬슀튞, 상섞 가읎드 +- **bottom-box**: 핎당 페읎지 낎용 요앜 + +--- + +## 요소 역할 분류 Ʞ쀀 + +| 역할 | 섀명 | 배치 | +|------|------|------| +| 핵심명제 | 묞서 전첎 죌제륌 한 묞장윌로 | 1p lead-box | +| 녌늬/귌거 | 왜 귞런가? 정당성, 법적 귌거 | 1p 볞묞 | +| 늬슀크 | 죌의핎알 할 섞묎/법적 위험 | 1p 볞묞 | +| 죌의사항 | 싀묎상 유의점, 제얞 | 1p 볞묞 | +| 핵심결론 | 묞서 요앜 한 묞장 | 1p bottom-box | +| 프로섞슀 | 닚계별 절찚, Step | 첚부 | +| Ʞ쀀표 | 할읞윚, 판정 Ʞ쀀 등 표 | 첚부 | +| 첎크늬슀튞 | 항목별 점검사항 | 첚부 | +| 상섞가읎드 | 섞부 섀명, 예시 | 첚부 | +| 싀묎멘튞 | 대응 슀크늜튞, ë°©ì–Ž 녌늬 | 첚부 bottom-box | + +--- + +## 출력 JSON 슀킀마 +```json +{ + "page_plan": { + "page_1": { + "type": "볞묞", + "lead": { + "source_section": "원볞 섹션명 또는 null", + "text": "lead-box에 듀얎갈 핵심 명제 묞장" + }, + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "녌늬/귌거 | 늬슀크 | 죌의사항", + "new_title": "변환 후 섹션 제목 (필요시 수정)" + } + ], + "bottom": { + "label": "핵심 ê²°ë¡ ", + "source": "원볞에서 가젞올 묞장 또는 조합할 킀워드", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + }, + "page_2": { + "type": "첚부", + "title": "[첚부] 페읎지 제목", + "sections": [ + { + "source": "원볞 섹션 제목", + "role": "프로섞슀 | Ʞ쀀표 | 첎크늬슀튞 | 상섞가읎드", + "new_title": "변환 후 섹션 제목" + } + ], + "bottom": { + "label": "띌벚 (예: 싀묎 핵심, 첎크포읞튞 등)", + "source": "원볞에서 가젞올 묞장", + "text": "bottom-box에 듀얎갈 최종 묞장" + } + } + }, + "page_count": 2 +} +``` + +--- + +## 판당 규칙 + +1. **프로섞슀/Step 있윌멎** → 묎조걎 첚부로 +2. **표(table) 있윌멎** → 가능하멎 첚부로 (당, 핵심 늬슀크 표는 1p 가능) +3. **"~입니닀", "~합니닀" 종결묞** → 개조식윌로 변환 표시 +4. **핵심 ê²°ë¡  선정**: "귞래서 뭐?" 에 대한 답읎 되는 묞장 +5. **첚부 bottom-box**: 핎당 페읎지 싀묎 적용 시 핵심 포읞튞 + +--- + +## 죌의사항 + +1. 원볞에 없는 낎용 추가/추론 ꞈ지 +2. 원볞 묞장을 선별/조합만 허용 +3. 개조식 변환 필요한 묞장 표시 (is_formal: true) +4. JSON만 출력 (섀명 없읎) \ No newline at end of file diff --git a/03. Code/geulbeot_9th/prompts/step1_extract.txt b/03. Code/geulbeot_9th/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/03. Code/geulbeot_9th/prompts/step1_extract.txt @@ -0,0 +1,122 @@ +당신은 HTML 묞서 구조 분석 전묞가입니닀. +사용자가 제공하는 HTML 묞서륌 분석하여 **구조화된 JSON**윌로 추출합니닀. + +## 규칙 + +1. 원볞 텍슀튞륌 **귞대로** 볎졎 (요앜/수정 ꞈ지) +2. 묞서의 녌늬적 구조륌 정확히 파악 +3. 반드시 유횚한 JSON만 출력 (마크닀욎 윔드랔록 없읎) + +## 출력 JSON 슀킀마 + +```json +{ + "title": "묞서 제목 (원묞 귞대로)", + "title_en": "영묞 제목 (원얎믌 수쀀 비슈니슀 영얎로 번역)", + "department": "부서명 (있윌멎 추출, 없윌멎 '쎝ꎄꞰ획싀')", + "lead": { + "text": "핵심 요앜/êž°ì¡° 텍슀튞 (원묞 귞대로)", + "highlight_keywords": ["강조할 킀워드1", "킀워드2"] + }, + "sections": [ + { + "number": 1, + "title": "섹션 제목 (원묞 귞대로)", + "type": "list | table | grid | process | qa | text", + "content": { + // type에 따띌 닀늄 (아래 ì°žì¡°) + } + } + ], + "conclusion": { + "label": "띌벚 (예: 핵심 ê²°ë¡ , 요앜 등)", + "text": "ê²°ë¡  텍슀튞 (원묞 귞대로, 한 묞장)" + } +} +``` + +## 섹션 type별 content 구조 + +### type: "list" +```json +{ + "items": [ + {"keyword": "킀워드", "text": "섀명 텍슀튞", "highlight": ["강조할 부분"]}, + {"keyword": null, "text": "킀워드 없는 항목", "highlight": []} + ] +} +``` + +### type: "table" +```json +{ + "columns": ["컬럌1", "컬럌2", "컬럌3"], + "rows": [ + { + "cells": [ + {"text": "셀낎용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null}, + {"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null}, + {"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"} + ] + } + ], + "footnote": "표 하당 죌석 (있윌멎)" +} +``` +- badge 값: "safe" | "caution" | "risk" | null +- highlight: true멎 빚간색 강조 + +### type: "grid" +```json +{ + "columns": 2, + "items": [ + {"title": "① 항목 제목", "text": "섀명", "highlight": ["강조 부분"]}, + {"title": "② 항목 제목", "text": "섀명", "highlight": []} + ] +} +``` + +### type: "two-column" +```json +{ + "items": [ + {"title": "① 제목", "text": "낎용", "highlight": ["강조"]}, + {"title": "② 제목", "text": "낎용", "highlight": []} + ] +} +``` + +### type: "process" +```json +{ + "steps": [ + {"number": 1, "title": "닚계명", "text": "섀명"}, + {"number": 2, "title": "닚계명", "text": "섀명"} + ] +} +``` + +### type: "qa" +```json +{ + "items": [ + {"question": "질묞?", "answer": "답변"}, + {"question": "질묞?", "answer": "답변"} + ] +} +``` + +### type: "text" +```json +{ + "paragraphs": ["묞닚1 텍슀튞", "묞닚2 텍슀튞"] +} +``` + +## 쀑요 + +1. **원볞 텍슀튞 100% 볎졎** - 요앜하거나 바꟞지 말 것 +2. **구조 정확히 파악** - 테읎랔 ì—Ž 수, rowspan/colspan 정확히 +3. **JSON만 출력** - 섀명 없읎 순수 JSON만 +4. **badge 판당** - "안전", "위험", "죌의" 등의 표현 볎고 적절히 맀핑 diff --git a/03. Code/geulbeot_9th/prompts/step2_generate.txt b/03. Code/geulbeot_9th/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/03. Code/geulbeot_9th/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 볎고서 생성 전묞가입니닀. +사용자가 제공하는 **JSON 구조 데읎터**륌 받아서 **각읞된 양식의 HTML 볎고서**륌 생성합니닀. + +## 출력 규칙 + +1. 완전한 HTML 묞서 출력 ( ~ ) +2. 윔드 랔록(```) 없읎 **순수 HTML만** 출력 +3. JSON의 텍슀튞륌 **귞대로** 사용 (수정 ꞈ지) +4. 아래 CSS륌 **정확히** 사용 + +## 페읎지 옵션 + +- **1페읎지**: 몚든 낎용을 1페읎지에 (텍슀튞/쀄간 조정) +- **2페읎지**: 1페읎지 볞묞 + 2페읎지 [첚부] +- **N페읎지**: 1페읎지 볞묞 + 나뚞지 [첚부 1], [첚부 2]... + +## HTML 템플늿 구조 + +```html + + + + + {{title}} + + + +
                                        + +
                                        +

                                        {{title}}

                                        +
                                        +
                                        +
                                        +
                                        +
                                        {{lead.text}} - 킀워드 강조
                                        +
                                        + +
                                        +
                                        {{conclusion.label}}
                                        +
                                        {{conclusion.text}}
                                        +
                                        +
                                        +
                                        - 1 -
                                        +
                                        + + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
                                        +
                                        {{section.title}}
                                        +
                                          +
                                        • {{item.keyword}}: {{item.text}} {{highlight}}
                                        • +
                                        +
                                        +``` + +### table → data-table +```html +
                                        +
                                        {{section.title}}
                                        + + + + + + + + + + + + + +
                                        {{col1}}{{col2}}
                                        {{text}}{{text}}
                                        +
                                        +``` +- badge가 있윌멎: `{{text}}` +- highlight가 true멎: `class="highlight-red"` + +### grid → strategy-grid +```html +
                                        +
                                        {{section.title}}
                                        +
                                        +
                                        +
                                        {{item.title}}
                                        +

                                        {{item.text}} {{highlight}}

                                        +
                                        +
                                        +
                                        +``` + +### two-column → two-col +```html +
                                        +
                                        {{section.title}}
                                        +
                                        +
                                        +
                                        {{item.title}}
                                        +

                                        {{item.text}} {{highlight}}

                                        +
                                        +
                                        +
                                        +``` + +### process → process-container +```html +
                                        +
                                        {{section.title}}
                                        +
                                        +
                                        +
                                        {{step.number}}
                                        +
                                        {{step.title}}: {{step.text}}
                                        +
                                        +
                                        ▌
                                        + +
                                        +
                                        +``` + +### qa → qa-grid +```html +
                                        +
                                        {{section.title}}
                                        +
                                        +
                                        + Q. {{question}}
                                        + A. {{answer}} +
                                        +
                                        +
                                        +``` + +## 완전한 CSS (반드시 읎대로 사용) + +```css +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap'); + +:root { + --primary-navy: #1a365d; + --secondary-navy: #2c5282; + --accent-navy: #3182ce; + --dark-gray: #2d3748; + --medium-gray: #4a5568; + --light-gray: #e2e8f0; + --bg-light: #f7fafc; + --text-black: #1a202c; + --border-color: #cbd5e0; +} + +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; } + +body { + font-family: 'Noto Sans KR', sans-serif; + background-color: #f0f0f0; + color: var(--text-black); + line-height: 1.55; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + gap: 20px; + word-break: keep-all; +} + +.sheet { + background-color: white; + width: 210mm; + height: 297mm; + padding: 20mm; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media print { + body { background: none; padding: 0; gap: 0; } + .sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; } + .sheet:last-child { page-break-after: auto; } +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + font-size: 9pt; + color: var(--medium-gray); +} + +.header-title { + font-size: 23pt; + font-weight: 900; + margin-bottom: 8px; + letter-spacing: -1px; + color: var(--primary-navy); + line-height: 1.25; + text-align: center; +} + +.title-divider { + height: 3px; + background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%); + width: 100%; + margin-bottom: 20px; +} + +.lead-box { + background-color: var(--bg-light); + border-left: 4px solid var(--primary-navy); + padding: 14px 16px; + margin-bottom: 18px; +} + +.lead-box div { + font-size: 11.5pt; + font-weight: 500; + color: var(--dark-gray); + line-height: 1.6; +} + +.lead-box b { color: var(--primary-navy); font-weight: 700; } + +.body-content { flex: 1; display: flex; flex-direction: column; } + +.section { margin-bottom: 16px; } + +.section-title { + font-size: 12pt; + font-weight: 700; + display: flex; + align-items: center; + margin-bottom: 10px; + color: var(--primary-navy); +} + +.section-title::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + background-color: var(--secondary-navy); + margin-right: 10px; +} + +.attachment-title { + font-size: 19pt; + font-weight: 700; + text-align: left; + color: var(--primary-navy); + margin-bottom: 8px; +} + +ul { list-style: none; padding-left: 10px; } + +li { + font-size: 10.5pt; + position: relative; + margin-bottom: 6px; + padding-left: 14px; + color: var(--dark-gray); + line-height: 1.55; +} + +li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--secondary-navy); + font-size: 10pt; +} + +.bottom-box { + border: 1.5px solid var(--border-color); + display: flex; + margin-top: auto; + min-height: 50px; + margin-bottom: 10px; +} + +.bottom-left { + width: 18%; + background-color: var(--primary-navy); + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-weight: 700; + font-size: 10.5pt; + color: #fff; + line-height: 1.4; +} + +.bottom-right { + width: 82%; + background-color: var(--bg-light); + padding: 12px 18px; + font-size: 10.5pt; + line-height: 1.6; + color: var(--dark-gray); +} + +.bottom-right b { display: inline; } + +.page-footer { + position: absolute; + bottom: 10mm; + left: 20mm; + right: 20mm; + padding-top: 8px; + text-align: center; + font-size: 8.5pt; + color: var(--medium-gray); + border-top: 1px solid var(--light-gray); +} + +b { font-weight: 700; color: var(--primary-navy); display: inline; } +.keyword { font-weight: 600; color: var(--text-black); } + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 9.5pt; + border-top: 2px solid var(--primary-navy); + border-bottom: 1px solid var(--border-color); + margin-top: 6px; +} + +.data-table th { + background-color: var(--primary-navy); + color: #fff; + font-weight: 600; + padding: 8px 6px; + border: 1px solid var(--secondary-navy); + text-align: center; + font-size: 9pt; +} + +.data-table td { + border: 1px solid var(--border-color); + padding: 7px 10px; + vertical-align: middle; + color: var(--dark-gray); + line-height: 1.45; + text-align: left; +} + +.data-table td:first-child { + background-color: var(--bg-light); + font-weight: 600; + text-align: center; +} + +.highlight-red { color: #c53030; font-weight: 600; } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 8.5pt; +} + +.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; } +.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; } +.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; } + +.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; } +.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; } + +.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; } +.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; } +.qa-item strong { color: var(--primary-navy); } + +.two-col { display: flex; gap: 12px; margin-top: 6px; } +.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; } +.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; } +.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; } + +.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; } +.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; } +.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; } +.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); } +.step-content strong { color: var(--primary-navy); font-weight: 600; } +.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; } +``` + +## 1페읎지 볞묞 구성 녌늬 + +1. **lead-box**: 원볞에서 전첎 죌제/핵심 명제륌 대표하는 묞장을 ì°Ÿì•„ 배치 +2. **볞묞 섹션**: 원볞의 녌늬 흐늄에 따띌 재구성 (귌거, 방안, 전략 등) +3. **bottom-box**: 핎당 페읎지 볞묞 낎용을 대표하는 묞장 선별 또는 핵심 킀워드 조합 + +## 첚부 페읎지 구성 + +1. **제목**: `

                                        [첚부] 핎당 낎용에 맞는 제목

                                        ` +2. **볞묞**: 1페읎지륌 뒷받칚하는 상섞 자료 (표, 프로섞슀, 첎크늬슀튞 등) +3. **bottom-box**: 핎당 첚부 페읎지 낎용의 핵심 요앜 + +## 쀑요 규칙 + +1. **원묞 êž°ë°˜ 재구성** - 추가/추론 ꞈ지, 당 아래는 허용: + - 위치 재펞성, 통합/분할 + - 표 ↔ 볞묞 ↔ 늬슀튞 형식 변환 + +2. **개조식 필수 (전첎 적용)** - 몚든 텍슀튞는 명사형/첎얞 종결: + - lead-box, bottom-box, 표 낎부, 늬슀튞, 몚든 묞장 + - ❌ "~입니닀", "~합니닀", "~됩니닀" + - ✅ "~임", "~핹", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부읞 및 슝여섞 부곌 대상읎 됩니닀" + - ✅ "부당행위계산 부읞 및 슝여섞 부곌 대상" + +3. **페읎지 겜계 쀀수** - 몚든 윘텐잠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2쀄, 핵심 킀워드만 로 강조 + +5. **섹션 번혞 독늜** - 볞묞곌 첚부 번혞 연계 불필요 + +6. **표 정렬** - 제목셀/구분엎은 가욎데, 섀명은 좌잡 정렬 + +## 첚부 페읎지 규칙 +- 제목: `

                                        [첚부] 핎당 페읎지 낎용에 맞는 제목

                                        ` +- 제목은 좌잡 정렬, 16pt +- 각 첚부 페읎지도 마지막에 bottom-box로 핎당 페읎지 요앜 포핚 \ No newline at end of file diff --git a/03. Code/geulbeot_9th/requirements.txt b/03. Code/geulbeot_9th/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/03. Code/geulbeot_9th/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/03. Code/geulbeot_9th/static/css/editor.css b/03. Code/geulbeot_9th/static/css/editor.css new file mode 100644 index 0000000..013e99c --- /dev/null +++ b/03. Code/geulbeot_9th/static/css/editor.css @@ -0,0 +1,297 @@ +/* ===== 펞집 바 슀타음 ===== */ +.format-bar { + display: none; + align-items: center; + padding: 8px 12px; + background: var(--ui-panel); + border-bottom: 1px solid var(--ui-border); + gap: 6px; + flex-wrap: wrap; +} + +.format-bar.active { display: flex; } + +/* 펞집 바 2쀄 구조 */ +.format-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.format-row:first-child { + border-bottom: 1px solid var(--ui-border); + padding-bottom: 8px; +} + +.format-btn { + padding: 6px 10px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--ui-text); + font-size: 14px; + position: relative; +} + +.format-btn:hover { background: var(--ui-hover); } +.format-btn.active { background: rgba(0, 200, 83, 0.3); color: var(--ui-accent); } + +.format-select { + padding: 5px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + font-size: 12px; +} + +.format-divider { + width: 1px; + height: 24px; + background: var(--ui-border); + margin: 0 6px; +} + +/* 툮팁 */ +.format-btn .tooltip { + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 100; +} + +.format-btn:hover .tooltip { opacity: 1; } + +/* 페읎지 버튌 슀타음 */ +.format-btn.page-btn { + padding: 6px 12px; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; + min-width: fit-content; +} + +/* 페읎지 람레읎크 표시 */ +.page-break-forced { + border-top: 3px solid #e65100 !important; + margin-top: 10px; +} + +.move-to-prev-page { + border-top: 3px dashed #1976d2 !important; + margin-top: 10px; +} + +/* 색상 선택Ʞ */ +.color-picker-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.color-picker-btn input[type="color"] { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +/* 펞집 몚드 활성 랔록 */ +.active-block { + outline: 2px dashed var(--ui-accent) !important; + outline-offset: 2px; +} + +/* 표 삜입 몚달 */ +.table-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.table-modal.active { display: flex; } + +.table-modal-content { + background: var(--ui-panel); + border-radius: 12px; + padding: 24px; + width: 320px; + border: 1px solid var(--ui-border); +} + +.table-modal-title { + font-size: 16px; + font-weight: 700; + color: var(--ui-text); + margin-bottom: 20px; +} + +.table-modal-row { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.table-modal-row label { + flex: 1; + font-size: 13px; + color: var(--ui-dim); +} + +.table-modal-row input[type="number"] { + width: 60px; + padding: 6px 8px; + border: 1px solid var(--ui-border); + border-radius: 4px; + background: var(--ui-bg); + color: var(--ui-text); + text-align: center; +} + +.table-modal-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.table-modal-buttons { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.table-modal-btn { + flex: 1; + padding: 10px; + border-radius: 6px; + border: none; + font-size: 13px; + cursor: pointer; +} + +.table-modal-btn.primary { + background: var(--ui-accent); + color: #003300; + font-weight: 600; +} + +.table-modal-btn.secondary { + background: var(--ui-border); + color: var(--ui-text); +} + +/* 토슀튞 메시지 */ +.toast-container { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; +} + +.toast { + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +.resizable-container { position: relative; display: inline-block; max-width: 100%; } +.resizable-container.block-type { display: block; } + +.resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; +} + +.resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.resizable-container:hover .resize-handle { opacity: 0.8; } +.resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } +.resizable-container.resizing { outline: 2px dashed #00C853 !important; } +.resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + +/* 표 전용 */ +.resizable-container.table-resize .resize-handle { background: #2196F3; } +.resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + +/* 읎믞지 전용 */ +.resizable-container.figure-resize img { display: block; } + +/* 크Ʞ 표시 툮팁 */ +.size-tooltip { + position: absolute; + bottom: 100%; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.resizable-container:hover .size-tooltip, +.resizable-container.resizing .size-tooltip { opacity: 1; } + + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* 읞쇄 시 숚김 */ +@media print { + .format-bar, + .table-modal, + .toast-container { + display: none !important; + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/static/js/editor.js b/03. Code/geulbeot_9th/static/js/editor.js new file mode 100644 index 0000000..1294ff3 --- /dev/null +++ b/03. Code/geulbeot_9th/static/js/editor.js @@ -0,0 +1,1208 @@ +/** + * Ꞁ벗 Light - 펞집 바 몚듈 + * editor.js + */ + +// ===== 전역 변수 ===== +let isEditing = false; +let activeBlock = null; +let historyStack = []; +let redoStack = []; +const MAX_HISTORY = 50; +let isApplyingFormat = false; + +// ===== 펞집 바 HTML 생성 ===== +// ===== 펞집 바 HTML 생성 ===== +function createFormatBar() { + const formatBarHTML = ` +
                                        + + + +
                                        + + + + +
                                        + + +
                                        +
                                        + A + +
                                        +
                                        + A + +
                                        + + +
                                        + + + +
                                        + `; + return formatBarHTML; +} + +// ===== 로컬 폰튾 불러였Ʞ ===== +async function loadLocalFonts() { + // API 지원 여부 확읞 + if (!('queryLocalFonts' in window)) { + toast('⚠ 읎 람띌우저는 폰튾 불러였Ʞ륌 지원하지 않습니닀 (Chrome/Edge 필요)'); + return; + } + + try { + toast('🔄 폰튾 불러였는 쀑...'); + + // 사용자 권한 요청 & 폰튾 목록 가젞였Ʞ + const fonts = await window.queryLocalFonts(); + const fontSelect = document.getElementById('fontFamily'); + + // Ʞ졎 옵션듀의 값 수집 (쀑복 방지) + const existingFonts = new Set(); + fontSelect.querySelectorAll('option').forEach(opt => { + existingFonts.add(opt.value); + }); + + // 쀑복 제거 (family Ʞ쀀) + const families = [...new Set(fonts.map(f => f.family))]; + + // 구분선 추가 + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──── 낮 컎퓚터 ────'; + fontSelect.appendChild(separator); + + // 새 폰튾 추가 + let addedCount = 0; + families.sort().forEach(family => { + if (!existingFonts.has(family)) { + const option = document.createElement('option'); + option.value = family; + option.textContent = family; + fontSelect.appendChild(option); + addedCount++; + } + }); + + toast(`✅ ${addedCount}개 폰튾 추가됚 (쎝 ${families.length}개)`); + + } catch (e) { + if (e.name === 'NotAllowedError') { + toast('⚠ 폰튾 ì ‘ê·Œ 권한읎 거부되었습니닀'); + } else { + console.error('폰튾 로드 였류:', e); + toast('❌ 폰튾 불러였Ʞ 싀팚: ' + e.message); + } + } +} + +// ===== 삜입 핞듀러 ===== +function handleInsert(type) { + if (type === 'table') openTableModal(); + else if (type === 'image') insertImage(); + else if (type === 'hr') insertHR(); +} + + +// ===== 표 삜입 몚달 HTML 생성 ===== +function createTableModal() { + const modalHTML = ` +
                                        +
                                        +
                                        ▩ 표 삜입
                                        +
                                        + + +
                                        +
                                        + + +
                                        +
                                        + + +
                                        +
                                        + + +
                                        +
                                        +
                                        + `; + return modalHTML; +} + +// ===== 토슀튞 컚테읎너 생성 ===== +function createToastContainer() { + if (!document.getElementById('toastContainer')) { + const container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } +} + +// ===== 토슀튞 메시지 ===== +function toast(message) { + createToastContainer(); + const container = document.getElementById('toastContainer'); + const toastEl = document.createElement('div'); + toastEl.className = 'toast'; + toastEl.textContent = message; + container.appendChild(toastEl); + setTimeout(() => toastEl.remove(), 3000); +} + +// ===== iframe ì°žì¡° 가젞였Ʞ ===== +function getPreviewIframe() { + return document.getElementById('previewFrame'); +} + +function getIframeDoc() { + const iframe = getPreviewIframe(); + if (!iframe) return null; + return iframe.contentDocument || iframe.contentWindow.document; +} + +// ===== Ʞ볞 포맷 명령 ===== +function formatText(command, value = null) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + doc.execCommand(command, false, value); +} + +// ===== 자간 조절 ===== +function adjustLetterSpacing(delta) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + isApplyingFormat = true; + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast('텍슀튞륌 선택핎죌섞요'); + return; + } + + saveState(); + const range = selection.getRangeAt(0); + let targetNode = range.commonAncestorContainer; + if (targetNode.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode; + + const computed = doc.defaultView.getComputedStyle(targetNode); + const currentSpacing = parseFloat(computed.letterSpacing) || 0; + const newSpacing = currentSpacing + delta; + + if (targetNode.tagName === 'SPAN' && range.toString() === targetNode.textContent) { + targetNode.style.letterSpacing = newSpacing + 'px'; + } else { + try { + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.letterSpacing = newSpacing + 'px'; + span.appendChild(fragment); + range.insertNode(span); + } + } + toast('자간: ' + newSpacing.toFixed(1) + 'px'); + setTimeout(() => { isApplyingFormat = false; }, 100); +} + +// ===== 색상 적용 ===== +function applyTextColor(color) { formatText('foreColor', color); } +function applyBgColor(color) { formatText('hiliteColor', color); } + +// ===== 목록 ===== +function toggleBulletList() { formatText('insertUnorderedList'); } +function toggleNumberList() { formatText('insertOrderedList'); } + +// ===== 듀여쓰Ʞ ===== +function adjustIndent(direction) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + if (activeBlock) { + saveState(); + const current = parseInt(activeBlock.style.marginLeft) || 0; + activeBlock.style.marginLeft = Math.max(0, current + (direction * 20)) + 'px'; + toast(direction > 0 ? '→ 듀여쓰Ʞ' : '← 낎얎쓰Ʞ'); + } else { + formatText(direction > 0 ? 'indent' : 'outdent'); + } +} + +// ===== 제목 슀타음 ===== +function applyHeading(tag) { + const doc = getIframeDoc(); + if (!doc || !isEditing || !activeBlock) return; + + saveState(); + const content = activeBlock.innerHTML; + let newEl; + + if (tag === '') { + newEl = doc.createElement('p'); + newEl.innerHTML = content; + newEl.style.fontSize = '12pt'; + newEl.style.lineHeight = '1.6'; + } else { + newEl = doc.createElement(tag); + newEl.innerHTML = content; + if (tag === 'h1') { + newEl.style.cssText = 'font-size:20pt; font-weight:900; color:#1a365d; border-bottom:2px solid #1a365d; margin-bottom:20px;'; + } else if (tag === 'h2') { + newEl.style.cssText = 'font-size:18pt; border-left:5px solid #2c5282; padding-left:10px; color:#1a365d;'; + } else if (tag === 'h3') { + newEl.style.cssText = 'font-size:14pt; color:#2c5282;'; + } + } + + newEl.setAttribute('contenteditable', 'true'); + activeBlock.replaceWith(newEl); + setActiveBlock(newEl); +} + +// ===== 폰튾 ===== +function applyFontFamily(fontName) { + if (!isEditing) return; + formatText('fontName', fontName); +} + +function applyFontSizeInput(size) { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const selection = doc.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; + + saveState(); + const range = selection.getRangeAt(0); + try { + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + range.surroundContents(span); + } catch (e) { + const fragment = range.extractContents(); + const span = doc.createElement('span'); + span.style.fontSize = size + 'pt'; + span.appendChild(fragment); + range.insertNode(span); + } + toast('Ꞁ씚 크Ʞ: ' + size + 'pt'); +} + +// ===== 표 삜입 ===== +function openTableModal() { + document.getElementById('tableModal').classList.add('active'); +} + +function closeTableModal() { + document.getElementById('tableModal').classList.remove('active'); +} + +function insertTable() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const rows = parseInt(document.getElementById('tableRows').value) || 3; + const cols = parseInt(document.getElementById('tableCols').value) || 3; + const hasHeader = document.getElementById('tableHeader').checked; + + saveState(); + + let tableHTML = ''; + for (let i = 0; i < rows; i++) { + tableHTML += ''; + for (let j = 0; j < cols; j++) { + if (i === 0 && hasHeader) { + tableHTML += ''; + } else { + tableHTML += ''; + } + } + tableHTML += ''; + } + tableHTML += '
                                        헀더낎용
                                        '; + + insertAtCursor(tableHTML); + closeTableModal(); + toast('▩ 표가 삜입되었습니닀'); +} + +// ===== 읎믞지 삜입 ===== +function insertImage() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = e => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + saveState(); + const html = `
                                        + +
                                        귞늌 섀명
                                        +
                                        `; + insertAtCursor(html); + toast('🖌 읎믞지가 삜입되었습니닀'); + }; + reader.readAsDataURL(file); + }; + input.click(); +} + +// ===== 읎믞지 늬사읎슈 ===== +function selectImageForResize(img) { + if (!isEditing) return; + + // Ʞ졎 선택 핎제 + const doc = getIframeDoc(); + doc.querySelectorAll('img.selected-image').forEach(i => { + i.classList.remove('selected-image'); + i.style.outline = ''; + }); + + // 새 선택 + img.classList.add('selected-image'); + img.style.outline = '3px solid #00c853'; + + // 크Ʞ 조절 핞듀러 + img.onmousedown = function(e) { + if (!isEditing) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = img.offsetWidth; + + function onMouseMove(e) { + const diff = e.clientX - startX; + const newWidth = Math.max(50, startWidth + diff); + img.style.width = newWidth + 'px'; + img.style.height = 'auto'; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('읎믞지 크Ʞ 조절됚'); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; +} + +// ===== 구분선 삜입 ===== +function insertHR() { + const doc = getIframeDoc(); + if (!doc || !isEditing) return; + saveState(); + insertAtCursor('
                                        '); + toast('― 구분선 삜입'); +} + +// ===== 컀서 위치에 HTML 삜입 ===== +function insertAtCursor(html) { + const doc = getIframeDoc(); + if (!doc) return; + + const selection = doc.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + const temp = doc.createElement('div'); + temp.innerHTML = html; + const frag = doc.createDocumentFragment(); + while (temp.firstChild) frag.appendChild(temp.firstChild); + range.insertNode(frag); + } else if (activeBlock) { + activeBlock.insertAdjacentHTML('afterend', html); + } +} + +// ===== 랔록 선택/ꎀ늬 ===== +function setActiveBlock(el) { + clearActiveBlock(); + activeBlock = el; + if (activeBlock) activeBlock.classList.add('active-block'); +} + +function clearActiveBlock() { + if (activeBlock) activeBlock.classList.remove('active-block'); + activeBlock = null; +} + +// ===== Undo/Redo ===== +function saveState() { + const doc = getIframeDoc(); + if (!doc) return; + + if (redoStack.length > 0) redoStack.length = 0; + historyStack.push(doc.body.innerHTML); + if (historyStack.length > MAX_HISTORY) historyStack.shift(); +} + +function performUndo() { + const doc = getIframeDoc(); + if (!doc || historyStack.length <= 1) return; + + redoStack.push(doc.body.innerHTML); + historyStack.pop(); + doc.body.innerHTML = historyStack[historyStack.length - 1]; + bindIframeEditEvents(); + toast('↩ 싀행 췚소'); +} + +function performRedo() { + const doc = getIframeDoc(); + if (!doc || redoStack.length === 0) return; + + const nextState = redoStack.pop(); + historyStack.push(nextState); + doc.body.innerHTML = nextState; + bindIframeEditEvents(); + toast('↪ 닀시 싀행'); +} + +// ===== 킀볎드 닚축킀 ===== +function handleEditorKeydown(e) { + if (!isEditing) return; + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': e.preventDefault(); formatText('bold'); break; + case 'i': e.preventDefault(); formatText('italic'); break; + case 'u': e.preventDefault(); formatText('underline'); break; + case 'z': e.preventDefault(); e.shiftKey ? performRedo() : performUndo(); break; + case 'y': e.preventDefault(); performRedo(); break; + case '=': + case '+': e.preventDefault(); adjustLetterSpacing(0.5); break; + case '-': e.preventDefault(); adjustLetterSpacing(-0.5); break; + } + } + if (e.key === 'Tab') { + e.preventDefault(); + adjustIndent(e.shiftKey ? -1 : 1); + } +} + + +// ===== 늬사읎슈 핞듀 추가 핚수 ===== +function addResizeHandle(doc, element, type) { + // wrapper 생성 + const wrapper = doc.createElement('div'); + wrapper.className = 'resizable-container ' + (type === 'table' ? 'table-resize block-type' : 'figure-resize'); + + // 쎈Ʞ 크Ʞ 섀정 + const rect = element.getBoundingClientRect(); + wrapper.style.width = element.style.width || (rect.width + 'px'); + + // 크Ʞ 표시 툮팁 + const tooltip = doc.createElement('div'); + tooltip.className = 'size-tooltip'; + tooltip.textContent = Math.round(rect.width) + ' × ' + Math.round(rect.height); + + // 늬사읎슈 핞듀 + const handle = doc.createElement('div'); + handle.className = 'resize-handle'; + handle.title = '드래귞하여 크Ʞ 조절'; + + // DOM 구조 변겜 + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); + wrapper.appendChild(tooltip); + wrapper.appendChild(handle); + + // 표는 width 100%로 시작 + if (type === 'table') { + element.style.width = '100%'; + } + + // 늬사읎슈 읎벀튞 + let isResizing = false; + let startX, startY, startWidth, startHeight; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + wrapper.classList.add('resizing'); + + startX = e.clientX; + startY = e.clientY; + startWidth = wrapper.offsetWidth; + startHeight = wrapper.offsetHeight; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + if (!isResizing) return; + e.preventDefault(); + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + const aspectRatio = startWidth / startHeight; + let newWidth = Math.max(100, startWidth + deltaX); + let newHeight; + + if (e.shiftKey) { + newHeight = newWidth / aspectRatio; // 비윚 유지 + } else { + newHeight = Math.max(50, startHeight + deltaY); + } + + wrapper.style.width = newWidth + 'px'; + + // 읎믞지읞 겜우 width, height 둘 ë‹€ 조절 + if (type !== 'table') { + const img = wrapper.querySelector('img'); + if (img) { + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + } + + tooltip.textContent = Math.round(newWidth) + ' × ' + Math.round(newHeight); + } + + function onMouseUp(e) { + if (!isResizing) return; + isResizing = false; + wrapper.classList.remove('resizing'); + + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + + saveState(); + toast('📐 크Ʞ 조절: ' + Math.round(wrapper.offsetWidth) + 'px'); + } +} + +// ===== iframe 낎부에 펞집용 슀타음 죌입 ===== +function injectEditStyles(doc) { + if (doc.getElementById('editor-inject-style')) return; + + const style = doc.createElement('style'); + style.id = 'editor-inject-style'; + style.textContent = ` + /* 늬사읎슈 컚테읎너 */ + .resizable-container { position: relative; display: inline-block; max-width: 100%; } + .resizable-container.block-type { display: block; } + + /* 늬사읎슈 핞듀 */ + .resize-handle { + position: absolute; + right: -2px; + bottom: -2px; + width: 18px; + height: 18px; + background: #00C853; + cursor: se-resize; + opacity: 0; + transition: opacity 0.2s; + z-index: 100; + border-radius: 3px 0 3px 0; + display: flex; + align-items: center; + justify-content: center; + } + .resize-handle::after { + content: '‡'; + color: white; + font-size: 12px; + font-weight: bold; + } + .resizable-container:hover .resize-handle { opacity: 0.8; } + .resize-handle:hover { opacity: 1 !important; transform: scale(1.1); } + .resizable-container.resizing { outline: 2px dashed #00C853 !important; } + .resizable-container.resizing .resize-handle { opacity: 1; background: #FF9800; } + + /* 표 전용 - 파란색 핞듀 */ + .resizable-container.table-resize .resize-handle { background: #2196F3; } + .resizable-container.table-resize.resizing .resize-handle { background: #FF5722; } + + /* 읎믞지 전용 */ + .resizable-container.figure-resize img { display: block; } + + /* 크Ʞ 표시 툮팁 */ + .size-tooltip { + position: absolute; + top: -25px; + right: 0; + background: rgba(0,0,0,0.8); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + } + .resizable-container:hover .size-tooltip, + .resizable-container.resizing .size-tooltip { opacity: 1; } + + /* ì—Ž 늬사읎슈 핞듀 */ + .col-resize-handle { + position: absolute; + top: 0; + width: 6px; + height: 100%; + background: transparent; + cursor: col-resize; + z-index: 50; + } + .col-resize-handle:hover { background: rgba(33, 150, 243, 0.3); } + .col-resize-handle.dragging { background: rgba(33, 150, 243, 0.5); } + + /* 펞집 쀑 하읎띌읎튞 */ + [contenteditable]:focus { outline: 2px solid #00C853 !important; } + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); } + `; + doc.head.appendChild(style); +} + +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +// ===== iframe 펞집 읎벀튞 바읞딩 ===== +function bindIframeEditEvents() { + const doc = getIframeDoc(); + if (!doc) return; + + // 펞집용 슀타음 죌입 + injectEditStyles(doc); + + // 킀볎드 읎벀튞 + doc.removeEventListener('keydown', handleEditorKeydown); + doc.addEventListener('keydown', handleEditorKeydown); + + // 랔록 큎늭 읎벀튞 + doc.body.addEventListener('click', function(e) { + if (!isEditing) return; + let target = e.target; + while (target && target !== doc.body) { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(target.tagName)) { + setActiveBlock(target); + return; + } + target = target.parentElement; + } + clearActiveBlock(); + }); + + // ===== 표에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('.body-content table, .sheet table').forEach(table => { + if (table.closest('.resizable-container')) return; + addResizeHandle(doc, table, 'table'); + addColumnResizeHandles(doc, table); // ì—Ž 늬사읎슈 추가 + }); + + // ===== 읎믞지에 늬사읎슈 핞듀 추가 ===== + doc.querySelectorAll('figure img, .body-content img, .sheet img').forEach(img => { + if (img.closest('.resizable-container')) return; + addResizeHandle(doc, img, 'image'); + }); +} +// ===== 표 ì—Ž 늬사읎슈 핞듀 추가 ===== +function addColumnResizeHandles(doc, table) { + // 테읎랔에 position relative 섀정 + table.style.position = 'relative'; + + // 첫 번짞 행의 셀듀을 Ʞ쀀윌로 ì—Ž 핞듀 생성 + const firstRow = table.querySelector('tr'); + if (!firstRow) return; + + const cells = firstRow.querySelectorAll('th, td'); + + cells.forEach((cell, index) => { + if (index === cells.length - 1) return; // 마지막 엎은 제왞 + + // 읎믞 핞듀읎 있윌멎 슀킵 + if (cell.querySelector('.col-resize-handle')) return; + + cell.style.position = 'relative'; + + const handle = doc.createElement('div'); + handle.className = 'col-resize-handle'; + handle.style.right = '-3px'; + cell.appendChild(handle); + + let startX, startWidth, nextStartWidth; + let nextCell = cells[index + 1]; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + e.stopPropagation(); + + handle.classList.add('dragging'); + startX = e.clientX; + startWidth = cell.offsetWidth; + nextStartWidth = nextCell ? nextCell.offsetWidth : 0; + + doc.addEventListener('mousemove', onMouseMove); + doc.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + const delta = e.clientX - startX; + const newWidth = Math.max(30, startWidth + delta); + + cell.style.width = newWidth + 'px'; + + // 닀음 엎도 조정 (테읎랔 전첎 너비 유지) + if (nextCell && nextStartWidth > 30) { + const newNextWidth = Math.max(30, nextStartWidth - delta); + nextCell.style.width = newNextWidth + 'px'; + } + } + + function onMouseUp() { + handle.classList.remove('dragging'); + doc.removeEventListener('mousemove', onMouseMove); + doc.removeEventListener('mouseup', onMouseUp); + saveState(); + toast('📊 ì—Ž 너비 조절됚'); + } + }); +} + +// ===== 펞집 몚드 토Ꞁ ===== +function toggleEditMode() { + const doc = getIframeDoc(); + if (!doc) return; + + isEditing = !isEditing; + + const formatBar = document.getElementById('formatBar'); + const editBtn = document.getElementById('editModeBtn'); + + if (isEditing) { + // 펞집 몚드 ON + doc.designMode = 'on'; + if (formatBar) formatBar.classList.add('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집 쀑'; + editBtn.classList.add('active'); + } + + // contenteditable 섀정 + doc.querySelectorAll('.sheet *').forEach(el => { + if (['DIV', 'P', 'H1', 'H2', 'H3', 'SPAN', 'LI', 'TD', 'TH', 'FIGCAPTION'].includes(el.tagName)) { + el.setAttribute('contenteditable', 'true'); + } + }); + + bindIframeEditEvents(); + saveState(); + toast('✏ 펞집 몚드 시작'); + } else { + // 펞집 몚드 OFF + doc.designMode = 'off'; + if (formatBar) formatBar.classList.remove('active'); + if (editBtn) { + editBtn.textContent = '✏ 펞집하Ʞ'; + editBtn.classList.remove('active'); + } + + // contenteditable 제거 + doc.querySelectorAll('[contenteditable]').forEach(el => { + el.removeAttribute('contenteditable'); + }); + + clearActiveBlock(); + toast('✏ 펞집 몚드 종료'); + } +} + +// ===== 펞집Ʞ 쎈Ʞ화 ===== +function initEditor() { + // 펞집 바가 없윌멎 생성 + if (!document.getElementById('formatBar')) { + const previewContainer = document.querySelector('.main'); + if (previewContainer) { + previewContainer.insertAdjacentHTML('afterbegin', createFormatBar()); + } + } + + // 표 몚달읎 없윌멎 생성 + if (!document.getElementById('tableModal')) { + document.body.insertAdjacentHTML('beforeend', createTableModal()); + } + + // 토슀튞 컚테읎너 생성 + createToastContainer(); + + console.log('Editor initialized'); +} + +// ===== 지능형 정렬 ===== +function smartAlign() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + // ===== 현재 슀크례 위치 저장 ===== + const iframe = getPreviewIframe(); + const scrollY = iframe?.contentWindow?.scrollY || 0; + + const sheets = Array.from(doc.querySelectorAll('.sheet')); + if (sheets.length < 2) { + toast('⚠ 정렬할 볞묞 페읎지가 없습니닀'); + return; + } + + toast('지능형 정렬 싀행 쀑...'); + + setTimeout(() => { + try { + // 1. 표지 유지 + const coverSheet = sheets[0]; + + // 2. 볎고서 제목 추출 + let reportTitle = "볎고서"; + const existingTitle = sheets[1]?.querySelector('.rpt-title, .header-title'); + if (existingTitle) reportTitle = existingTitle.innerText; + + // 3. 윘텐잠 수집 (표지 제왞) + const contentSheets = sheets.slice(1); + let allNodes = []; + + contentSheets.forEach(sheet => { + const body = sheet.querySelector('.body-content'); + if (body) { + Array.from(body.children).forEach(child => { + if (child.classList.contains('add-after-btn') || + child.classList.contains('delete-block-btn') || + child.classList.contains('empty-placeholder')) return; + + if (['P', 'DIV', 'SPAN'].includes(child.tagName) && + child.innerText.trim() === '' && + !child.querySelector('img, table, figure')) return; + + allNodes.push(child); + }); + } + sheet.remove(); + }); + + // 4. 섀정값 + const MAX_HEIGHT = 970; + const HEADING_RESERVE = 90; + let currentHeaderTitle = "목찚"; + let pageNum = 1; + + // 5. 새 페읎지 생성 핚수 + function createNewPage(headerText) { + const newSheet = doc.createElement('div'); + newSheet.className = 'sheet'; + newSheet.innerHTML = ` + +
                                        + `; + doc.body.appendChild(newSheet); + return newSheet; + } + + // 6. 페읎지 재구성 + let currentPage = createNewPage(currentHeaderTitle); + let currentBody = currentPage.querySelector('.body-content'); + + allNodes.forEach(node => { + // 강제 페읎지 람레읎크 + if (node.classList && node.classList.contains('page-break-forced')) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + return; + } + + // H1: 새 섹션 시작 + if (node.tagName === 'H1') { + currentHeaderTitle = node.innerText.split('-')[0].trim(); + if (currentBody.children.length > 0) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } else { + currentPage.querySelector('.page-header').innerText = currentHeaderTitle; + } + } + + // H2, H3: 낚은 공간 부족하멎 새 페읎지 + if (['H2', 'H3'].includes(node.tagName)) { + const spaceLeft = MAX_HEIGHT - currentBody.scrollHeight; + if (spaceLeft < HEADING_RESERVE) { + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + } + } + + // 녾드 추가 + currentBody.appendChild(node); + + // 전 페읎지로 강제 읎동 섀정된 겜우 슀킵 + if (node.classList && node.classList.contains('move-to-prev-page')) { + return; + } + + // 높읎 쎈곌 시 새 페읎지로 읎동 + if (currentBody.scrollHeight > MAX_HEIGHT) { + currentBody.removeChild(node); + currentPage = createNewPage(currentHeaderTitle); + currentBody = currentPage.querySelector('.body-content'); + currentBody.appendChild(node); + } + }); + + // 7. 펞집 몚드였윌멎 복원 + if (isEditing) { + bindIframeEditEvents(); + } + + // 8. generatedHTML 업데읎튞 (전역 변수) + if (typeof generatedHTML !== 'undefined') { + generatedHTML = '' + doc.documentElement.outerHTML; + } + + // ===== 슀크례 위치 복원 ===== + setTimeout(() => { + if (iframe?.contentWindow) { + iframe.contentWindow.scrollTo(0, scrollY); + } + }, 50); + + toast('✅ 지능형 정렬 완료 (' + pageNum + '페읎지)'); + + + } catch (e) { + console.error('smartAlign 였류:', e); + toast('❌ 정렬 쀑 였류: ' + e.message); + } + }, 100); +} + +// ===== 새페읎지 시작 ===== +function forcePageBreak() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 분늬할 위치륌 큎늭하섞요'); + return; + } + + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + const currentBody = targetEl.parentElement; + const currentSheet = currentBody.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 큮멭한 요소부터 끝까지 수집 + const elementsToMove = []; + let sibling = targetEl; + while (sibling) { + elementsToMove.push(sibling); + sibling = sibling.nextElementSibling; + } + + if (elementsToMove.length === 0) { + toast('⚠ 읎동할 낎용읎 없습니닀'); + return; + } + + // 닀음 페읎지 ì°Ÿêž° + let nextSheet = sheets[currentIndex + 1]; + let nextBody; + + if (!nextSheet || !nextSheet.querySelector('.body-content')) { + const oldHeader = currentSheet.querySelector('.page-header'); + const oldFooter = currentSheet.querySelector('.page-footer'); + nextSheet = doc.createElement('div'); + nextSheet.className = 'sheet'; + nextSheet.innerHTML = ` + +
                                        + `; + currentSheet.after(nextSheet); + } + + nextBody = nextSheet.querySelector('.body-content'); + + // 역순윌로 ë§š 앞에 삜입 (순서 유지) + for (let i = elementsToMove.length - 1; i >= 0; i--) { + nextBody.insertBefore(elementsToMove[i], nextBody.firstChild); + } + + // 첫 번짞 요소에 페읎지 람레읎크 마컀 추가 (나쀑에 지능형 정렬읎 졎쀑핚) + targetEl.classList.add('page-break-forced'); + + // 페읎지 번혞만 재정렬 (smartAlign 혞출 안 핹!) + renumberPages(doc); + + toast('✅ 닀음 페읎지로 읎동됚'); +} + + +// ===== 전페읎지로 읎동 (슉시 적용) ===== +function moveToPrevPage() { + const doc = getIframeDoc(); + if (!doc) { + toast('⚠ 묞서가 로드되지 않았습니닀'); + return; + } + + const selection = doc.getSelection(); + if (!selection || !selection.anchorNode) { + toast('⚠ 읎동할 랔록을 큎늭하섞요'); + return; + } + + // 현재 선택된 요소에서 body-content 직계 자식 ì°Ÿêž° + let targetEl = selection.anchorNode.nodeType === 1 + ? selection.anchorNode + : selection.anchorNode.parentElement; + + while (targetEl && targetEl.parentElement) { + if (targetEl.parentElement.classList && targetEl.parentElement.classList.contains('body-content')) { + break; + } + targetEl = targetEl.parentElement; + } + + if (!targetEl || !targetEl.parentElement || !targetEl.parentElement.classList.contains('body-content')) { + toast('⚠ 볞묞 랔록을 뚌저 큎늭하섞요'); + return; + } + + saveState(); + + // 현재 sheet ì°Ÿêž° + const currentSheet = targetEl.closest('.sheet'); + const sheets = Array.from(doc.querySelectorAll('.sheet')); + const currentIndex = sheets.indexOf(currentSheet); + + // 읎전 페읎지 ì°Ÿêž° (표지 제왞) + if (currentIndex <= 1) { + toast('⚠ 읎전 페읎지가 없습니닀'); + return; + } + + const prevSheet = sheets[currentIndex - 1]; + const prevBody = prevSheet.querySelector('.body-content'); + + if (!prevBody) { + toast('⚠ 읎전 페읎지에 볞묞 영역읎 없습니닀'); + return; + } + + // 요소륌 읎전 페읎지 ë§š 아래로 읎동 + prevBody.appendChild(targetEl); + + // 현재 페읎지가 비었윌멎 삭제 + const currentBody = currentSheet.querySelector('.body-content'); + if (currentBody && currentBody.children.length === 0) { + currentSheet.remove(); + } + + // 페읎지 번혞 재정렬 + renumberPages(doc); + + toast('✅ 전 페읎지로 읎동됚'); +} + +// ===== 페읎지 번혞 재정렬 ===== +function renumberPages(doc) { + const sheets = doc.querySelectorAll('.sheet'); + let pageNum = 1; + + sheets.forEach((sheet, idx) => { + if (idx === 0) return; // 표지는 번혞 없음 + + const pgNum = sheet.querySelector('.pg-num'); + if (pgNum) { + pgNum.innerText = `- ${pageNum++} -`; + } + }); +} + + + + +// DOM 로드 시 쎈Ʞ화 +document.addEventListener('DOMContentLoaded', initEditor); + + + + diff --git a/03. Code/geulbeot_9th/templates/default/doc_types/briefing/config.json b/03. Code/geulbeot_9th/templates/default/doc_types/briefing/config.json new file mode 100644 index 0000000..20369db --- /dev/null +++ b/03. Code/geulbeot_9th/templates/default/doc_types/briefing/config.json @@ -0,0 +1,26 @@ +{ + "id": "briefing", + "name": "Ʞ획서", + "icon": "📋", + "description": "1~2페읎지 분량의 임원 볎고용 묞서", + "features": [ + {"icon": "📌", "text": "헀더 + 제목 랔록"}, + {"icon": "💡", "text": "핵심 요앜 (Lead Box)"}, + {"icon": "📊", "text": "볞묞 섹션 + 첚부"} + ], + "thumbnailType": "briefing", + "enabled": true, + "isDefault": true, + "order": 1, + "options": { + "pageConfig": { + "type": "radio-with-input", + "choices": [ + {"value": "body-only", "label": "(볞묞) 1p"}, + {"value": "body-attach", "label": "(볞묞) 1p + (첚부)", "hasInput": true, "inputSuffix": "p", "inputDefault": 1, "inputMin": 1, "inputMax": 10, "default": true} + ] + } + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/default/doc_types/presentation/config.json b/03. Code/geulbeot_9th/templates/default/doc_types/presentation/config.json new file mode 100644 index 0000000..3b8c5db --- /dev/null +++ b/03. Code/geulbeot_9th/templates/default/doc_types/presentation/config.json @@ -0,0 +1,27 @@ +{ + "id": "presentation", + "name": "발표자료", + "icon": "📊", + "description": "슬띌읎드 형식의 프레젠테읎션", + "features": [ + {"icon": "🎯", "text": "슬띌읎드 레읎아웃"}, + {"icon": "📈", "text": "찚튞/닀읎얎귞랚"}, + {"icon": "🎚", "text": "비죌얌 쀑심 구성"} + ], + "thumbnailType": "ppt", + "enabled": false, + "isDefault": true, + "order": 3, + "badge": "쀀비쀑", + "options": { + "slideCount": [ + {"value": "auto", "label": "자동 (낎용 êž°ë°˜)", "default": true}, + {"value": "5", "label": "5장 읎낎"}, + {"value": "10", "label": "10장 읎낎"}, + {"value": "20", "label": "20장 읎낎"} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/default/doc_types/report/config.json b/03. Code/geulbeot_9th/templates/default/doc_types/report/config.json new file mode 100644 index 0000000..578026e --- /dev/null +++ b/03. Code/geulbeot_9th/templates/default/doc_types/report/config.json @@ -0,0 +1,26 @@ +{ + "id": "report", + "name": "볎고서", + "icon": "📄", + "description": "닀페읎지 분량의 상섞 볎고서", + "features": [ + {"icon": "📘", "text": "표지 (선택)"}, + {"icon": "📑", "text": "목찚 자동 생성"}, + {"icon": "📝", "text": "챕터별 낎지"} + ], + "thumbnailType": "report", + "enabled": true, + "isDefault": true, + "order": 2, + "options": { + "components": [ + {"id": "cover", "label": "표지", "icon": "📘", "default": true}, + {"id": "toc", "label": "목찚", "icon": "📑", "default": true}, + {"id": "divider", "label": "간지", "icon": "📄", "default": false}, + {"id": "content", "label": "낎지 (필수)", "icon": "📝", "default": true, "required": true} + ] + }, + "generateFlow": "draft-first", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2025-01-30T00:00:00Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/hwp_guide.md b/03. Code/geulbeot_9th/templates/hwp_guide.md new file mode 100644 index 0000000..da7aafa --- /dev/null +++ b/03. Code/geulbeot_9th/templates/hwp_guide.md @@ -0,0 +1,302 @@ +# A4 HTML 묞서 레읎아웃 가읎드 +> 읎 가읎드는 Ꞁ벗 doc_template_analyzer가 HWPX에서 추출한 구조륌 +> A4 규격 HTML template.html로 변환할 때 찞조하는 레읎아웃 규격입니닀. +> +> ★ 읎 파음의 값은 윔드에 하드윔딩하지 않습니닀. +> ★ doc_template_analyzer._build_css(), _build_full_html() 등에서 읎 파음을 읜얎 적용합니닀. +> ★ 색상, 폰튾 등 슀타음은 HWPX에서 추출한 값을 우선 사용하고, 없윌멎 읎 가읎드의 Ʞ볞값을 사용합니닀. + +--- + +## 1. 페읎지 규격 (Page Dimensions) + +```yaml +page: + width: 210mm # A4 가로 + height: 297mm # A4 섞로 + background: white + boxSizing: border-box + +margins: + top: 20mm # 상닚 여백 (뚞늿말 + 볞묞 시작) + bottom: 20mm # 하당 여백 (ꌬ늿말 + 볞묞 끝) + left: 20mm # 좌잡 여백 + right: 20mm # ìš°ìž¡ 여백 + +# 볞묞 가용 높읎 = 297mm - 20mm(상) - 20mm(하) = 257mm ≈ 970px +bodyMaxHeight: 970px +``` + +## 2. HTML 곚격 구조 (Page Structure) + +각 페읎지는 `.sheet` 큎래슀로 감싞며, 낎부에 header/body/footer륌 absolute로 배치합니닀. + +```html + +
                                        + + + + +
                                        + +
                                        + + + +
                                        +``` + +## 3. 핵심 CSS 레읎아웃 (Layout CSS) + +### 3.1 용지 (.sheet) +```css +.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); +} +``` + +### 3.2 읞쇄 대응 +```css +@media print { + .sheet { margin: 0; break-after: page; box-shadow: none; } + body { background: white; } +} +``` + +### 3.3 뚞늿말 (.page-header) +```css +.page-header { + position: absolute; + top: 10mm; /* 상닚 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + padding-bottom: 5px; +} +``` +- 뚞늿말읎 **테읎랔 형태**읞 겜우: `` 사용, 테두늬 없음 +- HWPX에서 추출한 ì—Ž 수와 셀 낎용을 placeholder로 배치 +- 닀쀑행 셀은 `
                                        `로 쀄바꿈 + +### 3.4 ꌬ늿말 (.page-footer) +```css +.page-footer { + position: absolute; + bottom: 10mm; /* 하당 여백(20mm)의 쀑간 */ + left: 20mm; + right: 20mm; + font-size: 9pt; + color: #555; + border-top: 1px solid #eee; + padding-top: 5px; +} +``` +- ꌬ늿말읎 **테읎랔 형태**읞 겜우: `
                                        ` 사용, 테두늬 없음 +- 2ì—Ž 읎상음 때 `display: flex; justify-content: space-between` 팹턮도 가능 +- 페읎지 번혞는 별도 `` 윌로 + +### 3.5 볞묞 영역 (.body-content) +```css +.body-content { + position: absolute; + top: 20mm; + left: 20mm; + right: 20mm; + bottom: 20mm; /* 또는 auto + JS 제얎 */ +} +``` + +## 4. 타읎포귞래플 Ʞ볞값 (Typography Defaults) + +> HWPX에서 폰튾/크Ʞ륌 추출했윌멎 ê·ž 값을 사용합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 적용합니닀. + +```yaml +typography: + fontFamily: "'Noto Sans KR', sans-serif" + fontImport: "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap" + + body: + fontSize: 12pt + lineHeight: 1.6 + textAlign: justify + wordBreak: keep-all # 한Ꞁ 닚얎 쀑간 끊김 방지 + + heading: + h1: { fontSize: 20pt, fontWeight: 900 } + h2: { fontSize: 18pt, fontWeight: 700 } + h3: { fontSize: 14pt, fontWeight: 700 } + + headerFooter: + fontSize: 9pt + + table: + fontSize: 9.5pt + thFontSize: 9pt +``` + +## 5. 표 슀타음 Ʞ볞값 (Table Defaults) + +```yaml +table: + width: "100%" + borderCollapse: collapse + tableLayout: fixed # colgroup 비윚 적용 시 fixed 필수 + borderTop: "2px solid" # 상닚 굵은 선 (색상은 HWPX 추출) + + th: + fontWeight: 900 + textAlign: center + verticalAlign: middle + whiteSpace: nowrap # 헀더 셀은 한 쀄 유지 + wordBreak: keep-all + padding: "6px 5px" + + td: + textAlign: center + verticalAlign: middle + wordBreak: keep-all + wordWrap: break-word + padding: "6px 5px" + border: "1px solid #ddd" +``` + +## 6. 뚞늿말/ꌬ늿말 테읎랔 (Header/Footer Table) + +뚞늿말/ꌬ늿말읎 HWPX에서 테읎랔로 구성된 겜우: + +```yaml +headerFooterTable: + border: none # 테두늬 없음 + width: "100%" + fontSize: 9pt + + # ì—Ž 역할 팹턮 (HWPX에서 추출) + # 볎통 3ì—Ž: [소속정볎 | 빈칞/로고 | 작성자/날짜] + # 또는 2ì—Ž: [제목 | 페읎지번혞] + + cellStyle: + padding: "2px 5px" + verticalAlign: middle + border: none +``` + +## 7. 개조식 (Bullet Style) + +```yaml +bulletList: + marker: "·" # 한국 묞서 Ʞ볞 불늿 + className: "bullet-list" + + css: | + .bullet-list { + list-style: none; + padding-left: 15px; + margin: 5px 0; + } + .bullet-list li::before { + content: "· "; + font-weight: bold; + } + .bullet-list li { + margin-bottom: 3px; + line-height: 1.5; + } +``` + +## 8. 색상 (Color Scheme) + +> HWPX에서 추출한 색상을 CSS 변수로 죌입합니닀. +> 추출 싀팚 시 아래 Ʞ볞값을 사용합니닀. + +```yaml +colors: + # Navy 계엎 (Ʞ볞) + primary: "#1a365d" + accent: "#2c5282" + lightBg: "#EBF4FF" + + # 묞서별 였버띌읎드 (HWPX 추출값) + # doc_template_analyzer가 HWPX의 Ꞁ자색/배겜색을 분석하여 + # 읎 값을 덮얎씁니닀. + + css: | + :root { + --primary: #1a365d; + --accent: #2c5282; + --light-bg: #EBF4FF; + --bg: #f5f5f5; + } +``` + +## 9. 페읎지 분할 규칙 (Page Break Rules) + +```yaml +pageBreak: + # H1(대제목)에서만 강제 페읎지 분할 + h1Break: true + + # H2/H3읎 페읎지 하닚에 홀로 낚지 않도록 + orphanControl: true + orphanMinSpace: 90px # 읎 공간 믞만읎멎 닀음 페읎지로 + + # 표/귞늌은 분할하지 않음 + atomicBlocks: + - table + - figure + - ".highlight-box" + + # break-inside: avoid 적용 대상 + avoidBreakInside: + - table + - figure + - ".atomic-block" +``` + +## 10. 배겜 (Preview Background) + +```yaml +preview: + bodyBackground: "#525659" # 회색 배겜 위에 흰색 용지 + # 읞쇄 시 배겜 제거 (@media print) +``` + +--- + +## ★ 사용 방법 (How doc_template_analyzer uses this guide) + +1. `doc_template_analyzer._build_full_html()` 혞출 시: + - 읎 가읎드 파음을 읜음 + - HWPX에서 추출한 슀타음(색상, 폰튾, 크Ʞ)읎 있윌멎 였버띌읎드 + - 없윌멎 가읎드 Ʞ볞값 사용 + +2. CSS 생성 순서: + ``` + 가읎드 Ʞ볞값 → HWPX 추출 슀타음 였버띌읎드 → CSS 변수로 통합 + ``` + +3. HTML 구조: + ``` + 가읎드의 곚격(.sheet > .page-header + .body-content + .page-footer) + + HWPX에서 추출한 placeholder 배치 + = template.html + ``` + +4. 색상 결정: + ``` + HWPX headerTextColor → --primary + HWPX headerBgColor → --light-bg + 없윌멎 → 가읎드 Ʞ볞값(Navy 계엎) + ``` \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/hwp_html_defaults.json b/03. Code/geulbeot_9th/templates/hwp_html_defaults.json new file mode 100644 index 0000000..34b5243 --- /dev/null +++ b/03. Code/geulbeot_9th/templates/hwp_html_defaults.json @@ -0,0 +1,116 @@ +{ + "_comment": "A4 HTML 묞서 레읎아웃 Ʞ볞값 - hwp_html_guide.md ì°žì¡°. HWPX 추출값읎 있윌멎 였버띌읎드됚", + + "page": { + "width": "210mm", + "height": "297mm", + "background": "white" + }, + + "margins": { + "top": "20mm", + "bottom": "20mm", + "left": "20mm", + "right": "20mm" + }, + + "headerPosition": { + "top": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "footerPosition": { + "bottom": "10mm", + "left": "20mm", + "right": "20mm" + }, + + "bodyContent": { + "top": "20mm", + "left": "20mm", + "right": "20mm", + "bottom": "20mm" + }, + + "bodyMaxHeight": "970px", + + "preview": { + "bodyBackground": "#f5f5f5", + "sheetMargin": "20px auto", + "sheetShadow": "0 0 15px rgba(0,0,0,0.1)" + }, + + "typography": { + "fontFamily": "'Noto Sans KR', sans-serif", + "fontImport": "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap", + "body": { + "fontSize": "10pt", + "lineHeight": "1.6", + "textAlign": "justify", + "wordBreak": "keep-all" + }, + "heading": { + "h1": { "fontSize": "20pt", "fontWeight": "900" }, + "h2": { "fontSize": "16pt", "fontWeight": "700" }, + "h3": { "fontSize": "13pt", "fontWeight": "700" } + }, + "headerFooter": { + "fontSize": "9pt" + } + }, + + "colors": { + "primary": "#1a365d", + "accent": "#2c5282", + "lightBg": "#EBF4FF", + "text": "#000", + "headerText": "#000", + "footerText": "#555", + "footerBorder": "#eee", + "tableBorderTop": "#1a365d", + "tableBorder": "#ddd", + "tableHeaderBg": "#EBF4FF" + }, + + "table": { + "width": "100%", + "borderCollapse": "collapse", + "tableLayout": "fixed", + "fontSize": "9.5pt", + "th": { + "fontSize": "9pt", + "fontWeight": "900", + "textAlign": "center", + "verticalAlign": "middle", + "whiteSpace": "nowrap", + "padding": "6px 5px" + }, + "td": { + "textAlign": "center", + "verticalAlign": "middle", + "wordBreak": "keep-all", + "padding": "6px 5px" + } + }, + + "headerFooterTable": { + "border": "none", + "width": "100%", + "fontSize": "9pt", + "cellPadding": "2px 5px" + }, + + "bulletList": { + "marker": "·", + "className": "bullet-list", + "paddingLeft": "15px", + "itemMargin": "3px 0" + }, + + "pageBreak": { + "h1Break": true, + "orphanControl": true, + "orphanMinSpace": "90px" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/index.html b/03. Code/geulbeot_9th/templates/index.html new file mode 100644 index 0000000..a207a34 --- /dev/null +++ b/03. Code/geulbeot_9th/templates/index.html @@ -0,0 +1,3764 @@ + + + + + + Ꞁ벗 - AI 묞서 자동화 시슀템 + + + + + + +
                                        + + +
                                        + + + +
                                        + + + +
                                        + + + + + +
                                        + + +
                                        + + + + +
                                        +
                                        +
                                        +
                                        + +
                                        +
                                        📄
                                        +
                                        HTML을 입력하고 생성하섞요
                                        +
                                        좌잡에서 HTML 붙여넣Ʞ 또는 파음 업로드
                                        +
                                        +
                                        +
                                        +
                                        + + +
                                        + + +
                                        + + + +
                                        + + +
                                        +
                                        + 묞서 섀정 +
                                        +
                                        + +
                                        +
                                        묞서 유형
                                        +
                                        + +
                                        + + + +
                                        + + +
                                        + +
                                        + + +
                                        +
                                        템플늿
                                        +
                                        +
                                        + + 📄 Ʞ볞 템플늿 +
                                        +
                                        +
                                        + + + +
                                        + + +
                                        +
                                        요청사항
                                        + +
                                        + + + +
                                        +
                                        +
                                        + + +
                                        +
                                        + + 쀀비됚 +
                                        +
                                        Ꞁ벗 Light v2.1
                                        +
                                        + + + + + + + + + + + +
                                        + +
                                        🀖 AI로 수정하Ʞ
                                        +
                                        선택된 텍슀튞:
                                        +
                                        + + +
                                        + + +
                                        +
                                        +
                                        +
                                        📁 템플늿 추가
                                        + +
                                        + +
                                        + + +
                                        + +
                                        + +
                                        +
                                        📄
                                        +
                                        파음을 드래귞하거나 큎늭하여 선택
                                        +
                                        HWPX, HWP, PDF 지원
                                        +
                                        + +
                                        + + ✕ +
                                        +
                                        + + +
                                        +
                                        + + + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/config.json b/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/config.json new file mode 100644 index 0000000..fde829b --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/config.json @@ -0,0 +1,165 @@ +{ + "id": "user_1770335603", + "name": "발표안", + "icon": "📄", + "description": "발표안", + "features": [ + { + "icon": "📋", + "text": "발표 Ʞ획서" + }, + { + "icon": "🎯", + "text": "특정 죌제에 대한 발표륌 첎..." + }, + { + "icon": "👥", + "text": "상위 결재자 또는 발표 승읞권자" + }, + { + "icon": "📄", + "text": "앜 2p" + } + ], + "thumbnailType": "custom", + "enabled": true, + "isDefault": false, + "order": 100, + "template_id": "tpl_1770335603", + "context": { + "documentDefinition": "발표륌 하Ʞ 위한 Ʞ획서", + "documentType": "발표 Ʞ획서", + "purpose": "특정 죌제에 대한 발표륌 첎계적윌로 쀀비하고 발표 낎용곌 구성을 사전에 계획하Ʞ 위핚", + "perspective": "발표할 낎용을 녌늬적윌로 구성하고, 청쀑에게 전달할 핵심 메시지와 시연 방식을 명확히 정늬하는 ꎀ점", + "audience": "상위 결재자 또는 발표 승읞권자", + "tone": "제안형" + }, + "layout": { + "hasHeader": true, + "headerLayout": { + "structure": "테읎랔", + "colCount": 3, + "rowCount": 1, + "cellTexts": [ + "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "", + "2025. 2. 5(목)" + ], + "cellLines": [ + [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ], + [], + [ + "2025. 2. 5(목)" + ] + ] + }, + "hasFooter": true, + "footerLayout": { + "structure": "테읎랔", + "colCount": 3, + "rowCount": 1, + "cellTexts": [ + "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "", + "" + ], + "cellLines": [ + [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ], + [], + [] + ] + }, + "titleBlock": { + "type": "테읎랔", + "colCount": 2, + "text": "AI 업묎 활용 적용 사례 발표 계획(안)" + }, + "sections": [ + { + "name": "개요", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "발표 구성(안)", + "hasBulletIcon": true, + "hasTable": false, + "tableIndex": null + }, + { + "name": "발표 낎용", + "hasBulletIcon": true, + "hasTable": true, + "tableIndex": 0 + } + ], + "overallStyle": { + "writingStyle": "개조식", + "bulletType": "-", + "tableUsage": "볎통" + } + }, + "structure": { + "sectionGuides": [ + { + "name": "개요", + "role": "발표의 목적곌 배겜을 명확히 제시하여 청쀑의 읎핎륌 돕는 도입부", + "writingStyle": "개조식", + "contentGuide": "발표 죌제, 목적, 대상 청쀑, 핵심 메시지륌 간결한 불늿 포읞튞로 정늬. 청쀑읎 발표 전반을 믞늬 파악할 수 있도록 핵심 낎용을 요앜하여 제시", + "hasTable": false + }, + { + "name": "발표 구성(안)", + "role": "발표의 전첎적읞 흐늄곌 구조륌 제시하여 첎계적읞 진행을 안낎", + "writingStyle": "개조식", + "contentGuide": "발표 제목, 죌요 섹션별 순서와 낎용을 계잵적윌로 구성. 발표자와 청쀑 몚두가 발표 흐늄을 명확히 읎핎할 수 있도록 녌늬적 순서로 ë°°ì—Ž", + "hasTable": false + }, + { + "name": "발표 낎용", + "role": "발표의 섞부 낎용곌 시간 배분을 구첎적윌로 계획하는 싀행 가읎드", + "writingStyle": "개조식", + "contentGuide": "각 발표 닚계별로 구첎적읞 낎용곌 소요 시간, 진행 방식을 상섞히 Ʞ술. 표 형태로 첎계적윌로 정늬하여 발표 쀀비와 진행을 횚윚적윌로 ꎀ늬", + "hasTable": true, + "tableStructure": { + "columns": 3, + "columnDefs": [ + { + "name": "구분", + "role": "발표 닚계나 죌제 영역을 분류", + "style": "간결한 킀워드나 닚계명" + }, + { + "name": "낎용", + "role": "각 닚계별 구첎적읞 발표 낎용곌 활동을 상섞 Ʞ술", + "style": "구첎적읞 섀명곌 섞부 항목을 불늿 포읞튞로 나엎" + }, + { + "name": "비고", + "role": "예상 소요 시간, 쀀비사항, 죌의점 등 부가 정볎 제공", + "style": "시간, 페읎지 수, 특읎사항 등을 간략하게 표시" + } + ], + "rowGuide": "각 행은 발표의 한 닚계나 죌제륌 나타낎며, 녌늬적 순서에 따띌 ë°°ì—Ž. 도입부터 마묎늬까지 발표 전첎 흐늄을 포ꎄ" + } + } + ], + "writingPrinciples": [ + "발표 목적곌 청쀑을 명확히 섀정하고 읎에 맞는 낎용 구성", + "개조식 묞첎로 핵심 낎용을 간결하고 명확하게 표현", + "녌늬적 순서와 첎계적 구조로 발표 흐늄을 섀계", + "구첎적읞 시간 배분곌 쀀비사항을 포핚하여 싀행 가능한 계획 수늜" + ], + "pageEstimate": 2 + }, + "options": {}, + "createdAt": "2026-02-06T08:53:23Z", + "updatedAt": "2026-02-06T08:53:23Z" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/content_prompt.json b/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/content_prompt.json new file mode 100644 index 0000000..0be201c --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/doc_types/user_1770335603/content_prompt.json @@ -0,0 +1,267 @@ +{ + "version": "1.0", + "document": { + "paper": "A4", + "layout": "landscape", + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + }, + "purpose_hint": "", + "audience_hint": "", + "tone_hint": "" + }, + "placeholders": { + "HEADER_R1_C1_LINE_1": { + "type": "department", + "pattern": "조직명", + "example": "쎝ꎄꞰ획싀", + "location": "header" + }, + "HEADER_R1_C1_LINE_2": { + "type": "team", + "pattern": "팀명", + "example": "Ʞ술Ʞ획팀", + "location": "header" + }, + "HEADER_R1_C2": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "header" + }, + "HEADER_R1_C3": { + "type": "date", + "pattern": "날짜 (YYYY. M. D)", + "example": "2025. 2. 5(목)", + "location": "header" + }, + "FOOTER_R1_C1_LINE_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "Ʞ술 로 사람 곌 자연 읎", + "location": "footer" + }, + "FOOTER_R1_C1_LINE_2": { + "type": "slogan", + "pattern": "회사 슬로걎/비전", + "example": "핚께하는 섞상을 만듀얎 갑니닀.", + "location": "footer" + }, + "FOOTER_R1_C2": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "footer" + }, + "FOOTER_R1_C3": { + "type": "empty", + "pattern": "빈 셀 (로고/여백)", + "example": "", + "location": "footer" + }, + "TITLE_R1_C2": { + "type": "doc_title", + "pattern": "묞서 제목", + "example": "AI 업묎 활용 적용 사례 발표 계획(안)", + "location": "title_block" + }, + "SECTION_1_TITLE": { + "type": "section_title", + "pattern": "섹션 제목", + "example": "", + "location": "body" + }, + "IMAGE_1": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image1", + "location": "body" + }, + "IMAGE_1_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 개요", + "location": "body" + }, + "IMAGE_2": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_2_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " AI륌 활용한 “업묎 횚윚성 개선 사례”와 읎륌 구현한 방식에 대한 공유", + "location": "body" + }, + "PARA_1": { + "type": "text", + "pattern": "자유 텍슀튞", + "example": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연", + "location": "body" + }, + "IMAGE_3": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image1", + "location": "body" + }, + "IMAGE_3_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 발표 구성(안)", + "location": "body" + }, + "IMAGE_4": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_4_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 제목 : AI 활용 묞서 업묎 개선 사례 -「Ꞁ벗」(사용자의 Ꞁ쓰Ʞ륌 돕는 친구) -", + "location": "body" + }, + "IMAGE_5": { + "type": "image", + "pattern": "읎믞지", + "example_ref": "image2", + "location": "body" + }, + "IMAGE_5_CAPTION": { + "type": "image_caption", + "pattern": "읎믞지 캡션", + "example": " 발표 낎용 ", + "location": "body" + }, + "TABLE_1_H_C1": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "구분", + "location": "table_1" + }, + "TABLE_1_H_C2": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "낎용", + "location": "table_1" + }, + "TABLE_1_H_C3": { + "type": "table_header", + "pattern": "표 ì—Ž 제목", + "example": "비고", + "location": "table_1" + }, + "TABLE_1_BODY": { + "type": "table_body", + "pattern": "표 데읎터 행듀 (HTML
                                        반복)", + "example": "", + "location": "table_1" + } + }, + "table_guide": { + "1": { + "col_headers": [ + "구분", + "낎용", + "비고" + ], + "col_count": 3, + "row_count": 5, + "merge_pattern": { + "col_0": "col_span", + "col_3": "row_group" + }, + "bullet_chars": [ + "- ", + "· " + ], + "example_rows": [ + [ + "소개", + "개요", + "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입 · 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등) ...", + "1p" + ], + [ + "Ꞁ벗 소개", + "- Ꞁ벗 Ʞ능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ ·..." + ], + [ + "시연", + "Ꞁ벗 시연", + "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성 - (Ʞ능 2) (Process) 웹 펞집Ʞ - (Ʞ능 3) (Exp...", + "Ꞁ벗 & Visual Studio" + ] + ], + "col_types": [ + { + "col": 0, + "type": "category", + "header": "구분" + }, + { + "col": 1, + "type": "content", + "header": "낎용" + }, + { + "col": 2, + "type": "note", + "header": "비고" + } + ], + "row_bf_pattern": [ + { + "col": 0, + "bf_class": "bf-12", + "colSpan": 1, + "rowSpan": 2 + }, + { + "col": 1, + "bf_class": "bf-8", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 2, + "bf_class": "bf-7", + "colSpan": 1, + "rowSpan": 1 + }, + { + "col": 3, + "bf_class": "bf-19", + "colSpan": 1, + "rowSpan": 2 + } + ] + } + }, + "writing_guide": { + "bullet_styles": [ + "- ", + "· " + ], + "numbering_patterns": [ + [ + "^1.", + "^2.", + "^3)" + ] + ], + "avg_line_length": 16, + "font_primary": "돋움", + "font_size_body": "10.0pt" + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/meta.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/meta.json new file mode 100644 index 0000000..d2d331d --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/meta.json @@ -0,0 +1,15 @@ +{ + "id": "tpl_1770333144", + "name": "발표자료 양식", + "original_file": "sample.hwpx", + "file_type": ".hwpx", + "description": "발표자료에서 추출한 묞서 양식", + "features": [ + "폰튾: 돋움", + "뚞늿말: 3ì—Ž", + "ꌬ늿말: 3ì—Ž", + "표: 5x4" + ], + "created_at": "2026-02-06T08:12:24Z", + "source": "doc_template_analyzer" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/semantic_map.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/semantic_map.json new file mode 100644 index 0000000..ec0ff4d --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/semantic_map.json @@ -0,0 +1,222 @@ +{ + "version": "1.0", + "table_roles": { + "0": { + "role": "footer_table", + "match_source": "footer", + "matched_texts": [ + "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + "1": { + "role": "header_table", + "match_source": "header", + "matched_texts": [ + "2025. 2. 5(목)", + "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀" + ] + }, + "2": { + "role": "title_block", + "title_text": "AI 업묎 활용 적용 사례 발표 계획(안)" + }, + "3": { + "role": "data_table", + "header_row": 0, + "col_headers": [ + "구분", + "낎용", + "비고" + ], + "row_count": 5, + "col_count": 4 + } + }, + "body_tables": [ + 3 + ], + "title_table": 2, + "sections": [], + "style_mappings": { + "char_pr": {}, + "border_fill": { + "1": { + "css_class": "bf-1", + "bg": "", + "borders": {} + }, + "2": { + "css_class": "bf-2", + "bg": "", + "borders": {} + }, + "3": { + "css_class": "bf-3", + "bg": "", + "borders": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "css_class": "bf-4", + "bg": "", + "borders": { + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "css_class": "bf-5", + "bg": "", + "borders": {} + }, + "6": { + "css_class": "bf-6", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "css_class": "bf-7", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "css_class": "bf-8", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "9": { + "css_class": "bf-9", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "10": { + "css_class": "bf-10", + "bg": "#DCDCDC", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "11": { + "css_class": "bf-11", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "12": { + "css_class": "bf-12", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "13": { + "css_class": "bf-13", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "14": { + "css_class": "bf-14", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "15": { + "css_class": "bf-15", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "16": { + "css_class": "bf-16", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "17": { + "css_class": "bf-17", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "css_class": "bf-18", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "css_class": "bf-19", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "css_class": "bf-20", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "para_pr": {} + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/style.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/style.json new file mode 100644 index 0000000..5928bae --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/style.json @@ -0,0 +1,4688 @@ +{ + "version": "v4", + "source": "doc_template_analyzer", + "template_info": { + "page": { + "paper": { + "name": "A4", + "width_mm": 210.0, + "height_mm": 297.0, + "landscape": true + }, + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + } + }, + "fonts": { + "HANGUL": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양견명조", + "type": "HFT" + }, + { + "id": 6, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 7, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 8, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "LATIN": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "한양견명조", + "type": "HFT" + } + ], + "HANJA": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "JAPANESE": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "OTHER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "한양신명조", + "type": "HFT" + } + ], + "SYMBOL": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕330", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "USER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "명조", + "type": "HFT" + } + ] + }, + "char_styles": [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 1, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 2, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 3, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 4, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 5, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 6, + "latin": 5, + "hanja": 5, + "japanese": 5, + "other": 5, + "symbol": 5, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 6, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 7, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 8, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 9, + "height_pt": 8.0, + "textColor": "#0000FF", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 10, + "height_pt": 8.0, + "textColor": "#FF0000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 11, + "height_pt": 8.0, + "textColor": "#008000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 12, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 90, + "latin": 90, + "hanja": 90, + "japanese": 90, + "other": 90, + "symbol": 90, + "user": 90 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 13, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 14, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 15, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 16, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 17, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 18, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 8, + "latin": 7, + "hanja": 7, + "japanese": 7, + "other": 6, + "symbol": 7, + "user": 6 + }, + "ratio": { + "hangul": 98, + "latin": 98, + "hanja": 98, + "japanese": 98, + "other": 98, + "symbol": 98, + "user": 98 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 19, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 20, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 21, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 22, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 23, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -10, + "latin": -10, + "hanja": -10, + "japanese": -10, + "other": -10, + "symbol": -10, + "user": -10 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 24, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -17, + "latin": -17, + "hanja": -17, + "japanese": -17, + "other": -17, + "symbol": -17, + "user": -17 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 25, + "height_pt": 16.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 5, + "latin": 8, + "hanja": 8, + "japanese": 8, + "other": 7, + "symbol": 8, + "user": 7 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 26, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 0, + "latin": 0, + "hanja": 1, + "japanese": 1, + "other": 1, + "symbol": 1, + "user": 1 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + } + ], + "para_styles": [ + { + "id": 0, + "tabPrIDRef": 1, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 130 + }, + "borderFillIDRef": 2 + }, + { + "id": 1, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 2, + "tabPrIDRef": 2, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 150 + }, + "borderFillIDRef": 2 + }, + { + "id": 3, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 4, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 5, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 6, + "tabPrIDRef": 0, + "align": "RIGHT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 7, + "tabPrIDRef": 4, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 100 + }, + "borderFillIDRef": 2 + }, + { + "id": 8, + "tabPrIDRef": 0, + "align": "DISTRIBUTE", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 500, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 9, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 10, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 11, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 12, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 13, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 200, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 14, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 600, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 155 + }, + "borderFillIDRef": 1 + }, + { + "id": 15, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1200, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 16, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 17, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1396, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 18, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 19, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1000, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 20, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 1 + }, + { + "id": 21, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 852 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 1 + }, + { + "id": 22, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + } + ], + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [ + { + "index": 0, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "charPrIDRefs": [ + 9, + 8, + 10, + 8, + 11, + 8, + 8 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 12, + 8 + ], + "primaryCharPrIDRef": 12, + "paraPrIDRefs": [ + 6, + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0, + 0 + ] + } + ] + ], + "colWidths_hu": [ + 16723, + 2856, + 28043 + ], + "colWidths_pct": [ + 35, + 6, + 59 + ] + }, + { + "index": 1, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "charPrIDRefs": [ + 7, + 8 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "2025. 2. 5(목)", + "charPrIDRefs": [ + 7 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0 + ], + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ], + "colWidths_hu": [ + 11912, + 7950, + 27760 + ], + "colWidths_pct": [ + 25, + 17, + 58 + ] + }, + { + "index": 2, + "rowCnt": 1, + "colCnt": 2, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 773, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47185, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "text": "AI 업묎 활용 적용 사례 발표 계획(안)", + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ], + "lines": [ + "AI 업묎 활용 적용 사례 발표 계획(안)" + ] + } + ] + ], + "colWidths_hu": [ + 773, + 47185 + ], + "colWidths_pct": [ + 2, + 98 + ] + }, + { + "index": 3, + "rowCnt": 5, + "colCnt": 4, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 10, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 2, + "rowSpan": 1, + "width_hu": 14354, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "구분", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "구분" + ] + }, + { + "borderFillIDRef": 13, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "낎용", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "낎용" + ] + }, + { + "borderFillIDRef": 14, + "isHeader": false, + "colAddr": 3, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 5392, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "비고", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "비고" + ] + } + ], + [ + { + "borderFillIDRef": 12, + "isHeader": false, + "colAddr": 0, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "소개" + ] + }, + { + "borderFillIDRef": 8, + "isHeader": false, + "colAddr": 1, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "개요", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "개요" + ] + }, + { + "borderFillIDRef": 7, + "isHeader": false, + "colAddr": 2, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입 · 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등) · 발죌처별 상읎한 양식곌 확장자로 재펞집 - Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감", + "charPrIDRefs": [ + 24, + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0, + 0 + ], + "lines": [ + "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입", + "· 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등)", + "· 발죌처별 상읎한 양식곌 확장자로 재펞집", + "- Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감" + ] + }, + { + "borderFillIDRef": 19, + "isHeader": false, + "colAddr": 3, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "1p", + "charPrIDRefs": [ + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "1p" + ] + } + ], + [ + { + "borderFillIDRef": 16, + "isHeader": false, + "colAddr": 1, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 소개" + ] + }, + { + "borderFillIDRef": 18, + "isHeader": false, + "colAddr": 2, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- Ꞁ벗 Ʞ능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ · (Export) 읞쇄, PDF, HWP", + "charPrIDRefs": [ + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0 + ], + "lines": [ + "- Ꞁ벗 Ʞ능 소개", + "· (Input) 로컬, 링크, HTML 구조", + "· (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ", + "· (Export) 읞쇄, PDF, HWP" + ] + } + ], + [ + { + "borderFillIDRef": 11, + "isHeader": false, + "colAddr": 0, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "시연" + ] + }, + { + "borderFillIDRef": 15, + "isHeader": false, + "colAddr": 1, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 시연" + ] + }, + { + "borderFillIDRef": 17, + "isHeader": false, + "colAddr": 2, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성 - (Ʞ능 2) (Process) 웹 펞집Ʞ - (Ʞ능 3) (Export) PDF와 HWP 추출", + "charPrIDRefs": [ + 24, + 23, + 24, + 23, + 24, + 23 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0 + ], + "lines": [ + "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성", + "- (Ʞ능 2) (Process) 웹 펞집Ʞ", + "- (Ʞ능 3) (Export) PDF와 HWP 추출" + ] + }, + { + "borderFillIDRef": 20, + "isHeader": false, + "colAddr": 3, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 & Visual Studio", + "charPrIDRefs": [ + 23, + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 &", + "Visual Studio" + ] + } + ], + [ + { + "borderFillIDRef": 9, + "isHeader": false, + "colAddr": 1, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 만드는 곌정", + "charPrIDRefs": [ + 20, + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 만드는", + "곌정" + ] + }, + { + "borderFillIDRef": 6, + "isHeader": false, + "colAddr": 2, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "AI에게 활용할 자료 제공하Ʞ AI륌 활용하여 윔딩하Ʞ", + "charPrIDRefs": [ + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 16, + 16 + ], + "primaryParaPrIDRef": 16, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "AI에게 활용할 자료 제공하Ʞ", + "AI륌 활용하여 윔딩하Ʞ" + ] + } + ] + ], + "colWidths_hu": [ + 5054, + 9300, + 27183, + 5392 + ], + "colWidths_pct": [ + 11, + 20, + 58, + 11 + ] + } + ], + "header": { + "exists": true, + "hidden": false, + "texts": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀", + "2025. 2. 5(목)" + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "text": "2025. 2. 5(목)", + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ] + } + }, + "footer": { + "exists": true, + "hidden": false, + "texts": [ + "Ʞ술", + "로", + "사람", + "곌", + "자연", + "읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043 + } + ] + ] + } + }, + "section": { + "textDirection": "HORIZONTAL", + "hideFirstHeader": false, + "hideFirstFooter": false, + "hideFirstMasterPage": false, + "hideFirstPageNum": false, + "hideFirstEmptyLine": false, + "startNum": { + "pageStartsOn": "BOTH", + "page": 0 + } + }, + "styles": [ + { + "id": 0, + "paraPrIDRef": 3, + "charPrIDRef": 0, + "nextStyleIDRef": 0, + "type": "PARA", + "name": "바탕Ꞁ", + "engName": "Normal" + }, + { + "id": 1, + "paraPrIDRef": 2, + "charPrIDRef": 3, + "nextStyleIDRef": 1, + "type": "PARA", + "name": "뚞늬말", + "engName": "Header" + }, + { + "id": 2, + "paraPrIDRef": 1, + "charPrIDRef": 2, + "nextStyleIDRef": 2, + "type": "PARA", + "name": "쪜 번혞", + "engName": "Page Number" + }, + { + "id": 3, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 3, + "type": "PARA", + "name": "각죌", + "engName": "Footnote" + }, + { + "id": 4, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 4, + "type": "PARA", + "name": "믞죌", + "engName": "Endnote" + }, + { + "id": 5, + "paraPrIDRef": 4, + "charPrIDRef": 6, + "nextStyleIDRef": 5, + "type": "PARA", + "name": "표위", + "engName": "Memo" + }, + { + "id": 6, + "paraPrIDRef": 8, + "charPrIDRef": 0, + "nextStyleIDRef": 6, + "type": "PARA", + "name": "표옆", + "engName": "" + }, + { + "id": 7, + "paraPrIDRef": 10, + "charPrIDRef": 0, + "nextStyleIDRef": 7, + "type": "PARA", + "name": "표낎용", + "engName": "" + }, + { + "id": 8, + "paraPrIDRef": 9, + "charPrIDRef": 13, + "nextStyleIDRef": 8, + "type": "PARA", + "name": "죌)", + "engName": "" + }, + { + "id": 9, + "paraPrIDRef": 14, + "charPrIDRef": 18, + "nextStyleIDRef": 9, + "type": "PARA", + "name": "#큰아읎윘", + "engName": "" + }, + { + "id": 10, + "paraPrIDRef": 21, + "charPrIDRef": 25, + "nextStyleIDRef": 10, + "type": "PARA", + "name": "개요1", + "engName": "" + }, + { + "id": 11, + "paraPrIDRef": 20, + "charPrIDRef": 26, + "nextStyleIDRef": 11, + "type": "PARA", + "name": "xl63", + "engName": "xl63" + } + ], + "numbering": { + "numberings": [ + { + "id": 1, + "start": 0, + "levels": [ + { + "level": 1, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^1." + }, + { + "level": 2, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^2." + }, + { + "level": 3, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^3)" + }, + { + "level": 4, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^4)" + }, + { + "level": 5, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "(^5)" + }, + { + "level": 6, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "(^6)" + }, + { + "level": 7, + "numFormat": "CIRCLED_DIGIT", + "align": "LEFT", + "pattern": "^7" + } + ] + } + ], + "bullets": [ + { + "id": 1, + "char": "-", + "useImage": false + } + ] + }, + "images": [ + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + } + ], + "content_order": [ + { + "index": 0, + "paraPrIDRef": "7", + "styleIDRef": "0", + "type": "table", + "table_idx": 0, + "rowCnt": "1", + "colCnt": "2", + "borderFillIDRef": "3" + }, + { + "index": 1, + "paraPrIDRef": "17", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 2, + "paraPrIDRef": "15", + "styleIDRef": "9", + "type": "image", + "image_idx": 0, + "binaryItemIDRef": "image1", + "text": " 개요", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 3, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 1, + "binaryItemIDRef": "image2", + "text": " AI륌 활용한 “업묎 횚윚성 개선 사례”와 읎륌 구현한 방식에 대한 공유", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 4, + "paraPrIDRef": "18", + "styleIDRef": "7", + "type": "paragraph", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연", + "charPrIDRef": "22", + "runs": [ + { + "charPrIDRef": "22", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연" + } + ] + }, + { + "index": 5, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 6, + "paraPrIDRef": "19", + "styleIDRef": "9", + "type": "image", + "image_idx": 2, + "binaryItemIDRef": "image1", + "text": " 발표 구성(안)", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 7, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 3, + "binaryItemIDRef": "image2", + "text": " 제목 : AI 활용 묞서 업묎 개선 사례 -「Ꞁ벗」(사용자의 Ꞁ쓰Ʞ륌 돕는 친구) -", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 8, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 4, + "binaryItemIDRef": "image2", + "text": " 발표 낎용 ", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 9, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "table", + "table_idx": 1, + "rowCnt": "5", + "colCnt": "4", + "borderFillIDRef": "3" + }, + { + "index": 10, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 11, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 12, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + } + ] + }, + "css": "", + "fonts": {}, + "colors": { + "background": [ + "#EDEDED", + "#DCDCDC" + ], + "border": [ + "#000000", + "#3057B9", + "#999999", + "#BBBBBB" + ], + "text": [] + }, + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [], + "style_summary": {} +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/template.html b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/template.html new file mode 100644 index 0000000..d7ef470 --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770333144/template.html @@ -0,0 +1,590 @@ + + + + +Template + + + +
                                        + +
                                        +
                                        ++ + + + + + + + + +
                                        {{HEADER_R1_C1_LINE_1}}
                                        {{HEADER_R1_C1_LINE_2}}
                                        {{HEADER_R1_C3}}
                                        + + +
                                        + ++ + + + + + + +
                                        {{TITLE_R1_C2}}
                                        +
                                        + + +
                                        + {{IMAGE_1}} +

                                        {{IMAGE_1_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_2}} +

                                        {{IMAGE_2_CAPTION}}

                                        +
                                        + +

                                        {{PARA_1}}

                                        + +
                                        + {{IMAGE_3}} +

                                        {{IMAGE_3_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_4}} +

                                        {{IMAGE_4_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_5}} +

                                        {{IMAGE_5_CAPTION}}

                                        +
                                        + + ++ + + + + + + + + + + + + + {{TABLE_1_BODY}} + +
                                        {{TABLE_1_H_C1}}{{TABLE_1_H_C2}}{{TABLE_1_H_C3}}
                                        + + + + + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/meta.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/meta.json new file mode 100644 index 0000000..e20f943 --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/meta.json @@ -0,0 +1,15 @@ +{ + "id": "tpl_1770335603", + "name": "발표안 양식", + "original_file": "sample.hwpx", + "file_type": ".hwpx", + "description": "발표안에서 추출한 묞서 양식", + "features": [ + "폰튾: 돋움", + "뚞늿말: 3ì—Ž", + "ꌬ늿말: 3ì—Ž", + "표: 5x4" + ], + "created_at": "2026-02-06T08:53:23Z", + "source": "doc_template_analyzer" +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/semantic_map.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/semantic_map.json new file mode 100644 index 0000000..ec0ff4d --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/semantic_map.json @@ -0,0 +1,222 @@ +{ + "version": "1.0", + "table_roles": { + "0": { + "role": "footer_table", + "match_source": "footer", + "matched_texts": [ + "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + "1": { + "role": "header_table", + "match_source": "header", + "matched_texts": [ + "2025. 2. 5(목)", + "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀" + ] + }, + "2": { + "role": "title_block", + "title_text": "AI 업묎 활용 적용 사례 발표 계획(안)" + }, + "3": { + "role": "data_table", + "header_row": 0, + "col_headers": [ + "구분", + "낎용", + "비고" + ], + "row_count": 5, + "col_count": 4 + } + }, + "body_tables": [ + 3 + ], + "title_table": 2, + "sections": [], + "style_mappings": { + "char_pr": {}, + "border_fill": { + "1": { + "css_class": "bf-1", + "bg": "", + "borders": {} + }, + "2": { + "css_class": "bf-2", + "bg": "", + "borders": {} + }, + "3": { + "css_class": "bf-3", + "bg": "", + "borders": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "css_class": "bf-4", + "bg": "", + "borders": { + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "css_class": "bf-5", + "bg": "", + "borders": {} + }, + "6": { + "css_class": "bf-6", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "css_class": "bf-7", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "css_class": "bf-8", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "9": { + "css_class": "bf-9", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "10": { + "css_class": "bf-10", + "bg": "#DCDCDC", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "11": { + "css_class": "bf-11", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "12": { + "css_class": "bf-12", + "bg": "#EDEDED", + "borders": { + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "13": { + "css_class": "bf-13", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "14": { + "css_class": "bf-14", + "bg": "#DCDCDC", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "15": { + "css_class": "bf-15", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "16": { + "css_class": "bf-16", + "bg": "#EDEDED", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "17": { + "css_class": "bf-17", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "css_class": "bf-18", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "css_class": "bf-19", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "css_class": "bf-20", + "bg": "", + "borders": { + "border-left": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "para_pr": {} + } +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/style.json b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/style.json new file mode 100644 index 0000000..5928bae --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/style.json @@ -0,0 +1,4688 @@ +{ + "version": "v4", + "source": "doc_template_analyzer", + "template_info": { + "page": { + "paper": { + "name": "A4", + "width_mm": 210.0, + "height_mm": 297.0, + "landscape": true + }, + "margins": { + "top": "10.0mm", + "bottom": "10.0mm", + "left": "20.0mm", + "right": "20.0mm", + "header": "15.0mm", + "footer": "15.0mm", + "gutter": "0.0mm" + } + }, + "fonts": { + "HANGUL": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양견명조", + "type": "HFT" + }, + { + "id": 6, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 7, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 8, + "face": "-윀고딕130", + "type": "TTF" + } + ], + "LATIN": [ + { + "id": 0, + "face": "돋움", + "type": "TTF" + }, + { + "id": 1, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "한양견명조", + "type": "HFT" + } + ], + "HANJA": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "JAPANESE": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "OTHER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "한양신명조", + "type": "HFT" + } + ], + "SYMBOL": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "한양쀑고딕", + "type": "HFT" + }, + { + "id": 6, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 7, + "face": "-윀고딕330", + "type": "TTF" + }, + { + "id": 8, + "face": "신명 견명조", + "type": "HFT" + } + ], + "USER": [ + { + "id": 0, + "face": "맑은 고딕", + "type": "TTF" + }, + { + "id": 1, + "face": "한컎바탕", + "type": "TTF" + }, + { + "id": 2, + "face": "휎뚌고딕", + "type": "TTF" + }, + { + "id": 3, + "face": "휎뚌명조", + "type": "TTF" + }, + { + "id": 4, + "face": "HY헀드띌읞M", + "type": "TTF" + }, + { + "id": 5, + "face": "나눔명조", + "type": "TTF" + }, + { + "id": 6, + "face": "-윀고딕130", + "type": "TTF" + }, + { + "id": 7, + "face": "명조", + "type": "HFT" + } + ] + }, + "char_styles": [ + { + "id": 0, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 1, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 2, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 3, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 1, + "latin": 1, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 4, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 5, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 6, + "latin": 5, + "hanja": 5, + "japanese": 5, + "other": 5, + "symbol": 5, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 6, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 7, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 8, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 9, + "height_pt": 8.0, + "textColor": "#0000FF", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 10, + "height_pt": 8.0, + "textColor": "#FF0000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 11, + "height_pt": 8.0, + "textColor": "#008000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 12, + "height_pt": 8.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 2, + "latin": 2, + "hanja": 2, + "japanese": 2, + "other": 2, + "symbol": 2, + "user": 2 + }, + "ratio": { + "hangul": 90, + "latin": 90, + "hanja": 90, + "japanese": 90, + "other": 90, + "symbol": 90, + "user": 90 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 13, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 14, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 15, + "height_pt": 15.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 4, + "latin": 4, + "hanja": 4, + "japanese": 4, + "other": 4, + "symbol": 4, + "user": 4 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 16, + "height_pt": 9.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 17, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 18, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 8, + "latin": 7, + "hanja": 7, + "japanese": 7, + "other": 6, + "symbol": 7, + "user": 6 + }, + "ratio": { + "hangul": 98, + "latin": 98, + "hanja": 98, + "japanese": 98, + "other": 98, + "symbol": 98, + "user": 98 + }, + "spacing": { + "hangul": -5, + "latin": -5, + "hanja": -5, + "japanese": -5, + "other": -5, + "symbol": -5, + "user": -5 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 19, + "height_pt": 13.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 20, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": true, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 21, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 22, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 3, + "latin": 3, + "hanja": 3, + "japanese": 3, + "other": 3, + "symbol": 3, + "user": 3 + }, + "ratio": { + "hangul": 95, + "latin": 95, + "hanja": 95, + "japanese": 95, + "other": 95, + "symbol": 95, + "user": 95 + }, + "spacing": { + "hangul": -2, + "latin": -2, + "hanja": -2, + "japanese": -2, + "other": -2, + "symbol": -2, + "user": -2 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 23, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -10, + "latin": -10, + "hanja": -10, + "japanese": -10, + "other": -10, + "symbol": -10, + "user": -10 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 24, + "height_pt": 10.0, + "textColor": "#000000", + "borderFillIDRef": 2, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 7, + "latin": 6, + "hanja": 6, + "japanese": 6, + "other": 5, + "symbol": 6, + "user": 5 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": -17, + "latin": -17, + "hanja": -17, + "japanese": -17, + "other": -17, + "symbol": -17, + "user": -17 + }, + "underline": "NONE", + "strikeout": "3D" + }, + { + "id": 25, + "height_pt": 16.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 5, + "latin": 8, + "hanja": 8, + "japanese": 8, + "other": 7, + "symbol": 8, + "user": 7 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + }, + { + "id": 26, + "height_pt": 11.0, + "textColor": "#000000", + "borderFillIDRef": 1, + "bold": false, + "italic": false, + "fontRef": { + "hangul": 0, + "latin": 0, + "hanja": 1, + "japanese": 1, + "other": 1, + "symbol": 1, + "user": 1 + }, + "ratio": { + "hangul": 100, + "latin": 100, + "hanja": 100, + "japanese": 100, + "other": 100, + "symbol": 100, + "user": 100 + }, + "spacing": { + "hangul": 0, + "latin": 0, + "hanja": 0, + "japanese": 0, + "other": 0, + "symbol": 0, + "user": 0 + }, + "underline": "NONE", + "strikeout": "NONE" + } + ], + "para_styles": [ + { + "id": 0, + "tabPrIDRef": 1, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1310, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 130 + }, + "borderFillIDRef": 2 + }, + { + "id": 1, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 2, + "tabPrIDRef": 2, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 150 + }, + "borderFillIDRef": 2 + }, + { + "id": 3, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 4, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 5, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 6, + "tabPrIDRef": 0, + "align": "RIGHT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 110 + }, + "borderFillIDRef": 2 + }, + { + "id": 7, + "tabPrIDRef": 4, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 100 + }, + "borderFillIDRef": 2 + }, + { + "id": 8, + "tabPrIDRef": 0, + "align": "DISTRIBUTE", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 500, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 9, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 10, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 11, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 12, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": -1223, + "left_hu": 500, + "right_hu": 0, + "before_hu": 0, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 170 + }, + "borderFillIDRef": 2 + }, + { + "id": 13, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 200, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 14, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 600, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 155 + }, + "borderFillIDRef": 1 + }, + { + "id": 15, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1200, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 16, + "tabPrIDRef": 0, + "align": "LEFT", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 2 + }, + { + "id": 17, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": -1396, + "left_hu": 800, + "right_hu": 0, + "before_hu": 0, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 18, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "BULLET", + "idRef": 1, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + }, + { + "id": 19, + "tabPrIDRef": 3, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 1000, + "after_hu": 500 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 2 + }, + { + "id": 20, + "tabPrIDRef": 0, + "align": "CENTER", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 0 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 160 + }, + "borderFillIDRef": 1 + }, + { + "id": 21, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "KEEP_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 0, + "right_hu": 0, + "before_hu": 0, + "after_hu": 852 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 180 + }, + "borderFillIDRef": 1 + }, + { + "id": 22, + "tabPrIDRef": 0, + "align": "JUSTIFY", + "verticalAlign": "BASELINE", + "heading": { + "type": "NONE", + "idRef": 0, + "level": 0 + }, + "breakSetting": { + "widowOrphan": false, + "keepWithNext": false, + "keepLines": false, + "pageBreakBefore": false, + "lineWrap": "BREAK", + "breakLatinWord": "KEEP_WORD", + "breakNonLatinWord": "BREAK_WORD" + }, + "margin": { + "indent_hu": 0, + "left_hu": 800, + "right_hu": 0, + "before_hu": 500, + "after_hu": 300 + }, + "lineSpacing": { + "type": "PERCENT", + "value": 140 + }, + "borderFillIDRef": 2 + } + ], + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [ + { + "index": 0, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "charPrIDRefs": [ + 9, + 8, + 10, + 8, + 11, + 8, + 8 + ], + "primaryCharPrIDRef": 9, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 12, + 8 + ], + "primaryCharPrIDRef": 12, + "paraPrIDRefs": [ + 6, + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0, + 0 + ] + } + ] + ], + "colWidths_hu": [ + 16723, + 2856, + 28043 + ], + "colWidths_pct": [ + 35, + 6, + 59 + ] + }, + { + "index": 1, + "rowCnt": 1, + "colCnt": 3, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "charPrIDRefs": [ + 7, + 8 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 5, + 5 + ], + "primaryParaPrIDRef": 5, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "charPrIDRefs": [ + 8 + ], + "primaryCharPrIDRef": 8, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 5, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "height_hu": 282, + "cellMargin": { + "left": 510, + "right": 510, + "top": 141, + "bottom": 141 + }, + "text": "2025. 2. 5(목)", + "charPrIDRefs": [ + 7 + ], + "primaryCharPrIDRef": 7, + "paraPrIDRefs": [ + 6 + ], + "primaryParaPrIDRef": 6, + "styleIDRefs": [ + 0 + ], + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ], + "colWidths_hu": [ + 11912, + 7950, + 27760 + ], + "colWidths_pct": [ + 25, + 17, + 58 + ] + }, + { + "index": 2, + "rowCnt": 1, + "colCnt": 2, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 773, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ] + }, + { + "borderFillIDRef": 4, + "isHeader": false, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 47185, + "height_hu": 0, + "cellMargin": { + "left": 141, + "right": 141, + "top": 283, + "bottom": 567 + }, + "text": "AI 업묎 활용 적용 사례 발표 계획(안)", + "charPrIDRefs": [ + 4 + ], + "primaryCharPrIDRef": 4, + "paraPrIDRefs": [ + 3 + ], + "primaryParaPrIDRef": 3, + "styleIDRefs": [ + 0 + ], + "lines": [ + "AI 업묎 활용 적용 사례 발표 계획(안)" + ] + } + ] + ], + "colWidths_hu": [ + 773, + 47185 + ], + "colWidths_pct": [ + 2, + 98 + ] + }, + { + "index": 3, + "rowCnt": 5, + "colCnt": 4, + "repeatHeader": true, + "pageBreak": "CELL", + "rows": [ + [ + { + "borderFillIDRef": 10, + "isHeader": false, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 2, + "rowSpan": 1, + "width_hu": 14354, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "구분", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "구분" + ] + }, + { + "borderFillIDRef": 13, + "isHeader": false, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "낎용", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "낎용" + ] + }, + { + "borderFillIDRef": 14, + "isHeader": false, + "colAddr": 3, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 5392, + "height_hu": 1850, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "비고", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "비고" + ] + } + ], + [ + { + "borderFillIDRef": 12, + "isHeader": false, + "colAddr": 0, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "소개" + ] + }, + { + "borderFillIDRef": 8, + "isHeader": false, + "colAddr": 1, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "개요", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "개요" + ] + }, + { + "borderFillIDRef": 7, + "isHeader": false, + "colAddr": 2, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 12265, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입 · 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등) · 발죌처별 상읎한 양식곌 확장자로 재펞집 - Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감", + "charPrIDRefs": [ + 24, + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0, + 0 + ], + "lines": [ + "- 현황 및 묞제점 : 읞적 였류와 추가적 늬소슀(읞력, 시간) 투입", + "· 동음한 원천데읎터로 산출묌 형식만 달띌짐 (제안서, 볎고서 등)", + "· 발죌처별 상읎한 양식곌 확장자로 재펞집", + "- Ʞ대횚곌 : 묞서 작업 시간의 닚축, 반복 잡업의 감소, 였류 절감" + ] + }, + { + "borderFillIDRef": 19, + "isHeader": false, + "colAddr": 3, + "rowAddr": 1, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 20930, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "1p", + "charPrIDRefs": [ + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "1p" + ] + } + ], + [ + { + "borderFillIDRef": 16, + "isHeader": false, + "colAddr": 1, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 소개", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 소개" + ] + }, + { + "borderFillIDRef": 18, + "isHeader": false, + "colAddr": 2, + "rowAddr": 2, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- Ꞁ벗 Ʞ능 소개 · (Input) 로컬, 링크, HTML 구조 · (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ · (Export) 읞쇄, PDF, HWP", + "charPrIDRefs": [ + 24, + 24, + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0, + 0 + ], + "lines": [ + "- Ꞁ벗 Ʞ능 소개", + "· (Input) 로컬, 링크, HTML 구조", + "· (Process) 목찚 구성 및 묞서 작성 / (Edit) 펞집Ʞ", + "· (Export) 읞쇄, PDF, HWP" + ] + } + ], + [ + { + "borderFillIDRef": 11, + "isHeader": false, + "colAddr": 0, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5054, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "시연" + ] + }, + { + "borderFillIDRef": 15, + "isHeader": false, + "colAddr": 1, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 시연", + "charPrIDRefs": [ + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0 + ], + "lines": [ + "Ꞁ벗 시연" + ] + }, + { + "borderFillIDRef": 17, + "isHeader": false, + "colAddr": 2, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 8665, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성 - (Ʞ능 2) (Process) 웹 펞집Ʞ - (Ʞ능 3) (Export) PDF와 HWP 추출", + "charPrIDRefs": [ + 24, + 23, + 24, + 23, + 24, + 23 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 11, + 11, + 11 + ], + "primaryParaPrIDRef": 11, + "styleIDRefs": [ + 0, + 0, + 0 + ], + "lines": [ + "- (Ʞ능 1) (Input) 업로드한 묞서 êž°ë°˜ 목찚 정늬 / 작성", + "- (Ʞ능 2) (Process) 웹 펞집Ʞ", + "- (Ʞ능 3) (Export) PDF와 HWP 추출" + ] + }, + { + "borderFillIDRef": 20, + "isHeader": false, + "colAddr": 3, + "rowAddr": 3, + "colSpan": 1, + "rowSpan": 2, + "width_hu": 5392, + "height_hu": 13730, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 & Visual Studio", + "charPrIDRefs": [ + 23, + 23 + ], + "primaryCharPrIDRef": 23, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 &", + "Visual Studio" + ] + } + ], + [ + { + "borderFillIDRef": 9, + "isHeader": false, + "colAddr": 1, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 9300, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "Ꞁ벗 만드는 곌정", + "charPrIDRefs": [ + 20, + 20 + ], + "primaryCharPrIDRef": 20, + "paraPrIDRefs": [ + 4, + 4 + ], + "primaryParaPrIDRef": 4, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "Ꞁ벗 만드는", + "곌정" + ] + }, + { + "borderFillIDRef": 6, + "isHeader": false, + "colAddr": 2, + "rowAddr": 4, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27183, + "height_hu": 5065, + "cellMargin": { + "left": 142, + "right": 142, + "top": 425, + "bottom": 425 + }, + "text": "AI에게 활용할 자료 제공하Ʞ AI륌 활용하여 윔딩하Ʞ", + "charPrIDRefs": [ + 24, + 24 + ], + "primaryCharPrIDRef": 24, + "paraPrIDRefs": [ + 16, + 16 + ], + "primaryParaPrIDRef": 16, + "styleIDRefs": [ + 0, + 0 + ], + "lines": [ + "AI에게 활용할 자료 제공하Ʞ", + "AI륌 활용하여 윔딩하Ʞ" + ] + } + ] + ], + "colWidths_hu": [ + 5054, + 9300, + 27183, + 5392 + ], + "colWidths_pct": [ + 11, + 20, + 58, + 11 + ] + } + ], + "header": { + "exists": true, + "hidden": false, + "texts": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀", + "2025. 2. 5(목)" + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 11912, + "text": "쎝ꎄꞰ획싀 Ʞ술Ʞ획팀", + "lines": [ + "쎝ꎄꞰ획싀", + "Ʞ술Ʞ획팀" + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 7950 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 27760, + "text": "2025. 2. 5(목)", + "lines": [ + "2025. 2. 5(목)" + ] + } + ] + ] + } + }, + "footer": { + "exists": true, + "hidden": false, + "texts": [ + "Ʞ술", + "로", + "사람", + "곌", + "자연", + "읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ], + "type": "table", + "table": { + "rowCnt": 1, + "colCnt": 3, + "rows": [ + [ + { + "borderFillIDRef": 5, + "colAddr": 0, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 16723, + "text": "Ʞ술 로 사람 곌 자연 읎 핚께하는 섞상을 만듀얎 갑니닀.", + "lines": [ + "Ʞ술 로 사람 곌 자연 읎", + "핚께하는 섞상을 만듀얎 갑니닀." + ] + }, + { + "borderFillIDRef": 5, + "colAddr": 1, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 2856 + }, + { + "borderFillIDRef": 5, + "colAddr": 2, + "rowAddr": 0, + "colSpan": 1, + "rowSpan": 1, + "width_hu": 28043 + } + ] + ] + } + }, + "section": { + "textDirection": "HORIZONTAL", + "hideFirstHeader": false, + "hideFirstFooter": false, + "hideFirstMasterPage": false, + "hideFirstPageNum": false, + "hideFirstEmptyLine": false, + "startNum": { + "pageStartsOn": "BOTH", + "page": 0 + } + }, + "styles": [ + { + "id": 0, + "paraPrIDRef": 3, + "charPrIDRef": 0, + "nextStyleIDRef": 0, + "type": "PARA", + "name": "바탕Ꞁ", + "engName": "Normal" + }, + { + "id": 1, + "paraPrIDRef": 2, + "charPrIDRef": 3, + "nextStyleIDRef": 1, + "type": "PARA", + "name": "뚞늬말", + "engName": "Header" + }, + { + "id": 2, + "paraPrIDRef": 1, + "charPrIDRef": 2, + "nextStyleIDRef": 2, + "type": "PARA", + "name": "쪜 번혞", + "engName": "Page Number" + }, + { + "id": 3, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 3, + "type": "PARA", + "name": "각죌", + "engName": "Footnote" + }, + { + "id": 4, + "paraPrIDRef": 0, + "charPrIDRef": 1, + "nextStyleIDRef": 4, + "type": "PARA", + "name": "믞죌", + "engName": "Endnote" + }, + { + "id": 5, + "paraPrIDRef": 4, + "charPrIDRef": 6, + "nextStyleIDRef": 5, + "type": "PARA", + "name": "표위", + "engName": "Memo" + }, + { + "id": 6, + "paraPrIDRef": 8, + "charPrIDRef": 0, + "nextStyleIDRef": 6, + "type": "PARA", + "name": "표옆", + "engName": "" + }, + { + "id": 7, + "paraPrIDRef": 10, + "charPrIDRef": 0, + "nextStyleIDRef": 7, + "type": "PARA", + "name": "표낎용", + "engName": "" + }, + { + "id": 8, + "paraPrIDRef": 9, + "charPrIDRef": 13, + "nextStyleIDRef": 8, + "type": "PARA", + "name": "죌)", + "engName": "" + }, + { + "id": 9, + "paraPrIDRef": 14, + "charPrIDRef": 18, + "nextStyleIDRef": 9, + "type": "PARA", + "name": "#큰아읎윘", + "engName": "" + }, + { + "id": 10, + "paraPrIDRef": 21, + "charPrIDRef": 25, + "nextStyleIDRef": 10, + "type": "PARA", + "name": "개요1", + "engName": "" + }, + { + "id": 11, + "paraPrIDRef": 20, + "charPrIDRef": 26, + "nextStyleIDRef": 11, + "type": "PARA", + "name": "xl63", + "engName": "xl63" + } + ], + "numbering": { + "numberings": [ + { + "id": 1, + "start": 0, + "levels": [ + { + "level": 1, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^1." + }, + { + "level": 2, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^2." + }, + { + "level": 3, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "^3)" + }, + { + "level": 4, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "^4)" + }, + { + "level": 5, + "numFormat": "DIGIT", + "align": "LEFT", + "pattern": "(^5)" + }, + { + "level": 6, + "numFormat": "HANGUL_SYLLABLE", + "align": "LEFT", + "pattern": "(^6)" + }, + { + "level": 7, + "numFormat": "CIRCLED_DIGIT", + "align": "LEFT", + "pattern": "^7" + } + ] + } + ], + "bullets": [ + { + "id": 1, + "char": "-", + "useImage": false + } + ] + }, + "images": [ + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 1133, + "height_hu": 1133, + "width_mm": 4.0, + "height_mm": 4.0, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + }, + { + "type": "image", + "width_hu": 624, + "height_hu": 624, + "width_mm": 2.2, + "height_mm": 2.2, + "offset": { + "x": 0, + "y": 0 + } + } + ], + "content_order": [ + { + "index": 0, + "paraPrIDRef": "7", + "styleIDRef": "0", + "type": "table", + "table_idx": 0, + "rowCnt": "1", + "colCnt": "2", + "borderFillIDRef": "3" + }, + { + "index": 1, + "paraPrIDRef": "17", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 2, + "paraPrIDRef": "15", + "styleIDRef": "9", + "type": "image", + "image_idx": 0, + "binaryItemIDRef": "image1", + "text": " 개요", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 3, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 1, + "binaryItemIDRef": "image2", + "text": " AI륌 활용한 “업묎 횚윚성 개선 사례”와 읎륌 구현한 방식에 대한 공유", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 4, + "paraPrIDRef": "18", + "styleIDRef": "7", + "type": "paragraph", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연", + "charPrIDRef": "22", + "runs": [ + { + "charPrIDRef": "22", + "text": "삌안의 임원 대상 「Ꞁ벗」 소개와 읎륌 구현한 방식에 대한 예시 시연" + } + ] + }, + { + "index": 5, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 6, + "paraPrIDRef": "19", + "styleIDRef": "9", + "type": "image", + "image_idx": 2, + "binaryItemIDRef": "image1", + "text": " 발표 구성(안)", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 7, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 3, + "binaryItemIDRef": "image2", + "text": " 제목 : AI 활용 묞서 업묎 개선 사례 -「Ꞁ벗」(사용자의 Ꞁ쓰Ʞ륌 돕는 친구) -", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 8, + "paraPrIDRef": "12", + "styleIDRef": "7", + "type": "image", + "image_idx": 4, + "binaryItemIDRef": "image2", + "text": " 발표 낎용 ", + "imgRect": { + "x": null, + "y": null, + "w": null, + "h": null + } + }, + { + "index": 9, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "table", + "table_idx": 1, + "rowCnt": "5", + "colCnt": "4", + "borderFillIDRef": "3" + }, + { + "index": 10, + "paraPrIDRef": "13", + "styleIDRef": "0", + "type": "empty" + }, + { + "index": 11, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + }, + { + "index": 12, + "paraPrIDRef": "22", + "styleIDRef": "7", + "type": "empty" + } + ] + }, + "css": "", + "fonts": {}, + "colors": { + "background": [ + "#EDEDED", + "#DCDCDC" + ], + "border": [ + "#000000", + "#3057B9", + "#999999", + "#BBBBBB" + ], + "text": [] + }, + "border_fills": { + "1": { + "id": 1, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "2": { + "id": 2, + "left": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.1mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "3": { + "id": 3, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "0.12mm solid #000000", + "border-right": "0.12mm solid #000000", + "border-top": "0.12mm solid #000000", + "border-bottom": "0.12mm solid #000000" + } + }, + "4": { + "id": 4, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "SOLID", + "width": "0.7mm", + "color": "#3057B9" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "0.7mm solid #3057B9" + } + }, + "5": { + "id": 5, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "top": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "bottom": { + "type": "NONE", + "width": "0.12mm", + "color": "#000000" + }, + "diagonal": { + "type": "SOLID", + "width": "0.1mm", + "color": "#000000" + }, + "css": { + "border-left": "none", + "border-right": "none", + "border-top": "none", + "border-bottom": "none" + } + }, + "6": { + "id": 6, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + }, + "7": { + "id": 7, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "8": { + "id": 8, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "9": { + "id": 9, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "10": { + "id": 10, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "11": { + "id": 11, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "12": { + "id": 12, + "left": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "none", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "13": { + "id": 13, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "14": { + "id": 14, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#DCDCDC", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.3mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#DCDCDC" + } + }, + "15": { + "id": 15, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "16": { + "id": 16, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "background": "#EDEDED", + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB", + "background-color": "#EDEDED" + } + }, + "17": { + "id": 17, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.12mm solid #BBBBBB" + } + }, + "18": { + "id": 18, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "0.12mm solid #999999", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "19": { + "id": 19, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.12mm solid #BBBBBB", + "border-bottom": "0.5mm solid #BBBBBB" + } + }, + "20": { + "id": 20, + "left": { + "type": "SOLID", + "width": "0.12mm", + "color": "#999999" + }, + "right": { + "type": "NONE", + "width": "0.12mm", + "color": "#BBBBBB" + }, + "top": { + "type": "SOLID", + "width": "0.5mm", + "color": "#BBBBBB" + }, + "bottom": { + "type": "SOLID", + "width": "0.3mm", + "color": "#BBBBBB" + }, + "css": { + "border-left": "0.12mm solid #999999", + "border-right": "none", + "border-top": "0.5mm solid #BBBBBB", + "border-bottom": "0.3mm solid #BBBBBB" + } + } + }, + "tables": [], + "style_summary": {} +} \ No newline at end of file diff --git a/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/template.html b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/template.html new file mode 100644 index 0000000..d7ef470 --- /dev/null +++ b/03. Code/geulbeot_9th/templates/user/templates/tpl_1770335603/template.html @@ -0,0 +1,590 @@ + + + + +Template + + + +
                                        + +
                                        + ++ + + + + + + + + +
                                        {{HEADER_R1_C1_LINE_1}}
                                        {{HEADER_R1_C1_LINE_2}}
                                        {{HEADER_R1_C3}}
                                        +
                                        + +
                                        + ++ + + + + + + +
                                        {{TITLE_R1_C2}}
                                        +
                                        + + +
                                        + {{IMAGE_1}} +

                                        {{IMAGE_1_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_2}} +

                                        {{IMAGE_2_CAPTION}}

                                        +
                                        + +

                                        {{PARA_1}}

                                        + +
                                        + {{IMAGE_3}} +

                                        {{IMAGE_3_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_4}} +

                                        {{IMAGE_4_CAPTION}}

                                        +
                                        + +
                                        + {{IMAGE_5}} +

                                        {{IMAGE_5_CAPTION}}

                                        +
                                        + + ++ + + + + + + + + + + + + + {{TABLE_1_BODY}} + +
                                        {{TABLE_1_H_C1}}{{TABLE_1_H_C2}}{{TABLE_1_H_C3}}
                                        + + + +
                                        + + \ No newline at end of file diff --git a/03. Code/geulbeot_9th/Ʞ쀀 프롬프튞(0206_0706).txt b/03. Code/geulbeot_9th/Ʞ쀀 프롬프튞(0206_0706).txt new file mode 100644 index 0000000..1e57357 --- /dev/null +++ b/03. Code/geulbeot_9th/Ʞ쀀 프롬프튞(0206_0706).txt @@ -0,0 +1,486 @@ +# Ꞁ벗 Light v2.0 — 전첎 프로젝튞 구조 + +> 작성음: 2025-02-06 +> Ʞ쀀: app.py + 전첎 소슀윔드 분석 완료 + +--- + +## 1. 프로젝튞 개요 + +**Ꞁ벗**은 HWPX(한Ꞁ) 묞서륌 HTML로 변환하고, AI가 낎용을 채워 전묞 묞서륌 생성하는 시슀템. + +### 핵심 원칙 +- **HWPX 1:1 재현**: 원볞 양식을 귞대로 HTML로 변환 +- **하드윔딩 제로**: 몚든 슀타음은 HWPX에서 추출 +- **템플늿 êž°ë°˜**: 양식은 유지, 낎용만 AI가 채움 + +--- + +## 2. 전첎 아킀텍처 (16개 Ʞ능) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 【 Ꞁ벗 Light v2.0 】 │ +├──────────────────┬─────────────────────────────┬───────────────────────────── +│ │ │ │ +│ 【 INPUT 】 │ 【 PROCESS 】 │ 【 OUTPUT 】 │ +│ 원볞 데읎터 │ 핵심 엔진 │ 결곌묌 │ +│ │ │ │ +│ ① 로컬 묞서 RAG화 │ ┌─────────────────┐ │ â‘€ Ʞ획서 생성 ✅ │ +│ ✅ 구현완료 │ │ │ │ 1p / 1p+첚부 │ +│ hwp,pdf,ppt │ │ HWPX ↔ HTML │ │ │ +│ → 텍슀튞 추출 │ │ 변환 엔진 │ │ ⑥ 볎고서 생성 ✅ │ +│ │ │ │ │ 표지/목찚/볞묞 │ +│ ② 웹 묞서 수집 │ │ ✅ 구현완료 │ │ │ +│ ❌ 믞구현 │ │ │ │ ⑩ 발표자료 생성 ⏳ │ +│ │ └────────┬────────┘ │ (쀀비쀑) │ +│ ③ HTML 표쀀화 │ │ │ │ +│ ❓ 믞확읞 │ â–Œ │ ⑧ 신규 묞서유형 ✅ │ +│ │ ┌─────────────────┐ │ HWPX 업로드 → 등록 │ +│ ④ 반영 수쀀 선택 │ │ │ │ │ +│ ❓ 믞확읞 │ │ 【 EDIT 】 │ │ │ +│ │ │ 펞집 엔진 │ │ │ +│ │ │ │ │ │ +│ │ │ ⑹ 웹 펞집Ʞ ✅ │ │ │ +│ │ │ ⑩ AI 펞집 ✅ │ │ │ +│ │ │ ⑪ 신규템플늿 ✅ │ │ │ +│ │ │ │ │ │ +│ │ └─────────────────┘ │ │ +└──────────────────┮─────────────────────────────┮────────────────────────────┘ + │ + â–Œ + ┌───────────────────────────────┐ + │ 【 EXPORT 】 │ + ├──────────────────────────────── + │ ⑫ HTML ✅ ⑬ PDF ✅ │ + │ ⑭ HWPX/HWP ✅ ⑮ PPT ❌ │ + └───────────────────────────────┘ + │ + â–Œ + ┌───────────────────────────────┐ + │ 【 SERVER 】 │ + │ ⑯ 서버 업로드/공유 ❌ │ + └───────────────────────────────┘ +``` + +### Ʞ능별 구현 현황 + +| # | 구분 | Ʞ능 | 상태 | 구현 위치 | +|---|------|------|------|-----------| +| 1 | INPUT | 로컬 묞서 RAG화 | ✅ 완료 | converters/pipeline/* | +| 2 | INPUT | 웹 묞서 수집 | ❌ 믞구현 | - | +| 3 | INPUT | HTML 표쀀화 | ❓ 믞확읞 | - | +| 4 | INPUT | 반영 수쀀 선택 | ⏳ UI만 졎재 | index.html (추후 개발) | +| 5 | OUTPUT | Ʞ획서 생성 | ✅ 완료 | handlers/briefing/ | +| 6 | OUTPUT | 볎고서 생성 | ✅ 완료 | handlers/report/ | +| 7 | OUTPUT | 발표자료 생성 | ⏳ 쀀비쀑 | config.json (enabled:false) | +| 8 | OUTPUT | 신규 묞서유형 | ✅ 완료 | handlers/doc_type_analyzer | +| 9 | EDIT | 웹 펞집Ʞ | ✅ 완료 | static/js/editor.js | +| 10 | EDIT | AI 펞집 | ✅ 완료 | /refine, /refine-selection | +| 11 | EDIT | 신규 템플늿 | ✅ 완료 | handlers/template_manager | +| 12 | EXPORT | HTML | ✅ 완료 | app.py /download/html | +| 13 | EXPORT | PDF | ✅ 완료 | app.py /download/pdf | +| 14 | EXPORT | HWPX/HWP | ✅ 완료 | converters/html_to_hwp* | +| 15 | EXPORT | PPT | ❌ 믞구현 | - | +| 16 | SERVER | 서버 업로드 | ❌ 믞구현 | - | + +--- + +## 3. 폮더 구조 (전첎) + +``` +Ꞁ벗/ +│ +├── app.py ✅ Flask 메읞 (25개 API 엔드포읞튞) +│ +├── handlers/ ✅ 【PROCESS】 핵심 처늬 몚듈 +│ ├── __init__.py +│ ├── common.py ✅ AI 혞출 공통 (call_claude 등) +│ │ +│ │── 【프로섞서 4종】 +│ ├── briefing/ ✅ Ʞ획서 처늬 +│ │ ├── processor.py BriefingProcessor +│ │ └── prompts/ +│ │ ├── step1_extract.txt 구조 추출 +│ │ ├── step1_5_plan.txt 배치 계획 +│ │ └── step2_generate.txt HTML 생성 +│ │ +│ ├── report/ ✅ 볎고서 처늬 +│ │ ├── processor.py ReportProcessor +│ │ └── prompts/ +│ │ └── refine_selection.txt 부분 수정 +│ │ +│ ├── template/ ✅ 템플늿 처늬 (v3) +│ │ ├── processor.py TemplateProcessor +│ │ ├── html_table_template_css.txt +│ │ └── prompts/ +│ │ └── analyze_template.txt +│ │ +│ ├── custom_doc_type.py ✅ 사용자 정의 묞서 (user_*) +│ │ +│ │── 【HWPX 분석 파읎프띌읞】 +│ ├── doc_type_analyzer.py ✅ 6닚계 SSE 였쌀슀튞레읎터 +│ ├── doc_template_analyzer.py ✅ tools/ 혞출 → template_info +│ ├── template_manager.py ✅ template.html 생성/저장/로드 +│ ├── style_generator.py ✅ template_info → CSS +│ ├── semantic_mapper.py ✅ 표 역할 분류 +│ ├── content_analyzer.py ✅ content_prompt.json 생성 +│ │ +│ └── tools/ ✅ HWPX 파싱 도구 (12개) +│ ├── page_setup.py §7 용지/여백 +│ ├── char_style.py §4 Ꞁ자 몚양 → cpr-* +│ ├── para_style.py §5 묞닚 몚양 → ppr-* +│ ├── border_fill.py §2 테두늬/배겜 → bf-* +│ ├── table.py §6 표 +│ ├── header_footer.py §8 뚞늬말/ꌬ늬말 +│ ├── content_order.py 볞묞 순서 +│ ├── font.py §3 Ꞁꌎ +│ ├── style_def.py 슀타음 정의 +│ ├── numbering.py 번혞맀ꞰꞰ +│ ├── image.py 읎믞지 +│ └── section.py §9 구역 +│ +├── converters/ ✅ 【EXPORT】 변환 몚듈 +│ ├── __init__.py +│ │ +│ │── 【HTML → HWP 변환】 +│ ├── html_to_hwp.py ✅ 음반 묞서 → HWP +│ ├── html_to_hwp_briefing.py ✅ Ʞ획서 → HWP +│ ├── hwp_style_mapping.py ✅ 슀타음 맀핑 정의 +│ ├── hwpx_style_injector.py ✅ HWPX 슀타음 죌입 +│ ├── hwpx_table_injector.py ✅ HWPX 표 죌입 +│ ├── style_analyzer.py ✅ HTML 슀타음 분석 +│ │ +│ │── 【믞사용】 +│ ├── hwpx_generator.py ❌ 믞사용 (독늜 싀행용/개발쀑) +│ │ +│ └── pipeline/ ✅ 【INPUT】 RAG 파읎프띌읞 +│ ├── router.py ë¶„êž° 처늬 +│ ├── step1_convert.py 파음 변환 (hwp,pdf,ppt→pdf) +│ ├── step2_extract.py 텍슀튞/읎믞지 추출 +│ ├── step3_domain.py 도메읞 분석 +│ ├── step4_chunk.py 청킹 +│ ├── step5_rag.py RAG 검색 +│ ├── step6_corpus.py 윔퍌슀 구축 +│ ├── step7_index.py 읞덱싱 +│ ├── step8_content.py 윘텐잠 생성 +│ └── step9_html.py HTML 생성 +│ +├── domain/ ✅ 도메읞 유틞늬티 +│ └── hwpx/ +│ ├── hwpx_domain_guide.md HWPX ↔ HTML 맀핑 슀펙 (§1~§11) +│ └── hwpx_utils.py 닚위 변환 핚수 +│ +├── static/ ✅ 【EDIT】 웹 펞집Ʞ +│ ├── js/ +│ │ └── editor.js ✅ 펞집Ʞ (1208쀄) +│ │ · 폰튾/크Ʞ/색상/정렬 +│ │ · 굵게/êž°ìšžìž„/밑쀄/췚소선 +│ │ · 표/귞늌/구분선 삜입 +│ │ · 지능형 정렬 (smartAlign) +│ │ · 새 페읎지 / 전 페읎지 읎동 +│ │ · Undo/Redo +│ │ +│ └── css/ +│ └── editor.css ✅ 펞집Ʞ 슀타음 (296쀄) +│ +├── templates/ ✅ UI + 데읎터 저장소 +│ ├── index.html ✅ 메읞 UI (3765쀄) +│ │ +│ │── 【믞사용 - 삭제 가능】 +│ ├── hwp_guide.md ❌ 믞사용 +│ ├── hwp_html_defaults.json ❌ 믞사용 +│ │ +│ ├── default/doc_types/ ✅ Ʞ볞 묞서 유형 (3개) +│ │ ├── briefing/config.json Ʞ획서 ✅ +│ │ ├── report/config.json 볎고서 ✅ +│ │ └── presentation/config.json 발표자료 ⏳ (enabled:false) +│ │ +│ └── user/ ✅ 사용자 정의 저장 +│ ├── doc_types/{type_id}/ +│ │ ├── config.json +│ │ └── content_prompt.json +│ └── templates/{tpl_id}/ +│ ├── template.html +│ ├── style.json +│ ├── meta.json +│ └── semantic_map.json +│ +├── output/ ⚠ 임시 출력 (.gitignore 권장) +│ └── assets/*.png +│ +└── prompts/ ❌ 믞사용 (쀑복 - 삭제 가능) + ├── step1_extract.txt = briefing/prompts/ 와 동음 + ├── step1_5_plan.txt + └── step2_generate.txt +``` + +--- + +## 4. API 엔드포읞튞 (25개) + +### 메읞 +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/` | GET | 메읞 페읎지 (index.html) | +| `/health` | GET | 헬슀 첎크 | + +### 묞서 유형 ꎀ늬 +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/api/doc-types` | GET | 묞서 유형 목록 | +| `/api/doc-types` | POST | 묞서 유형 저장 | +| `/api/doc-types/` | DELETE | 묞서 유형 삭제 | +| `/api/doc-types/analyze` | POST | 묞서 유형 분석 (닚음) | +| `/api/doc-types/analyze-stream` | POST | 묞서 유형 분석 (SSE 슀튞늬밍) | +| `/api/doc-types//template` | GET | 템플늿 조회 | +| `/api/doc-types//template` | PUT | 템플늿 연결 | + +### 템플늿 ꎀ늬 +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/templates` | GET | 템플늿 목록 | +| `/api/templates` | GET | 템플늿 목록 (API) | +| `/api/templates/` | GET | 템플늿 상섞 | +| `/api/templates/` | DELETE | 템플늿 삭제 | +| `/analyze-template` | POST | HWPX → 템플늿 추출 | +| `/delete-template/` | DELETE | 템플늿 삭제 (구버전) | + +### 묞서 생성 +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/generate` | POST | 묞서 생성 (briefing/report/template/custom) | +| `/generate-report` | POST | 볎고서 생성 | + +### 펞집 (EDIT) +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/refine` | POST | AI 전첎 수정 | +| `/refine-selection` | POST | AI 부분 수정 | +| `/analyze-styles` | POST | HTML 슀타음 분석 | + +### 닀욎로드 (EXPORT) +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/download/html` | POST | HTML 닀욎로드 | +| `/download/pdf` | POST | PDF 닀욎로드 (weasyprint) | +| `/export-hwp` | POST | HWP/HWPX 닀욎로드 | + +### Ʞ타 +| API | 메서드 | 섀명 | +|-----|--------|------| +| `/assets/` | GET | 로컬 에셋 서빙 | +| `/hwp-script` | GET | HWP 변환 안낎 | + +--- + +## 5. 몚듈 연결도 + +``` + app.py (Flask) + │ + ┌──────────┬─────────────────┌─────────────────┬──────────┐ + │ │ │ │ │ + â–Œ â–Œ â–Œ â–Œ â–Œ + ┌────────┐ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ + │briefing│ │ report │ │ template │ │ custom │ │doc_type│ + │Processor│ │Processor│ │Processor │ │DocType │ │Analyzer│ + └───┬────┘ └───┬────┘ └────┬─────┘ └───┬────┘ └───┬────┘ + │ │ │ │ │ + │ â–Œ │ │ │ + │ ┌──────────┐ │ │ │ + │ │converters│ │ │ │ + │ │/pipeline │ │ │ │ + │ │ (RAG 9닚계)│ │ │ │ + │ └──────────┘ │ │ │ + │ │ │ │ + │ â–Œ â–Œ â–Œ + │ ┌─────────────────────────────────────┐ + │ │ template_manager │ + │ │ ┌─────────────────────────────────┐│ + │ │ │ doc_template_analyzer ││ + │ │ │ └─ tools/* (12개) ││ + │ │ │ └─ domain/hwpx ││ + │ │ ├──────────────────────────────────│ + │ │ │ style_generator ││ + │ │ │ semantic_mapper ││ + │ │ │ content_analyzer ││ + │ │ └─────────────────────────────────┘│ + │ └─────────────────────────────────────┘ + │ + │ ┌─────────────────────────────────────────────────────┐ + └───▶│ converters/ (EXPORT) │ + ├────────────────────────────────────────────────────── + │ html_to_hwp_briefing ◀── doc_type == 'briefing' │ + │ html_to_hwp ◀── ê·ž 왞 │ + │ │ │ + │ ├── hwp_style_mapping │ + │ ├── hwpx_style_injector │ + │ ├── hwpx_table_injector │ + │ └── style_analyzer │ + └─────────────────────────────────────────────────────┘ + + ┌─────────────────────────────────────────────┐ + │ static/ (EDIT 몚듈) │ + ├────────────────────────────────────────────── + │ js/editor.js (1208쀄) │ + │ · 포맷 바 (폰튾, 크Ʞ, 색상, 정렬) │ + │ · 삜입 (표, 귞늌, 구분선) │ + │ · 페읎지 ꎀ늬 (새페읎지, 읎동) │ + │ · Undo/Redo │ + │ │ + │ css/editor.css (296쀄) │ + │ · 포맷 바 슀타음 │ + │ · 펞집 몚드 하읎띌읎튞 │ + └─────────────────────────────────────────────┘ +``` + +--- + +## 6. 핵심 파읎프띌읞 + +### Phase A: 묞서 유형 등록 (1회) +``` +HWPX 업로드 + 읎늄 입력 + │ + â–Œ +doc_type_analyzer.analyze() [SSE 6닚계] + │ + ├─ Step 1: _parse_hwpx() HWPX ZIP 핎제 + ├─ Step 2: _extract_layout() 레읎아웃 추출 + ├─ Step 3: _analyze_context() [AI] 맥띜 분석 + ├─ Step 4: _analyze_structure() [AI] 구조 분석 + ├─ Step 5: template_manager.extract_and_save() + │ ├─ doc_template_analyzer.analyze() + │ │ └─ tools/* (12개 파싱 도구) + │ ├─ semantic_mapper.generate() + │ ├─ style_generator.generate_css() + │ └─ content_analyzer.generate() + └─ Step 6: save_doc_type() 저장 + +저장 위치: + templates/user/doc_types/{type_id}/ + ├─ config.json + └─ content_prompt.json + templates/user/templates/{tpl_id}/ + ├─ template.html + ├─ style.json + ├─ meta.json + └─ semantic_map.json +``` + +### Phase B: 묞서 생성 (맀번) +``` +낎용 입력 + 묞서유형 선택 + │ + ├─ doc_type == 'briefing' + │ └─ BriefingProcessor.generate() + │ Step 1: 구조 추출 [AI] + │ Step 1.5: 배치 계획 [AI] + │ Step 2: HTML 생성 [AI] + │ + ├─ doc_type == 'report' + │ └─ ReportProcessor.generate() + │ └─ converters/pipeline/router + │ └─ 9닚계 RAG 파읎프띌읞 + │ + ├─ doc_type == 'template' + │ └─ TemplateProcessor.generate() + │ + └─ doc_type.startswith('user_') + └─ CustomDocTypeProcessor.generate() + ├─ config.json 로드 + ├─ template.html 로드 + ├─ content_prompt.json 로드 + ├─ [AI] placeholder 채우Ʞ + └─ _fill_template() +``` + +### Phase C: EXPORT +``` +HTML 결곌묌 + │ + ├─ /download/html → 귞대로 닀욎로드 + │ + ├─ /download/pdf → weasyprint 변환 + │ + └─ /export-hwp + ├─ doc_type == 'briefing' + │ └─ html_to_hwp_briefing.py + └─ ê·ž 왞 + └─ html_to_hwp.py + ├─ hwp_style_mapping + ├─ hwpx_style_injector + └─ hwpx_table_injector +``` + +--- + +## 7. 파음별 사용 현황 + +### 사용 쀑 (유지) +| 위치 | 파음 수 | 비고 | +|------|---------|------| +| handlers/ | 20개 | 전부 사용 | +| handlers/tools/ | 12개 | 전부 사용 | +| converters/ | 9개 | hwpx_generator.py 제왞 | +| converters/pipeline/ | 10개 | 전부 사용 | +| domain/hwpx/ | 2개 | 전부 사용 | +| static/ | 2개 | 전부 사용 (EDIT 몚듈) | +| templates/index.html | 1개 | 메읞 UI | +| templates/default/ | 3개 | Ʞ볞 묞서유형 | +| templates/user/ | 가변 | 사용자 데읎터 | + +### 믞사용 (삭제 가능) +| 파음 | 읎유 | +|------|------| +| `converters/hwpx_generator.py` | 얎디서도 import 안 됚 | +| `templates/hwp_guide.md` | 얎디서도 ì°žì¡° 안 됚 | +| `templates/hwp_html_defaults.json` | 얎디서도 ì°žì¡° 안 됚 | +| `prompts/` (룚튞 폮더) | handlers/briefing/prompts/와 쀑복 | + +### .gitignore 권장 +``` +output/ +*/__pycache__/ +*.pyc +``` + +--- + +## 8. 현재 버귞 (3걎) + +### 🔎 버귞 #1: 표 누띜 +- **위치**: template_manager.py +- **원읞**: content_order의 table_idx가 header/footer 표 포핚된 전첎 늬슀튞에 맀핑 +- **슝상**: 볞묞 표가 사띌짐 + +### 🔎 버귞 #2: CSS 탈띜 +- **위치**: custom_doc_type.py _fill_template() +- **원읞**: `

                                        ` 안에 `

                                          ` 삜입 → 람띌우저가 구조 깚뜚늌 +- **슝상**: 개조식 낎용에 슀타음 믞적용 + +### 🔎 버귞 #3: 제목 빈칞 +- **위치**: content_analyzer.py +- **원읞**: placeholder 읎늄 불음치 (TITLE vs TITLE_R1_C2) +- **슝상**: 제목읎 채워지지 않음 + +--- + +## 9. 닀음 닚계 + +### 슉시 수정 (버귞 3걎) +1. template_manager.py — 표 읞덱슀 맀핑 +2. custom_doc_type.py — inline context에서 `
                                            ` ꞈ지 +3. content_analyzer.py — TITLE placeholder 읎늄 + +### 믞구현 Ʞ능 +- ② 웹 묞서 수집 (크례링) +- ④ 반영 수쀀 선택 (UI만 졎재, 백엔드 믞연결) +- ⑩ 발표자료 생성 +- ⑮ PPT 출력 +- ⑯ 서버 업로드/공유 + +--- + +*읎 묞서륌 읜은 AI/개발자는 버귞 수정 또는 믞구현 Ʞ능 개발부터 시작할 수 있습니닀.* \ No newline at end of file