#!/usr/bin/env python3 """ Hand-Drawn Diagram Generator 零成本 SVG 图表生成器 - 不调用任何 AI API Usage: from generator import DiagramGenerator gen = DiagramGenerator() gen.create_architecture( title="My System", layers=[ {"name": "Frontend", "items": ["React", "Vue"], "color": "frontend"}, {"name": "Backend", "items": ["API", "Worker"], "color": "backend"}, ], output="/tmp/my_diagram.html" ) """ from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, field from datetime import datetime from pathlib import Path import json # ============ 风格配置系统(从 fireworks-tech-graph 学习) ============ STYLE_PROFILES = { "sketch": { "name": "Hand-Drawn Sketch", "background": "#020617", "surface": "#0f172a", "grid_stroke": "#1e293b", "colors": { "frontend": ("rgba(8, 51, 68, 0.4)", "#22d3ee"), "backend": ("rgba(6, 78, 59, 0.4)", "#34d399"), "database": ("rgba(76, 29, 149, 0.4)", "#a78bfa"), "cloud": ("rgba(120, 53, 15, 0.3)", "#fbbf24"), "security": ("rgba(136, 19, 55, 0.4)", "#fb7185"), "message": ("rgba(251, 146, 60, 0.3)", "#fb923c"), "generic": ("rgba(30, 41, 59, 0.5)", "#94a3b8"), }, "text": { "primary": "#e2e8f0", "secondary": "#94a3b8", "muted": "#64748b", "on_surface": "#cbd5e1", }, "container": { "fill": "rgba(15, 23, 42, 0.3)", "stroke": "#64748b", "label_bg": "#0f172a", }, "legend": { "bg": "rgba(15, 23, 42, 0.8)", "stroke": "#1e293b", }, "arrow": { "default": "#64748b", "cyan": "#22d3ee", "green": "#34d399", "rose": "#fb7185", "amber": "#fbbf24", "purple": "#a78bfa", }, "font_family": "'Noto Sans Mono CJK SC', 'JetBrains Mono', 'WenQuanYi Zen Hei Mono', monospace", "html": { "title_color": "white", "subtitle_color": "#94a3b8", "bg": "#020617", "surface": "rgba(15, 23, 42, 0.5)", "border": "#1e293b", "card_bg": "rgba(15, 23, 42, 0.5)", "card_border": "#1e293b", "card_title": "#e2e8f0", "card_text": "#94a3b8", "footer": "#64748b", "pulse_dot": "#22d3ee", }, }, "flat": { "name": "Flat Professional", "background": "#ffffff", "surface": "#f8fafc", "grid_stroke": "#e2e8f0", "colors": { "frontend": ("#f0f9ff", "#0ea5e9"), "backend": ("#f0fdf4", "#22c55e"), "database": ("#faf5ff", "#a855f7"), "cloud": ("#fffbeb", "#f59e0b"), "security": ("#fff1f2", "#f43f5e"), "message": ("#fff7ed", "#f97316"), "generic": ("#f8fafc", "#64748b"), }, "text": { "primary": "#1e293b", "secondary": "#475569", "muted": "#94a3b8", "on_surface": "#334155", }, "container": { "fill": "rgba(248, 250, 252, 0.8)", "stroke": "#cbd5e1", "label_bg": "#ffffff", }, "legend": { "bg": "#f8fafc", "stroke": "#e2e8f0", }, "arrow": { "default": "#94a3b8", "cyan": "#0ea5e9", "green": "#22c55e", "rose": "#f43f5e", "amber": "#f59e0b", "purple": "#a855f7", }, "font_family": "'Noto Sans CJK SC', '-apple-system', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif", "html": { "title_color": "#0f172a", "subtitle_color": "#64748b", "bg": "#f8fafc", "surface": "#ffffff", "border": "#e2e8f0", "card_bg": "#ffffff", "card_border": "#e2e8f0", "card_title": "#1e293b", "card_text": "#475569", "footer": "#94a3b8", "pulse_dot": "#0ea5e9", }, }, } def _get_style(name: str = "sketch") -> dict: """获取风格配置,支持默认回退""" return STYLE_PROFILES.get(name, STYLE_PROFILES["sketch"]) # 向后兼容:默认使用 sketch 风格的配色 COLORS = STYLE_PROFILES["sketch"]["colors"] @dataclass class Component: """架构组件""" name: str type: str = "generic" # frontend/backend/database/cloud/security/generic x: int = 0 y: int = 0 width: int = 120 height: int = 50 sublabel: str = "" # 副标题/端口号 description: str = "" # 描述文字(多行) shape: str = "" # 语义形状: box(默认)/double/cylinder/hexagon/diamond/cloud @dataclass class Container: """分组容器(虚线框)- 如 "FastAPI Backend" 这样的组""" name: str x: int = 0 y: int = 0 width: int = 300 height: int = 150 color: str = "#64748b" # 边框颜色 @dataclass class Connection: """组件连接""" from_comp: str to_comp: str style: str = "solid" # solid/dashed/dotted color: str = "#64748b" label: str = "" type: str = "data" # data/auth/config - 决定箭头样式 class SVGBuilder: """SVG 构建器""" def __init__(self, width: int = 1000, height: int = 600, style: str = "sketch"): self.width = width self.height = height self.elements = [] self.defs = [] self.style = style if isinstance(style, dict) else _get_style(style) self._add_default_defs() def _add_default_defs(self): """添加默认定义(箭头、网格、滤镜等)""" s = self.style arrow = s["arrow"] self.defs = [ # 网格图案 f'', f' ', f'', # 阴影滤镜 '', ' ', '', # 标准箭头(灰色 - 数据流) f'', f' ', f'', # 青色箭头(前端) f'', f' ', f'', # 绿色箭头(后端) f'', f' ', f'', # 玫瑰色箭头(认证/安全) f'', f' ', f'', # 琥珀色箭头(配置) f'', f' ', f'', # 紫色箭头(数据库) f'', f' ', f'', ] def add_rect(self, x: int, y: int, width: int, height: int, fill: str, stroke: str, stroke_width: float = 1.5, rx: int = 6, opacity: float = 1.0): """添加矩形""" self.elements.append( f'' ) def add_text(self, x: int, y: int, text: str, fill: str = "white", font_size: int = 11, font_weight: str = "normal", text_anchor: str = "middle"): """添加文本""" # 转义特殊字符 text = text.replace("&", "&").replace("<", "<").replace(">", ">") self.elements.append( f'{text}' ) def add_line(self, x1: int, y1: int, x2: int, y2: int, stroke: str = "#64748b", stroke_width: float = 1.5, dashed: bool = False, marker_end: str = "url(#arrowhead)"): """添加连线""" dash_attr = ' stroke-dasharray="4,2"' if dashed else '' self.elements.append( f'' ) def add_component_box(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic", sublabel: str = "", description: str = ""): """添加组件框(带背景+样式+多行描述) Args: height: 组件高度。如果为0或None,自动根据文字内容计算 """ colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) center_x = x + width // 2 # 计算高度 if height and height > 0: # 使用用户指定的高度 base_height = height else: # 自动根据文字内容计算 base_height = 50 if description: # 估算描述行数 desc_lines = description.count('\\n') + 1 if '\\n' in description else 1 base_height = max(50, 35 + desc_lines * 14) if sublabel: base_height += 14 # 不透明背景(遮挡箭头) self.add_rect(x, y, width, base_height, self.style["surface"], stroke, rx=6, opacity=1.0) # 样式层 self.add_rect(x, y, width, base_height, fill, stroke, rx=6, opacity=1.0) # 标签 self.add_text(center_x, y + 18, label, fill=self.style["text"]["primary"], font_weight="600", font_size=11) # 子标签(端口号等) if sublabel: self.add_text(center_x, y + 32, sublabel, fill=self.style["text"]["secondary"], font_size=8) start_y = y + 46 else: start_y = y + 32 # 描述文字(多行支持) if description: desc_y = start_y for line in description.split('\\n'): self.add_text(center_x, desc_y, line.strip(), fill=self.style["text"]["on_surface"], font_size=8) desc_y += 12 def add_container_box(self, x: int, y: int, width: int, height: int, label: str, color: str = None): """添加分组容器(虚线框)- 如 FastAPI Backend""" if color is None: color = self.style["container"]["stroke"] c = self.style["container"] # 虚线边框 self.elements.append( f'' ) # 标签(左上角) self.elements.append( f'' ) self.add_text(x + 20 + len(label)*4, y + 3, label, fill=color, font_size=9, font_weight="500") def add_connection(self, x1: int, y1: int, x2: int, y2: int, style: str = "solid", color: str = None, label: str = "", conn_type: str = "data"): if color is None: color = self.style["arrow"]["default"] """添加带样式的连接线 Args: style: solid/dashed/dotted conn_type: data/auth/config - 决定箭头颜色 """ # 选择箭头 arrow_map = { "data": "url(#arrowhead)", "auth": "url(#arrowhead-rose)", "config": "url(#arrowhead-amber)", "frontend": "url(#arrowhead-cyan)", "backend": "url(#arrowhead-green)", "database": "url(#arrowhead-purple)", } marker = arrow_map.get(conn_type, "url(#arrowhead)") # 处理样式 dash_attr = "" if style == "dashed": dash_attr = ' stroke-dasharray="5,3"' elif style == "dotted": dash_attr = ' stroke-dasharray="2,2"' self.elements.append( f'' ) # 添加标签 if label: mid_x = (x1 + x2) // 2 mid_y = (y1 + y2) // 2 # 标签背景 label_width = len(label) * 7 self.elements.append( f'' ) self.add_text(mid_x, mid_y + 3, label, fill=color, font_size=8) def add_diamond(self, x: int, y: int, size: int, label: str): """添加菱形(判断节点)""" cx, cy = x + size // 2, y + size // 2 half = size // 2 points = f"{cx},{y} {x+size},{cy} {cx},{y+size} {x},{cy}" colors = self.style["colors"] fill, stroke = colors.get("message", colors["generic"]) self.elements.append( f'' ) self.add_text(cx, cy + 4, label, fill=self.style["text"]["primary"], font_size=10) def add_rounded_rect(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic"): """添加圆角矩形(开始/结束节点)""" colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) self.add_rect(x, y, width, height, fill, stroke, rx=height//2) self.add_text(x + width//2, y + height//2 + 4, label, fill=self.style["text"]["primary"], font_weight="600") # ============ 语义形状(从 fireworks-tech-graph 学习) ============ def add_double_border_box(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic", sublabel: str = ""): """双边框圆角矩形 — LLM / 模型 / AI 节点""" colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) center_x = x + width // 2 # 外框 self.elements.append( f'' ) # 内框(主框) self.add_rect(x, y, width, height, fill, stroke, rx=6) # 标签 self.add_text(center_x, y + height//2 + 4, label, fill=self.style["text"]["primary"], font_weight="600", font_size=11) if sublabel: self.add_text(center_x, y + height - 10, sublabel, fill=self.style["text"]["secondary"], font_size=8) def add_cylinder(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic", sublabel: str = ""): """圆柱体 — 数据库 / 长期记忆 / 存储节点""" colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) center_x = x + width // 2 ry = 10 # 椭圆弧度 # 主体 self.elements.append( f'' ) # 顶部椭圆 self.elements.append( f'' ) # 标签 self.add_text(center_x, y + height//2 + 4, label, fill=self.style["text"]["primary"], font_weight="600", font_size=11) if sublabel: self.add_text(center_x, y + height - 8, sublabel, fill=self.style["text"]["secondary"], font_size=8) def add_hexagon(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic", sublabel: str = ""): """六边形 — Agent / 编排器 / API 网关""" colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) center_x = x + width // 2 cy = y + height // 2 hw = width // 2 hh = height // 2 inset = min(hw * 0.25, 15) points = (f"{x+inset},{y} {x+width-inset},{y} " f"{x+width},{cy} {x+width-inset},{y+height} " f"{x+inset},{y+height} {x},{cy}") self.elements.append( f'' ) self.add_text(center_x, cy + 4, label, fill=self.style["text"]["primary"], font_weight="600", font_size=11) if sublabel: self.add_text(center_x, y + height - 10, sublabel, fill=self.style["text"]["secondary"], font_size=8) def add_cloud_shape(self, x: int, y: int, width: int, height: int, label: str, color_type: str = "generic"): """云朵形状 — 云服务 / 外部 SaaS""" colors = self.style["colors"] fill, stroke = colors.get(color_type, colors["cloud"]) center_x = x + width // 2 # 用几个圆弧拼成云朵 r = height * 0.35 self.elements.append( f'' ) self.add_text(center_x, y + height//2 + 4, label, fill=self.style["text"]["primary"], font_weight="600", font_size=11) def build(self) -> str: """构建完整 SVG""" defs_str = "\n ".join([""] + self.defs + [""]) elements_str = "\n ".join(self.elements) font = self.style.get("font_family", "sans-serif") return f''' {defs_str} {elements_str} ''' class DiagramGenerator: """图表生成器主类""" def __init__(self, style: str = "sketch"): self.template_dir = Path(__file__).parent / "assets" self.style = _get_style(style) def _wrap_html(self, title: str, svg_content: str, cards: List[Dict] = None) -> str: """包装为完整 HTML""" s = self.style html = s["html"] cards_html = "" if cards: cards_content = "" colors = s["colors"] color_map = {name: stroke for name, (fill, stroke) in colors.items()} for card in cards: dot_color = card.get("color", "generic") dot_color_hex = color_map.get(dot_color, color_map["generic"]) items_html = "\n".join([f"
  • {item}
  • " for item in card.get("items", [])]) cards_content += f'''

    {card.get("title", "")}

      {items_html}
    ''' cards_html = f'
    {cards_content}
    ' timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") style_name = "Hand-Drawn SVG" if s["name"] == "Hand-Drawn Sketch" else "Flat Professional SVG" # 使用系统字体,避免截图时等待外部字体加载 font_link = "" return f''' {title} {font_link}

    {title}

    系统架构图 · {style_name}
    {svg_content}
    {cards_html}
    ''' def _auto_layout(self, components: List[Component], connections: List[Connection]) -> Dict[str, Tuple[int, int]]: """自动计算组件位置,避免重叠 采用分层布局算法: 1. 根据连接关系确定层级(拓扑排序) 2. 同一层级的组件水平排列 3. 不同层级垂直排列 """ if not components: return {} # 构建邻接表和入度表 graph = {c.name: [] for c in components} in_degree = {c.name: 0 for c in components} for conn in connections: if conn.from_comp in graph and conn.to_comp in graph: graph[conn.from_comp].append(conn.to_comp) in_degree[conn.to_comp] += 1 # 拓扑排序确定层级 levels = {} queue = [name for name, deg in in_degree.items() if deg == 0] current_level = 0 while queue: next_queue = [] for name in queue: levels[name] = current_level for neighbor in graph[name]: in_degree[neighbor] -= 1 if in_degree[neighbor] == 0: next_queue.append(neighbor) queue = next_queue current_level += 1 # 未处理的节点(孤立节点)放在最后一层 max_level = max(levels.values()) if levels else 0 for name in in_degree: if name not in levels: levels[name] = max_level # 按层级分组 level_groups = {} for name, level in levels.items(): if level not in level_groups: level_groups[level] = [] level_groups[level].append(name) # 计算位置 positions = {} level_height = 110 # 每层高度 start_y = 60 for level, names in sorted(level_groups.items()): # 同层组件水平居中排列 comp_objs = [c for c in components if c.name in names] total_width = sum(c.width for c in comp_objs) + (len(comp_objs) - 1) * 40 start_x = max(60, (1000 - total_width) // 2) x = start_x y = start_y + level * level_height for comp in comp_objs: positions[comp.name] = (x, y) x += comp.width + 40 return positions def create_architecture(self, title: str, components: List[Component], connections: List[Connection], output: str, containers: List[Container] = None, cards: List[Dict] = None, auto_layout: bool = True, format: str = "html") -> str: """创建架构图(支持容器/分组框) Args: containers: 分组容器列表(虚线框),如 FastAPI Backend auto_layout: 是否自动计算位置(默认 True),设为 False 使用组件的 x,y format: "html" 或 "svg" """ containers = containers or [] # 自动布局或保留原位置 if auto_layout and components: positions = self._auto_layout(components, connections) for comp in components: if comp.name in positions: comp.x, comp.y = positions[comp.name] # 计算 SVG 尺寸 max_x = max([c.x + c.width for c in components]) if components else 800 max_y = max([c.y + c.height for c in components]) if components else 400 # 考虑容器边界 for cont in containers: max_x = max(max_x, cont.x + cont.width) max_y = max(max_y, cont.y + cont.height) width = max(1100, max_x + 100) height = max(700, max_y + 150) svg = SVGBuilder(width, height, style=self.style) # 先添加容器(在最底层) for cont in containers: svg.add_container_box(cont.x, cont.y, cont.width, cont.height, cont.name, cont.color) # 添加组件(根据语义 shape 自动选择绘制方法) comp_map = {c.name: c for c in components} for comp in components: shape = comp.shape or "" # 若未显式指定 shape,根据 type 做默认推断 if not shape: shape_map = { "llm": "double", "model": "double", "ai": "double", "db": "cylinder", "database": "cylinder", "storage": "cylinder", "agent": "hexagon", "orchestrator": "hexagon", "cloud": "cloud", "saas": "cloud", "decision": "diamond", "choice": "diamond", } shape = shape_map.get(comp.type, "box") if shape == "double": svg.add_double_border_box( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type, comp.sublabel) elif shape == "cylinder": svg.add_cylinder( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type, comp.sublabel) elif shape == "hexagon": svg.add_hexagon( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type, comp.sublabel) elif shape == "cloud": svg.add_cloud_shape( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type) elif shape == "diamond": svg.add_diamond( comp.x, comp.y, min(comp.width, comp.height), comp.name) else: svg.add_component_box( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type, comp.sublabel, comp.description ) # 添加连线(智能路径,避开组件) for conn in connections: if conn.from_comp in comp_map and conn.to_comp in comp_map: from_c = comp_map[conn.from_comp] to_c = comp_map[conn.to_comp] # 智能计算连接点 x1, y1, x2, y2 = self._calculate_connection_points(from_c, to_c) # 使用新的连接方法 svg.add_connection(x1, y1, x2, y2, conn.style, conn.color, conn.label, conn.type) # 添加图例(增强版) self._add_enhanced_legend(svg, width, height) svg_content = svg.build() return self._save(title, svg_content, output, format, cards) def _save(self, title: str, svg_content: str, output: str, format: str = "html", cards: list = None) -> str: """统一保存输出,支持 html / svg / png""" if format == "svg": Path(output).write_text(svg_content, encoding='utf-8') elif format == "png": # 先写临时 HTML(需要完整页面才能正确渲染) html = self._wrap_html(title, svg_content, cards) tmp_html = output.rsplit(".", 1)[0] + "_tmp.html" Path(tmp_html).write_text(html, encoding='utf-8') try: from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.set_viewport_size({"width": 1200, "height": 3200}) page.goto(f"file://{tmp_html}", wait_until="domcontentloaded") page.wait_for_timeout(500) el = page.query_selector("svg") if el: el.screenshot(path=output) else: page.screenshot(path=output, full_page=True) browser.close() except Exception: # Playwright 不可用时回退到 chromium-browser 命令行 import subprocess cmd = [ "chromium-browser", "--headless", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", f"--screenshot={output}", f"--window-size=1280,3200", f"file://{tmp_html}" ] subprocess.run(cmd, capture_output=True, timeout=30) Path(tmp_html).unlink(missing_ok=True) else: html = self._wrap_html(title, svg_content, cards) Path(output).write_text(html, encoding='utf-8') return output def _calculate_connection_points(self, from_c: Component, to_c: Component) -> Tuple[int, int, int, int]: """智能计算组件间连接点,避开组件内部""" # 确定相对位置 dx = to_c.x - from_c.x dy = to_c.y - from_c.y # 默认从底部中心到顶部中心(上层到下层的标准流向) if dy > 0: # to_c 在 from_c 下方 x1 = from_c.x + from_c.width // 2 y1 = from_c.y + from_c.height x2 = to_c.x + to_c.width // 2 y2 = to_c.y elif dy < 0: # to_c 在 from_c 上方 x1 = from_c.x + from_c.width // 2 y1 = from_c.y x2 = to_c.x + to_c.width // 2 y2 = to_c.y + to_c.height else: # 同一水平线 if dx > 0: # to_c 在右边 x1 = from_c.x + from_c.width y1 = from_c.y + from_c.height // 2 x2 = to_c.x y2 = to_c.y + to_c.height // 2 else: # to_c 在左边 x1 = from_c.x y1 = from_c.y + from_c.height // 2 x2 = to_c.x + to_c.width y2 = to_c.y + to_c.height // 2 return x1, y1, x2, y2 def _add_enhanced_legend(self, svg: SVGBuilder, width: int, height: int): """添加增强版图例(包含连接线说明)""" s = svg.style y_pos = height - 70 x_start = 30 # 图例背景 svg.add_rect(x_start, y_pos, 850, 60, s["legend"]["bg"], s["legend"]["stroke"], rx=6) svg.add_text(x_start + 15, y_pos + 20, "组件类型:", fill=s["text"]["muted"], font_size=10, text_anchor="start") # 组件图例 legend_items = [ ("frontend", "前端"), ("backend", "后端"), ("database", "数据库"), ("cloud", "外部API"), ("security", "认证"), ("generic", "通用") ] x = x_start + 90 colors = s["colors"] for color_type, label in legend_items: fill, stroke = colors[color_type] svg.add_rect(x, y_pos + 8, 16, 16, fill, stroke, rx=3) svg.add_text(x + 22, y_pos + 20, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start") x += 75 # 连接线说明 svg.add_text(x_start + 15, y_pos + 45, "连接类型:", fill=s["text"]["muted"], font_size=10, text_anchor="start") arrow = s["arrow"] conn_items = [ ("data", "数据流", arrow["default"]), ("auth", "认证流", arrow["rose"]), ("config", "配置", arrow["amber"]), ] x = x_start + 90 for conn_type, label, color in conn_items: arrow_map = { "data": "url(#arrowhead)", "auth": "url(#arrowhead-rose)", "config": "url(#arrowhead-amber)", } marker = arrow_map.get(conn_type, "url(#arrowhead)") svg.elements.append( f'' ) svg.add_text(x + 28, y_pos + 44, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start") x += 75 def _add_legend(self, svg: SVGBuilder, width: int, height: int): """添加图例""" s = svg.style y_pos = height - 60 x_start = 30 # 图例背景 svg.add_rect(x_start, y_pos, 600, 45, s["legend"]["bg"], s["legend"]["stroke"], rx=6) svg.add_text(x_start + 15, y_pos + 28, "图例:", fill=s["text"]["muted"], font_size=10, text_anchor="start") # 图例项 legend_items = [ ("frontend", "前端"), ("backend", "后端"), ("database", "数据库"), ("cloud", "外部API"), ("security", "认证"), ("generic", "通用") ] x = x_start + 60 colors = s["colors"] for color_type, label in legend_items: fill, stroke = colors[color_type] svg.add_rect(x, y_pos + 12, 16, 16, fill, stroke, rx=3) svg.add_text(x + 22, y_pos + 25, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start") x += 80 def create_flowchart(self, title: str, steps: List[Dict], output: str, format: str = "html") -> str: """创建流程图 steps: [{"type": "start|process|decision|end", "label": "...", "next": "..."}] """ svg = SVGBuilder(800, 100 + len(steps) * 80, style=self.style) y = 50 x = 400 for step in steps: step_type = step.get("type", "process") label = step.get("label", "") if step_type == "start": svg.add_rounded_rect(x - 60, y, 120, 40, label, "backend") elif step_type == "end": svg.add_rounded_rect(x - 60, y, 120, 40, label, "security") elif step_type == "decision": svg.add_diamond(x - 40, y, 80, label) else: # process svg.add_component_box(x - 80, y, 160, 50, label, "generic") y += 80 svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def create_pyramid(self, title: str, levels: List[Dict], output: str, width: int = 700, format: str = "html") -> str: """创建金字塔/漏斗图 Args: levels: 从顶层到底层的列表 [{"label": "重要", "value": "10%", "color": "security"}, ...] width: 金字塔底边宽度 """ level_height = 60 padding = 80 height = padding * 2 + len(levels) * level_height svg = SVGBuilder(width, height, style=self.style) # 计算每一层的宽度(从顶到底递增) center_x = width // 2 max_half_width = (width - 100) // 2 # 留边距 y = padding for i, level in enumerate(levels): # 线性插值计算这层的一半宽度 ratio = (i + 1) / len(levels) half_w = int(30 + (max_half_width - 30) * ratio) # 顶层30px,底层max label = level.get("label", "") value = level.get("value", "") color_type = level.get("color", "backend") colors = svg.style["colors"] fill, stroke = colors.get(color_type, colors["backend"]) # 梯形坐标 if i == 0: # 顶层是矩形(或很小的梯形) x1, x2 = center_x - half_w, center_x + half_w else: # 使用上一层的底部宽度作为这层的顶部 prev_ratio = i / len(levels) prev_half_w = int(30 + (max_half_width - 30) * prev_ratio) x1 = center_x - half_w x2 = center_x + half_w x3 = center_x - prev_half_w x4 = center_x + prev_half_w # 绘制梯形 if i == 0: svg.add_rect(center_x - half_w, y, half_w * 2, level_height - 5, fill, stroke, rx=4) else: # 梯形多边形 prev_ratio = i / len(levels) prev_half_w = int(30 + (max_half_width - 30) * prev_ratio) x_top_left = center_x - prev_half_w x_top_right = center_x + prev_half_w x_bottom_left = center_x - half_w x_bottom_right = center_x + half_w y_top = y y_bottom = y + level_height - 5 points = f"{x_top_left},{y_top} {x_top_right},{y_top} {x_bottom_right},{y_bottom} {x_bottom_left},{y_bottom}" svg.elements.append( f'' ) # 标签文字 text_y = y + level_height // 2 + 4 svg.add_text(center_x, text_y, label, fill=svg.style["text"]["primary"], font_size=13, font_weight="600", text_anchor="middle") # 数值/百分比(右侧) if value: svg.add_text(center_x + half_w + 15, text_y, value, fill=svg.style["text"]["secondary"], font_size=11, text_anchor="start") y += level_height svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def create_quadrant(self, title: str, x_axis: Tuple[str, str], y_axis: Tuple[str, str], items: List[Dict], output: str, width: int = 800, height: int = 700, format: str = "html") -> str: """创建象限图 (2x2 矩阵) Args: x_axis: (左标签, 右标签) - 如 ("低成本", "高成本") y_axis: (下标签, 上标签) - 如 ("低影响", "高影响") items: [{"name": "...", "x": 0-1, "y": 0-1, "color": "...", "size": 1-3}, ...] x,y 是 0-1 的相对位置(左下角为0,0,右上角为1,1) """ svg = SVGBuilder(width, height, style=self.style) margin = 80 chart_w = width - margin * 2 chart_h = height - margin * 2 - 60 # 留空间给标题 center_x = width // 2 center_y = margin + chart_h // 2 + 30 # 下移一点给标题留空间 # 背景四象限 quad_fill = svg.style["container"]["fill"] quad_stroke = svg.style["legend"]["stroke"] # 左上象限 svg.add_rect(margin, center_y - chart_h//2, chart_w//2, chart_h//2, quad_fill, quad_stroke, rx=0) # 右上象限 svg.add_rect(center_x, center_y - chart_h//2, chart_w//2, chart_h//2, "rgba(30, 41, 59, 0.5)", quad_stroke, rx=0) # 左下象限 svg.add_rect(margin, center_y, chart_w//2, chart_h//2, "rgba(30, 41, 59, 0.5)", quad_stroke, rx=0) # 右下象限 svg.add_rect(center_x, center_y, chart_w//2, chart_h//2, quad_fill, quad_stroke, rx=0) axis_color = svg.style["text"]["muted"] # 中心轴线 svg.elements.append( f'' ) svg.elements.append( f'' ) # 轴标签 left_label, right_label = x_axis bottom_label, top_label = y_axis axis_label_color = svg.style["text"]["secondary"] # X轴标签 svg.add_text(margin + 10, height - margin + 20, left_label, fill=axis_label_color, font_size=11, text_anchor="start") svg.add_text(width - margin - 10, height - margin + 20, right_label, fill=axis_label_color, font_size=11, text_anchor="end") # Y轴标签(旋转90度) svg.elements.append( f'{bottom_label}' ) svg.elements.append( f'{top_label}' ) # 绘制项目点 for item in items: name = item.get("name", "") x_ratio = item.get("x", 0.5) y_ratio = item.get("y", 0.5) color_type = item.get("color", "frontend") size = item.get("size", 2) # 1-3 colors = svg.style["colors"] fill, stroke = colors.get(color_type, colors["frontend"]) # 计算实际坐标(y需要翻转,因为SVG y向下) x_pos = margin + x_ratio * chart_w y_pos = (center_y + chart_h//2) - y_ratio * chart_h radius = 8 + size * 4 # 16-32px # 绘制圆点 svg.elements.append( f'' ) # 标签(点上或点旁) svg.add_text(x_pos, y_pos - radius - 8, name, fill="#e2e8f0", font_size=11, font_weight="500", text_anchor="middle") svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def create_swimlane(self, title: str, lanes: List[Dict], steps: List[Dict], output: str, width: int = 1000, format: str = "html") -> str: """创建泳道图(跨职能流程图) Args: lanes: [{"name": "前端", "color": "frontend"}, ...] steps: [{"lane": 0, "step": 0, "label": "...", "type": "process|decision"}, ...] lane: 泳道索引(从0开始) step: 步骤顺序(从左到右,从0开始) """ lane_height = 100 step_width = 180 margin = 60 max_step = max([s.get("step", 0) for s in steps]) if steps else 0 height = margin * 2 + len(lanes) * lane_height width = max(width, margin * 2 + (max_step + 2) * step_width) svg = SVGBuilder(width, height, style=self.style) # 绘制泳道 for i, lane in enumerate(lanes): y = margin + i * lane_height name = lane.get("name", f"Lane {i}") color_type = lane.get("color", "generic") colors = svg.style["colors"] fill, stroke = colors.get(color_type, colors["generic"]) # 泳道背景 svg.add_rect(margin, y, width - margin * 2, lane_height - 2, svg.style["container"]["fill"], stroke, rx=0) # 泳道标签(左侧) svg.add_rect(margin, y + 10, 100, lane_height - 22, fill, stroke, rx=4) svg.add_text(margin + 50, y + lane_height//2 + 4, name, fill=svg.style["text"]["primary"], font_size=12, font_weight="600", text_anchor="middle") # 绘制步骤 for step in steps: lane_idx = step.get("lane", 0) step_idx = step.get("step", 0) label = step.get("label", "") step_type = step.get("type", "process") y = margin + lane_idx * lane_height + lane_height // 2 x = margin + 130 + step_idx * step_width if step_type == "decision": # 菱形 svg.add_diamond(x - 30, y - 25, 60, label) else: # 矩形 svg.add_component_box(x - 60, y - 25, 120, 50, label, "generic") # 绘制连接线(同泳道或跨泳道) for step_idx in range(max_step + 1): current_steps = [s for s in steps if s.get("step", 0) == step_idx] next_steps = [s for s in steps if s.get("step", 0) == step_idx + 1] for curr in current_steps: for nxt in next_steps: y1 = margin + curr["lane"] * lane_height + lane_height // 2 x1 = margin + 130 + step_idx * step_width + 60 y2 = margin + nxt["lane"] * lane_height + lane_height // 2 x2 = margin + 130 + (step_idx + 1) * step_width - 60 style = "solid" if curr["lane"] == nxt["lane"] else "dashed" svg.add_connection(x1, y1, x2, y2, style, svg.style["arrow"]["default"]) svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def create_timeline(self, title: str, events: List[Dict], output: str, width: int = 900, orientation: str = "horizontal", format: str = "html") -> str: """创建时间线图 Args: events: [{"date": "2024-Q1", "label": "...", "color": "...", "side": "top|bottom"}, ...] orientation: "horizontal" 或 "vertical" """ if orientation == "horizontal": return self._create_timeline_horizontal(title, events, output, width, format) else: return self._create_timeline_vertical(title, events, output, width, format) def _create_timeline_horizontal(self, title: str, events: List[Dict], output: str, width: int, format: str = "html") -> str: """水平时间线""" margin = 80 height = 400 timeline_y = height // 2 svg = SVGBuilder(width, height, style=self.style) # 主线 main_color = svg.style["arrow"]["cyan"] svg.elements.append( f'' ) # 事件节点 spacing = (width - margin * 2) // (len(events) + 1) for i, event in enumerate(events): x = margin + (i + 1) * spacing date = event.get("date", "") label = event.get("label", "") color_type = event.get("color", "backend") side = event.get("side", "top" if i % 2 == 0 else "bottom") colors = svg.style["colors"] fill, stroke = colors.get(color_type, colors["backend"]) # 节点圆点 svg.elements.append( f'' ) # 连接线 line_length = 40 if side == "top": y_end = timeline_y - line_length svg.elements.append( f'' ) self._add_timeline_card(svg, x, y_end - 50, date, label, fill, stroke, "top") else: y_end = timeline_y + line_length svg.elements.append( f'' ) self._add_timeline_card(svg, x, y_end + 10, date, label, fill, stroke, "bottom") svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def _create_timeline_vertical(self, title: str, events: List[Dict], output: str, width: int, format: str = "html") -> str: """垂直时间线""" margin = 80 event_height = 100 height = margin * 2 + len(events) * event_height timeline_x = width // 2 svg = SVGBuilder(width, height, style=self.style) # 主线 main_color = svg.style["arrow"]["cyan"] svg.elements.append( f'' ) for i, event in enumerate(events): y = margin + 30 + i * event_height date = event.get("date", "") label = event.get("label", "") color_type = event.get("color", "backend") colors = svg.style["colors"] fill, stroke = colors.get(color_type, colors["backend"]) # 节点 svg.elements.append( f'' ) # 交替左右显示 if i % 2 == 0: x_card = timeline_x - 200 svg.elements.append( f'' ) self._add_timeline_card(svg, x_card + 75, y - 25, date, label, fill, stroke, "right") else: x_card = timeline_x + 50 svg.elements.append( f'' ) self._add_timeline_card(svg, x_card + 75, y - 25, date, label, fill, stroke, "left") svg_content = svg.build() html = self._wrap_html(title, svg_content) return self._save(title, svg_content, output, format) def _add_timeline_card(self, svg: SVGBuilder, x: int, y: int, date: str, label: str, fill: str, stroke: str, align: str): """添加时间线事件卡片""" card_w, card_h = 150, 50 # 背景 svg.add_rect(x - card_w//2, y, card_w, card_h, "rgba(15, 23, 42, 0.9)", stroke, rx=6) # 日期 svg.add_text(x, y + 18, date, fill=stroke, font_size=10, font_weight="600", text_anchor="middle") # 标签 svg.add_text(x, y + 38, label, fill="#e2e8f0", font_size=11, text_anchor="middle") # ============ Markdown / Mermaid 支持 ============ def to_mermaid(title: str, layers: List[Dict]) -> str: """生成 Mermaid 流程图语法 可直接粘贴到 Markdown 中: ```markdown ```mermaid {生成的代码} ``` ``` """ lines = ["graph TD"] # 定义节点样式类 class_defs = [ " classDef frontend fill:#083344,stroke:#22d3ee,stroke-width:2px,color:#fff", " classDef backend fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#fff", " classDef database fill:#4c1d95,stroke:#a78bfa,stroke-width:2px,color:#fff", " classDef cloud fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#fff", " classDef security fill:#881337,stroke:#fb7185,stroke-width:2px,color:#fff", " classDef generic fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#fff", ] node_id = 0 prev_nodes = [] for layer in layers: layer_name = layer.get("name", "") items = layer.get("items", []) color = layer.get("color", "generic") current_nodes = [] for item in items: node_id += 1 safe_id = f"N{node_id}" # 转义特殊字符 safe_label = item.replace("(", "[").replace(")", "]").replace(" ", "_") lines.append(f' {safe_id}["{item}"]') lines.append(f' class {safe_id} {color}') current_nodes.append(safe_id) # 连接上一层的节点 if prev_nodes and current_nodes: for prev in prev_nodes: for curr in current_nodes: lines.append(f' {prev} --> {curr}') prev_nodes = current_nodes # 添加样式定义 lines.extend(class_defs) return "\n".join(lines) def to_svg_only(title: str, layers: List[Dict]) -> str: """生成纯 SVG 代码(可嵌入 Markdown HTML 块) 使用方式: ```markdown
    {生成的 SVG}
    ``` """ gen = DiagramGenerator() components = [] connections = [] y_pos = 50 node_id = 0 for layer in layers: color = layer.get("color", "generic") items = layer.get("items", []) total_width = len(items) * 140 - 20 start_x = max(50, (800 - total_width) // 2) for j, item in enumerate(items): node_id += 1 comp = Component( name=item, type=color, x=start_x + j * 140, y=y_pos, width=120, height=45 ) components.append(comp) y_pos += 100 # 计算 SVG 尺寸 max_x = max([c.x + c.width for c in components]) if components else 600 max_y = max([c.y + c.height for c in components]) if components else 300 svg = SVGBuilder(max_x + 100, max_y + 50) # 添加组件 for comp in components: svg.add_component_box( comp.x, comp.y, comp.width, comp.height, comp.name, comp.type ) # 添加层间连线 layer_items = {} for comp in components: # 按 y 坐标分组 y_key = comp.y if y_key not in layer_items: layer_items[y_key] = [] layer_items[y_key].append(comp) sorted_layers = sorted(layer_items.keys()) for i in range(len(sorted_layers) - 1): current_layer = layer_items[sorted_layers[i]] next_layer = layer_items[sorted_layers[i + 1]] for curr in current_layer: for next_c in next_layer: x1 = curr.x + curr.width // 2 y1 = curr.y + curr.height x2 = next_c.x + next_c.width // 2 y2 = next_c.y svg.add_line(x1, y1, x2, y2) return svg.build() class MarkdownDiagramHelper: """Markdown 图表辅助类 - 一键生成多种格式""" def __init__(self, title: str, layers: List[Dict]): self.title = title self.layers = layers def mermaid_code_block(self) -> str: """生成带 markdown 代码块的 Mermaid""" mermaid = to_mermaid(self.title, self.layers) return f"```mermaid\n{mermaid}\n```" def html_embedded(self) -> str: """生成可嵌入 Markdown 的 HTML""" svg = to_svg_only(self.title, self.layers) return f'''
    📊 {self.title} 架构图
    {svg}
    ''' def markdown_table(self) -> str: """生成架构表格(纯文本,最通用)""" lines = [f"### {self.title} 架构", ""] lines.append("| 层级 | 组件 | 类型 |") lines.append("|------|------|------|") type_names = { "frontend": "前端", "backend": "后端", "database": "数据库", "cloud": "云服务", "security": "安全", "generic": "通用" } for layer in self.layers: layer_name = layer.get("name", "") items = layer.get("items", []) color = layer.get("color", "generic") type_name = type_names.get(color, "通用") for item in items: lines.append(f"| {layer_name} | {item} | {type_name} |") return "\n".join(lines) def full_document(self) -> str: """生成完整的设计文档片段""" parts = [ f"## {self.title}", "", self.markdown_table(), "", "### 架构图", "", self.mermaid_code_block(), "", "### 详细视图", "", self.html_embedded(), ] return "\n".join(parts) # ============ 便捷函数 ============ def quick_architecture(title: str, layers: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str: """快速创建分层架构图 layers: [{"name": "Layer", "items": ["Item1", "Item2"], "color": "frontend"}] """ gen = DiagramGenerator(style=style) components = [] connections = [] cards = [] y_pos = 80 colors = ["frontend", "backend", "database", "cloud"] for i, layer in enumerate(layers): layer_color = layer.get("color", colors[i % len(colors)]) items = layer.get("items", []) # 计算布局 total_width = len(items) * 160 - 40 start_x = (1000 - total_width) // 2 layer_comps = [] display_items = [] for j, item in enumerate(items): shape = "" if isinstance(item, dict): name = item.get("label", "") color = item.get("color", layer_color) shape = item.get("shape", "") display_items.append(name) else: name = item color = layer_color display_items.append(name) comp = Component( name=name, type=color, x=start_x + j * 160, y=y_pos, width=120, height=50, shape=shape ) components.append(comp) layer_comps.append(comp) cards.append({ "title": layer.get("name", f"Layer {i+1}"), "color": layer_color, "items": display_items }) y_pos += 120 # 添加层间连接 for i in range(len(components) - 1): if i < len(components) - 1: connections.append(Connection( components[i].name, components[i+1].name )) fmt = format return gen.create_architecture(title, components, connections, output, cards=cards, auto_layout=False, format=fmt) def quick_pyramid(title: str, levels: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str: """快速创建金字塔图 levels: [{"label": "...", "value": "...", "color": "frontend|backend|..."}] """ gen = DiagramGenerator(style=style) return gen.create_pyramid(title, levels, output, format=format) def quick_quadrant(title: str, x_axis: Tuple[str, str], y_axis: Tuple[str, str], items: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str: """快速创建象限图 x_axis: (左, 右) 如 ("低成本", "高成本") y_axis: (下, 上) 如 ("低影响", "高影响") items: [{"name": "...", "x": 0-1, "y": 0-1, "color": "...", "size": 1-3}] """ gen = DiagramGenerator(style=style) return gen.create_quadrant(title, x_axis, y_axis, items, output, format=format) def quick_swimlane(title: str, lanes: List[Dict], steps: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str: """快速创建泳道图 lanes: [{"name": "前端", "color": "frontend"}] steps: [{"lane": 0, "step": 0, "label": "...", "type": "process|decision"}] """ gen = DiagramGenerator(style=style) return gen.create_swimlane(title, lanes, steps, output, format=format) def quick_timeline(title: str, events: List[Dict], output: str, orientation: str = "horizontal", style: str = "sketch", format: str = "html") -> str: """快速创建时间线 events: [{"date": "...", "label": "...", "color": "...", "side": "top|bottom"}] orientation: "horizontal" 或 "vertical" """ gen = DiagramGenerator(style=style) return gen.create_timeline(title, events, output, orientation=orientation, format=format) if __name__ == "__main__": import os os.makedirs("/tmp/diagram_tests", exist_ok=True) # 测试金字塔 quick_pyramid( "需求优先级金字塔", [ {"label": "必须有 (Must have)", "value": "20%", "color": "security"}, {"label": "应该有 (Should have)", "value": "30%", "color": "frontend"}, {"label": "可以有 (Could have)", "value": "35%", "color": "backend"}, {"label": "暂不需要 (Won't have)", "value": "15%", "color": "generic"}, ], "/tmp/diagram_tests/pyramid.html" ) print("Generated: /tmp/diagram_tests/pyramid.html") # 测试象限 quick_quadrant( "项目优先级矩阵", ("低影响", "高影响"), ("低成本", "高成本"), [ {"name": "快速获胜", "x": 0.2, "y": 0.8, "color": "backend", "size": 3}, {"name": "重大项目", "x": 0.8, "y": 0.9, "color": "security", "size": 3}, {"name": "填补空白", "x": 0.3, "y": 0.2, "color": "generic", "size": 2}, {"name": "避坑", "x": 0.9, "y": 0.1, "color": "cloud", "size": 1}, ], "/tmp/diagram_tests/quadrant.html" ) print("Generated: /tmp/diagram_tests/quadrant.html") # 测试泳道 quick_swimlane( "用户注册流程", [ {"name": "前端", "color": "frontend"}, {"name": "后端", "color": "backend"}, {"name": "外部", "color": "cloud"}, ], [ {"lane": 0, "step": 0, "label": "填写表单", "type": "process"}, {"lane": 0, "step": 1, "label": "提交", "type": "process"}, {"lane": 1, "step": 1, "label": "验证", "type": "decision"}, {"lane": 1, "step": 2, "label": "创建用户", "type": "process"}, {"lane": 2, "step": 2, "label": "发送邮件", "type": "process"}, {"lane": 0, "step": 3, "label": "成功页", "type": "process"}, ], "/tmp/diagram_tests/swimlane.html" ) print("Generated: /tmp/diagram_tests/swimlane.html") # 测试水平时间线 quick_timeline( "产品路线图", [ {"date": "Q1 2024", "label": "MVP 发布", "color": "backend", "side": "top"}, {"date": "Q2 2024", "label": "用户增长", "color": "frontend", "side": "bottom"}, {"date": "Q3 2024", "label": "企业版", "color": "security", "side": "top"}, {"date": "Q4 2024", "label": "国际化", "color": "cloud", "side": "bottom"}, ], "/tmp/diagram_tests/timeline_h.html" ) print("Generated: /tmp/diagram_tests/timeline_h.html") # 测试垂直时间线 quick_timeline( "开发里程碑", [ {"date": "3月", "label": "架构设计", "color": "backend"}, {"date": "4月", "label": "核心开发", "color": "frontend"}, {"date": "5月", "label": "测试上线", "color": "security"}, ], "/tmp/diagram_tests/timeline_v.html", orientation="vertical" ) print("Generated: /tmp/diagram_tests/timeline_v.html") print("\n所有测试图表已生成到 /tmp/diagram_tests/")