import graphviz import json from tempfile import NamedTemporaryFile import os from PIL import Image import platform import subprocess def setup_korean_font_env(): """한글 폰트 환경 설정""" CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) FONT_PATH = os.path.join(CURRENT_DIR, 'NanumGothic-Regular.ttf') # 폰트 파일 존재 확인 if not os.path.exists(FONT_PATH): print(f"[경고] 한글 폰트 파일을 찾을 수 없습니다: {FONT_PATH}") return None # 환경 변수 설정 os.environ['GDFONTPATH'] = CURRENT_DIR # fonts.conf 생성 fonts_conf_path = os.path.join(CURRENT_DIR, 'fonts.conf') fonts_conf_content = f""" {CURRENT_DIR} /tmp/fontconfig-cache NanumGothic NanumGothic-Regular NanumGothic-Regular {FONT_PATH} NanumGothic NanumGothic-Regular """ with open(fonts_conf_path, 'w', encoding='utf-8') as f: f.write(fonts_conf_content) os.environ['FONTCONFIG_FILE'] = fonts_conf_path os.environ['FONTCONFIG_PATH'] = CURRENT_DIR return FONT_PATH # 폰트 설정 초기화 FONT_PATH = setup_korean_font_env() def generate_process_flow_diagram(json_input: str, output_format: str) -> str: """ Generates a Process Flow Diagram (Flowchart) from JSON input. """ try: if not json_input.strip(): return "Error: Empty input" data = json.loads(json_input) # Validate required top-level keys for a flowchart if 'start_node' not in data or 'nodes' not in data or 'connections' not in data: raise ValueError("Missing required fields: 'start_node', 'nodes', or 'connections'") # Define specific node shapes for flowchart types node_shapes = { "process": "box", # Rectangle for processes "decision": "diamond", # Diamond for decisions "start": "oval", # Oval for start "end": "oval", # Oval for end "io": "parallelogram", # Input/Output "document": "note", # Document symbol "default": "box" # Fallback } # Graphviz 다이어그램 생성 - 크기와 여백 조정 dot = graphviz.Digraph( name='ProcessFlowDiagram', format='png', encoding='utf-8', graph_attr={ 'rankdir': 'TB', # Top-to-Bottom flow 'splines': 'ortho', # Straight lines with 90-degree bends 'bgcolor': 'white', 'pad': '0.2', # 전체 패딩 줄임 (0.5 -> 0.2) 'margin': '0.2', # 마진 추가 'nodesep': '0.4', # 노드 간 간격 줄임 (0.6 -> 0.4) 'ranksep': '0.6', # 랭크 간 간격 줄임 (0.8 -> 0.6) 'fontname': 'NanumGothic-Regular', 'charset': 'UTF-8', 'dpi': '96', # DPI 줄임 (150 -> 96) 'size': '10,7.5', # 전체 그래프 크기 제한 (인치 단위) 'ratio': 'compress' # 비율 압축 }, node_attr={ 'fontname': 'NanumGothic-Regular', 'fontsize': '12', # 폰트 크기 줄임 (14 -> 12) 'charset': 'UTF-8', 'height': '0.6', # 노드 높이 지정 'width': '2.0', # 노드 너비 지정 'margin': '0.2,0.1' # 노드 내부 마진 }, edge_attr={ 'fontname': 'NanumGothic-Regular', 'fontsize': '9', # 엣지 폰트 크기 줄임 (10 -> 9) 'charset': 'UTF-8' } ) base_color = '#19191a' fill_color_for_nodes = base_color font_color_for_nodes = 'white' if base_color == '#19191a' or base_color.lower() in ['#000000', '#19191a'] else 'black' # Store all nodes by ID for easy lookup all_defined_nodes = {node['id']: node for node in data['nodes']} # Add start node explicitly start_node_id = data['start_node'] dot.node( start_node_id, start_node_id, shape=node_shapes['start'], style='filled,rounded', fillcolor='#2196F3', fontcolor='white', fontsize='12', height='0.5', width='1.8' ) # Add all other nodes for node_id, node_info in all_defined_nodes.items(): if node_id == start_node_id: continue node_type = node_info.get("type", "default") shape = node_shapes.get(node_type, "box") node_label = node_info['label'] # 노드 타입에 따른 크기 조정 if node_type == 'decision': height = '0.8' width = '1.5' else: height = '0.6' width = '2.0' if node_type == 'end': dot.node( node_id, node_label, shape=shape, style='filled,rounded', fillcolor='#F44336', fontcolor='white', fontsize='12', height='0.5', width='1.8' ) else: dot.node( node_id, node_label, shape=shape, style='filled,rounded', fillcolor=fill_color_for_nodes, fontcolor=font_color_for_nodes, fontsize='12', height=height, width=width ) # Add connections (edges) for connection in data['connections']: dot.edge( connection['from'], connection['to'], label=connection.get('label', ''), color='#4a4a4a', fontcolor='#4a4a4a', fontsize='9' ) # PNG로 직접 렌더링 (크기 조정됨) with NamedTemporaryFile(delete=False, suffix=f'.{output_format}') as tmp: dot.render(tmp.name, format=output_format, cleanup=True) png_path = f"{tmp.name}.{output_format}" # 생성된 이미지를 PIL로 열어서 크기 확인 및 조정 from PIL import Image with Image.open(png_path) as img: width, height = img.size print(f"[프로세스 플로우] 생성된 이미지 크기: {width}x{height}") # 이미지가 너무 크면 리사이즈 max_width = 1200 max_height = 900 if width > max_width or height > max_height: # 비율 유지하면서 리사이즈 ratio = min(max_width/width, max_height/height) new_width = int(width * ratio) new_height = int(height * ratio) img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # 여백 추가하여 중앙 정렬 final_img = Image.new('RGB', (max_width, max_height), 'white') x = (max_width - new_width) // 2 y = (max_height - new_height) // 2 final_img.paste(img_resized, (x, y)) # 새로운 파일로 저장 new_path = png_path.replace('.png', '_resized.png') final_img.save(new_path) os.unlink(png_path) # 원본 삭제 return new_path return png_path except json.JSONDecodeError: return "Error: Invalid JSON format" except Exception as e: return f"Error: {str(e)}" def generate_process_flow_for_ppt(topic: str, context: str, style: str = "Business Workflow") -> Image.Image: """ PPT 생성기에서 사용할 프로세스 플로우 다이어그램 생성 """ print(f"[프로세스 플로우] 생성 시작 - 주제: {topic}, 컨텍스트: {context}") # 한글 폰트 재설정 (매번 확인) setup_korean_font_env() # 컨텍스트 분석하여 적절한 프로세스 플로우 JSON 생성 context_lower = context.lower() # 노드 수를 줄이고 레이블을 짧게 만들어 공간 절약 if "프로젝트" in context or "project" in context_lower: flow_json = { "start_node": "시작", "nodes": [ {"id": "plan", "label": "기획", "type": "process"}, {"id": "design", "label": "설계", "type": "process"}, {"id": "develop", "label": "개발", "type": "process"}, {"id": "test", "label": "테스트", "type": "decision"}, {"id": "deploy", "label": "배포", "type": "process"}, {"id": "end", "label": "완료", "type": "end"} ], "connections": [ {"from": "시작", "to": "plan", "label": ""}, {"from": "plan", "to": "design", "label": ""}, {"from": "design", "to": "develop", "label": ""}, {"from": "develop", "to": "test", "label": ""}, {"from": "test", "to": "deploy", "label": "통과"}, {"from": "test", "to": "develop", "label": "수정"}, {"from": "deploy", "to": "end", "label": ""} ] } elif "작동" in context or "기능" in context: flow_json = { "start_node": "시작", "nodes": [ {"id": "input", "label": "입력", "type": "io"}, {"id": "validate", "label": "검증", "type": "decision"}, {"id": "process", "label": "처리", "type": "process"}, {"id": "output", "label": "출력", "type": "io"}, {"id": "end", "label": "종료", "type": "end"} ], "connections": [ {"from": "시작", "to": "input", "label": ""}, {"from": "input", "to": "validate", "label": ""}, {"from": "validate", "to": "process", "label": "유효"}, {"from": "validate", "to": "input", "label": "재입력"}, {"from": "process", "to": "output", "label": ""}, {"from": "output", "to": "end", "label": ""} ] } else: # 기본 프로세스 플로우 (더 간단하게) flow_json = { "start_node": "시작", "nodes": [ {"id": "analyze", "label": "분석", "type": "process"}, {"id": "plan", "label": "계획", "type": "process"}, {"id": "execute", "label": "실행", "type": "process"}, {"id": "check", "label": "검토", "type": "decision"}, {"id": "complete", "label": "완료", "type": "end"} ], "connections": [ {"from": "시작", "to": "analyze", "label": ""}, {"from": "analyze", "to": "plan", "label": ""}, {"from": "plan", "to": "execute", "label": ""}, {"from": "execute", "to": "check", "label": ""}, {"from": "check", "to": "complete", "label": "승인"}, {"from": "check", "to": "plan", "label": "수정"} ] } # JSON을 문자열로 변환 json_str = json.dumps(flow_json, ensure_ascii=False) print(f"[프로세스 플로우] JSON 생성 완료") # 프로세스 플로우 다이어그램 생성 png_path = generate_process_flow_diagram(json_str, 'png') if png_path.startswith("Error:"): print(f"[프로세스 플로우] 생성 실패: {png_path}") # 에러 발생 시 기본 이미지 반환 from PIL import ImageDraw, ImageFont img = Image.new('RGB', (1200, 900), 'white') draw = ImageDraw.Draw(img) try: if FONT_PATH and os.path.exists(FONT_PATH): font = ImageFont.truetype(FONT_PATH, 20) else: font = ImageFont.load_default() except: font = ImageFont.load_default() draw.text((600, 450), "프로세스 플로우 생성 실패", fill='red', anchor='mm', font=font) return img print(f"[프로세스 플로우] PNG 생성 성공: {png_path}") # PNG 파일을 PIL Image로 변환 with Image.open(png_path) as img: img_copy = img.copy() # 임시 파일 삭제 try: os.unlink(png_path) except: pass return img_copy